From 99f4f8b8209415bf7319a155f0b26a08014504ae Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 18 Sep 2026 17:04:04 +0700 Subject: [PATCH] feat: add openhands and opencode-web services openhands runs each agent session in a container it spawns through the host docker socket, so the socket is mounted read-write and host.docker.internal is resolved. No host workspace is exposed. opencode-web serves the opencode agent as a browser UI. The vendor image ships only the opencode binary on bare Alpine, so a local Dockerfile adds bash, git, curl and an ssh client. --- README.md | 2 + opencode-web/.env.example | 17 ++++++ opencode-web/Dockerfile | 10 ++++ opencode-web/README.md | 108 ++++++++++++++++++++++++++++++++++++++ opencode-web/compose.yml | 23 ++++++++ openhands/README.md | 87 ++++++++++++++++++++++++++++++ openhands/compose.yml | 15 ++++++ 7 files changed, 262 insertions(+) create mode 100644 opencode-web/.env.example create mode 100644 opencode-web/Dockerfile create mode 100644 opencode-web/README.md create mode 100644 opencode-web/compose.yml create mode 100644 openhands/README.md create mode 100644 openhands/compose.yml diff --git a/README.md b/README.md index bfe3c13..e845ec9 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Each links to its own README for variables, ports, and storage. | [gitea-mirror-local](gitea-mirror-local/README.md) | Gitea + PostgreSQL + gitea-mirror, mirroring GitHub repos | | [netdata](netdata/README.md) | Netdata monitoring agent | | [ollama](ollama/README.md) | Ollama LLM server | +| [opencode-web](opencode-web/README.md) | opencode coding agent, served as a browser UI | +| [openhands](openhands/README.md) | OpenHands coding agent, running each session in a container it spawns | | [paseo](paseo/README.md) | Paseo coding-agent daemon and web UI | | [traffmonetizer](traffmonetizer/README.md) | TraffMonetizer bandwidth-sharing client | diff --git a/opencode-web/.env.example b/opencode-web/.env.example new file mode 100644 index 0000000..b72eaf5 --- /dev/null +++ b/opencode-web/.env.example @@ -0,0 +1,17 @@ +# Copy to .env and fill in. Never commit .env. +# +# cp .env.example .env + +# Web UI and API login password. MUST NOT be blank -- opencode serves the web +# interface, and with it a shell on this container, to anyone when +# OPENCODE_SERVER_PASSWORD is unset. +# Generate one with: openssl rand -base64 24 +OPENCODE_SERVER_PASSWORD= + +# Username to pair with it. opencode defaults to `opencode` when unset. +OPENCODE_SERVER_USERNAME=opencode + +# Git identity for the commits the agent makes, as author and committer. git +# reads these directly, so no `git config` step is needed. +GIT_NAME= +GIT_EMAIL= diff --git a/opencode-web/Dockerfile b/opencode-web/Dockerfile new file mode 100644 index 0000000..de22303 --- /dev/null +++ b/opencode-web/Dockerfile @@ -0,0 +1,10 @@ +FROM ghcr.io/anomalyco/opencode:latest + +# Shell, git, HTTP and SSH clients for the commands the agent runs. +RUN apk add --no-cache \ + bash \ + curl \ + git \ + openssh-client + +WORKDIR /workspace diff --git a/opencode-web/README.md b/opencode-web/README.md new file mode 100644 index 0000000..27c02b5 --- /dev/null +++ b/opencode-web/README.md @@ -0,0 +1,108 @@ +# opencode-web + +[opencode](https://opencode.ai) served as a browser UI by `opencode web`, which +starts the headless server and its web interface together. The agent runs in +*this* container — there is no sandbox layer between it and the filesystem. + +Built from a local `Dockerfile`, because the official image is the `opencode` +binary on bare Alpine and nothing else: no shell, no `git`, no `curl`, no +`ssh`. An agent whose main tool is "run a command" has nothing to run, so one +apk layer puts them back. + +## Setup + +1. Set the variables from `.env.example` in Coolify or Dokploy. +2. Point the domain at port `4096` and deploy. +3. Log in to a model provider — the UI cannot do it, so use a shell: + + ```sh + docker compose exec opencode-web opencode auth login + ``` + + It is interactive, which is why it is not an environment variable; `exec` + gives it the TTY it needs. The credentials land on the `opencode-home` + volume and survive a redeploy. + +4. Open the domain and sign in with `OPENCODE_SERVER_USERNAME` and + `OPENCODE_SERVER_PASSWORD`. + +The container logs a Bun stack trace ending in +`Executable not found in $PATH: "xdg-open"` on every start. `opencode web` +tries to open the UI in a local browser; there isn't one. It is noise — the +server is already listening by then, and the container keeps running. +Installing `xdg-utils` would silence it at the cost of pulling X11 in and +tripling the image, which is not worth it for a log line. + +## Authentication + +`OPENCODE_SERVER_PASSWORD` is the only thing between the domain and a shell on +this container. opencode's own words: *"If `OPENCODE_SERVER_PASSWORD` is not +set, the server will be unsecured."* Unset, every request is served — and every +request can ask the agent to run a command. Treat a blank value as publishing a +root terminal. + +`--hostname 0.0.0.0` in `command:` is what makes the service reachable at all; +both `web` and `serve` bind `127.0.0.1` by default. `--port 4096` is there +because the default is `0`, a random port, which the platform cannot map a +domain to. + +If the UI is loaded from a different origin than it is served from, add +`--cors ` to `command:`. The default setup does not need it. + +## Environment + +| Variable | Purpose | +| --- | --- | +| `OPENCODE_SERVER_PASSWORD` | Web UI and API login. Blank means no authentication at all. Generate with `openssl rand -base64 24`. | +| `OPENCODE_SERVER_USERNAME` | Username to go with it. opencode falls back to `opencode`. | +| `GIT_NAME` / `GIT_EMAIL` | Git author and committer identity for the agent's commits. | + +`TZ` is set in `compose.yml` rather than here: it is a property of this setup, +not of whoever deploys it, and Compose interpolation would let a `TZ` exported +by the deploying shell win over the `.env` file anyway. + +Model provider credentials are not variables — see [Setup](#setup). + +## Storage + +| Volume | Mount | Holds | +| --- | --- | --- | +| `opencode-home` | `/root` | Provider credentials, config, session database, caches | +| `opencode-workspace` | `/workspace` | Code the agent works on | + +The whole home directory is one volume because opencode spreads its state over +four places under it — `.config/opencode`, `.local/share/opencode` (the SQLite +session database), `.local/state/opencode` and `.cache/opencode` — and mounting +them separately would only be three more chances to miss one. Dotfiles and +anything else installed into `$HOME` persist as a side effect. + +The container runs as root, which is what the upstream image does; `$HOME` is +`/root` because of it. + +Anything written outside those two volumes — an `apk add` from the agent's own +terminal — is lost on the next deploy. Add it to the `Dockerfile` instead. + +## Networking + +Listens on `4096`, published nowhere — the platform maps the domain to it. See +the [root README](../README.md) for why. + +## Image + +`FROM ghcr.io/anomalyco/opencode:latest`, the vendor image. The opencode repo +moved out of the `sst` organisation; `ghcr.io/sst/opencode` still exists but no +longer serves anonymous pulls, so it is not the one to use. + +The apk layer adds `bash`, `git`, `curl` and `openssh-client` — the floor for +an agent that clones, commits and fetches. It carries no language toolchain: +none is wanted often enough to justify rebuilding the image for everybody, and +`apk add` from a terminal covers a one-off. Something needed on every deploy +belongs in the `Dockerfile`, since `/usr` is not on a volume. + +`ENTRYPOINT` stays the image's own `opencode`, so `command:` in `compose.yml` +is just the subcommand and its flags. + +## Related + +- [openhands](../openhands/README.md) — a coding agent that runs each session in a container it spawns +- [paseo](../paseo/README.md) — runs the opencode CLI, among others, in a terminal diff --git a/opencode-web/compose.yml b/opencode-web/compose.yml new file mode 100644 index 0000000..13fab35 --- /dev/null +++ b/opencode-web/compose.yml @@ -0,0 +1,23 @@ +services: + opencode-web: + build: . + command: + - web + - '--hostname' + - 0.0.0.0 + - '--port' + - '4096' + environment: + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME} + - TZ=Asia/Ho_Chi_Minh + - GIT_AUTHOR_NAME=${GIT_NAME} + - GIT_AUTHOR_EMAIL=${GIT_EMAIL} + - GIT_COMMITTER_NAME=${GIT_NAME} + - GIT_COMMITTER_EMAIL=${GIT_EMAIL} + volumes: + - 'opencode-home:/root' + - 'opencode-workspace:/workspace' +volumes: + opencode-home: + opencode-workspace: diff --git a/openhands/README.md b/openhands/README.md new file mode 100644 index 0000000..754a240 --- /dev/null +++ b/openhands/README.md @@ -0,0 +1,87 @@ +# openhands + +[OpenHands](https://openhands.dev) — a coding agent with a web UI. The app +container is only the front half: every agent session runs in a separate +*agent-server* container that this one creates on the host through the Docker +socket. Nothing is built here; the upstream image is complete. + +## Setup + +1. Point the domain at port `3000` and deploy. There is nothing to fill in — + the service has no environment file. +2. Open the domain and set the LLM provider and API key in the UI. They are + written to the state volume and survive a redeploy. + +## Docker socket + +Mounted read-write, and it has to be: the app creates and destroys containers, +and connecting to a Unix socket needs write permission on it. The `:ro` mount +that [code-server](../code-server/README.md) uses would fail here. + +That is full control of the host daemon, which is root-equivalent — the agent +can start a container that mounts anything. It is the price of the Docker +runtime; the alternative is running OpenHands somewhere it does not share a +daemon with anything else. + +`host.docker.internal` is required for the same architecture. Agent-server +containers are siblings started by the host daemon, not members of this compose +project's network, and they publish their port on the host. The app reaches +them by that name, so without the `extra_hosts` entry the UI loads and every +session then fails to connect. + +## Workspace + +There is none on the host. `SANDBOX_VOLUMES` is deliberately unset, so the +agent sees only the storage inside its own sandbox container and cannot touch +a host path. Clone into the sandbox from the agent's terminal instead. + +Setting it would mean a *host* path — the sibling container's mounts are +resolved by the host daemon, so a named volume or a path inside the app +container is not a valid value. + +## Environment + +Nothing is per-deployment, so there is no `.env.example`. What +`compose.yml` sets: + +| Variable | Purpose | +| --- | --- | +| `AGENT_SERVER_IMAGE_REPOSITORY` | Where to pull the sandbox image from | +| `AGENT_SERVER_IMAGE_TAG` | Which sandbox image to run. `-python` is the Python/Node toolchain variant; `-golang`, `-java` and others exist | +| `TZ` | Timestamps in the UI and logs | +| `LOG_ALL_EVENTS` | Full agent event stream in the container logs, which is what makes a failed session diagnosable | + +The app image tracks `latest` while the agent-server tag is pinned, which looks +inconsistent and is not. GHCR publishes no floating tag for +`openhands/agent-server` at all — its tags are version-and-toolchain triples +like `1.26.0-python` — so there is nothing to track. Bump it by hand when the +app image moves far enough that the two disagree; the app pulls it on first +session, so a wrong tag shows up as a session that never starts, not as a +failed deploy. + +## Storage + +| Volume | Mount | Holds | +| --- | --- | --- | +| `openhands-state` | `/.openhands` | Settings, LLM credentials, conversation history | + +`/.openhands` is at the filesystem root, not under `$HOME` — the upstream +`docker run` mounts the host's `~/.openhands` there, and only the container +side matters to a named volume. + +Sandbox containers keep their own storage and are not covered by this volume. + +## Networking + +Listens on `3000`, published nowhere — the platform maps the domain to it. See +the [root README](../README.md) for why. + +Sandbox containers do publish on the host, on ports the app picks, because that +is how the app reaches them. They are not reachable through the domain; serving +a preview running inside a sandbox would need `AGENT_SERVER_USE_HOST_NETWORK` +and `SANDBOX_CONTAINER_URL_PATTERN`, which this setup does not configure. + +## Related + +- [opencode-web](../opencode-web/README.md) — a coding agent that runs in its own container instead of spawning one +- [paseo](../paseo/README.md) — runs agent CLIs, including OpenHands' competitors, in a terminal diff --git a/openhands/compose.yml b/openhands/compose.yml new file mode 100644 index 0000000..8b58682 --- /dev/null +++ b/openhands/compose.yml @@ -0,0 +1,15 @@ +services: + openhands: + image: 'ghcr.io/openhands/openhands:latest' + environment: + - AGENT_SERVER_IMAGE_REPOSITORY=ghcr.io/openhands/agent-server + - AGENT_SERVER_IMAGE_TAG=1.26.0-python + - TZ=Asia/Ho_Chi_Minh + - LOG_ALL_EVENTS=true + extra_hosts: + - 'host.docker.internal:host-gateway' + volumes: + - 'openhands-state:/.openhands' + - '/var/run/docker.sock:/var/run/docker.sock' +volumes: + openhands-state: