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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Before editing, inspect the canonical checkout and create a task branch with `git worktree add`. Move the OpenCode session to the worktree and run edits, tests, commits, and pushes there.
- Keep uncommitted user changes untouched. Do not delete worktrees with uncommitted work or remove another session's worktree.
- For research-only tasks, reading the canonical checkout is fine. For non-Git directories, ask before initializing a repository.
- Never delete, move, or reset `/data/.config` or `/data/.local`. They hold the OpenCode database, logs, credentials, and OpenChamber settings for every session.

## Workflow

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ ENV HOME=/data \
XDG_CACHE_HOME=/data/.cache \
OPENCODE_CONFIG=/etc/opencode/opencode.json \
OPENCODE_CLIENT=app \
OPENCHAMBER_CHATS_DIR=/data/workspace/.chats \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt

COPY --from=config /build/runtime.json /etc/opencode/opencode.json
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,18 @@ docker compose up -d

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.
All state lives in the `/data` volume, and startup exits if `/data` is not a volume or bind mount. 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.

| Path under `/data` | Contents |
| --- | --- |
| `.local/share/opencode/opencode.db` | Sessions and provider credentials |
| `.local/share/opencode/log/opencode.log` | OpenCode server log |
| `.local/state/opencode` | Service registration and locks |
| `.config/opencode`, `.config/gh` | Global `AGENTS.md` (replaced on each start), GitHub CLI login |
| `.config/openchamber` | OpenChamber settings, passkeys, and login secret |
| `workspace` | Repositories, worktrees, and OpenChamber chats in `workspace/.chats` |

Back up the whole volume. To restore, stop both containers, replace the volume contents, and start them again. The servers keep files open or cached in memory, so deleting one from a running volume takes effect at the next restart.

To update the image or apply changed environment variables:

Expand Down Expand Up @@ -61,7 +72,7 @@ Grafana and Karakeep can connect before their credentials are validated; check a

## Agents and configuration

- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Tool permissions do not sandbox shell access.
- **Build** is the default agent and can use all configured tools. **General** can implement and test; **Explore** is read-only. Both subagents may use Context7 but deny Grafana, Lunch Money, Karakeep, Notion, Robinhood, and further delegation. Build and General ask before `rm` commands whose text contains `.config`, `.local`, `HOME`, `~`, or `/data/.`, or ends in `/data`. Tool permissions do not sandbox shell access.
- [Superpowers](https://github.com/obra/superpowers) supplies planning, debugging, testing, and review skills. The image also ships [`commit-push-pr` and `go-repo`](skills/). [`AGENTS.md`](AGENTS.md) requires repository edits in `/data/workspace/.worktrees/<repository>/<task>`.
- [OpenCode Quota](https://github.com/slkiser/opencode-quota) provides `/quota` and `/quota_status`.
- Defaults and pinned plugins live in [`opencode.jsonc`](opencode.jsonc). The image converts it to `/etc/opencode/opencode.json`; project `opencode.json(c)` files can override settings. See the [V2 configuration guide](https://opencode.ai/v2/docs/config).
Expand Down
5 changes: 5 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ if [ "${1:-}" = openchamber ] && [ "${#ui_password}" -lt 32 ]; then
exit 1
fi

if ! mountpoint -q /data; then
echo "/data must be a volume or bind mount; sessions, credentials, and logs written to the container layer are lost when it is recreated" >&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
18 changes: 16 additions & 2 deletions opencode.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
"agents": {
"build": {
"permissions": [
{ "action": "*", "resource": "*", "effect": "allow" }
{ "action": "*", "resource": "*", "effect": "allow" },
{ "action": "shell", "resource": "rm *.config*", "effect": "ask" },
{ "action": "shell", "resource": "rm *.local*", "effect": "ask" },
{ "action": "shell", "resource": "rm *HOME*", "effect": "ask" },
{ "action": "shell", "resource": "rm *~*", "effect": "ask" },
{ "action": "shell", "resource": "rm */data", "effect": "ask" },
{ "action": "shell", "resource": "rm */data/", "effect": "ask" },
{ "action": "shell", "resource": "rm */data/.*", "effect": "ask" }
]
},
"general": {
Expand All @@ -26,7 +33,14 @@
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "skill", "resource": "*", "effect": "allow" },
{ "action": "execute", "resource": "*", "effect": "allow" },
{ "action": "context7_*", "resource": "*", "effect": "allow" }
{ "action": "context7_*", "resource": "*", "effect": "allow" },
{ "action": "shell", "resource": "rm *.config*", "effect": "ask" },
{ "action": "shell", "resource": "rm *.local*", "effect": "ask" },
{ "action": "shell", "resource": "rm *HOME*", "effect": "ask" },
{ "action": "shell", "resource": "rm *~*", "effect": "ask" },
{ "action": "shell", "resource": "rm */data", "effect": "ask" },
{ "action": "shell", "resource": "rm */data/", "effect": "ask" },
{ "action": "shell", "resource": "rm */data/.*", "effect": "ask" }
]
},
"explore": {
Expand Down
76 changes: 52 additions & 24 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,38 +42,47 @@ docker run --rm --entrypoint zsh "$image" -euc '

# Startup must fail promptly when no password is provided.
status=0
timeout 15 docker run --rm "$image" || status=$?
timeout 15 docker run --rm -v /data "$image" || status=$?
test "$status" = 1
status=0
timeout 15 docker run --rm -e OPENCODE_PASSWORD=short "$image" || status=$?
timeout 15 docker run --rm -v /data -e OPENCODE_PASSWORD=short "$image" || status=$?
test "$status" = 1
# Startup must also fail without a volume at /data.
status=0
timeout 15 docker run --rm -e "OPENCODE_PASSWORD=$password" "$image" || status=$?
test "$status" = 1

docker run -d --name "$name" \
--cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 2048 --memory 4g --cpus 2 \
-p 127.0.0.1::4096 \
-e "OPENCODE_PASSWORD=$password" \
-e LUNCHMONEY_API_TOKEN=catalog-test-only \
-e KARAKEEP_API_ADDR=http://127.0.0.1:1 \
-e KARAKEEP_API_KEY=catalog-test-only \
"$image"
volume="opencode-test-data-$$"
start_opencode() {
docker run -d --name "$name" \
--cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 2048 --memory 4g --cpus 2 \
-p 127.0.0.1::4096 \
-v "$volume:/data" \
-e "OPENCODE_PASSWORD=$password" \
-e LUNCHMONEY_API_TOKEN=catalog-test-only \
-e KARAKEEP_API_ADDR=http://127.0.0.1:1 \
-e KARAKEEP_API_KEY=catalog-test-only \
"$image"
url="http://127.0.0.1:$(docker port "$name" 4096/tcp | cut -d: -f2)"
for attempt in $(seq 1 60); do
if docker exec "$name" /usr/local/bin/healthcheck.sh; then
break
fi
printf 'Waiting for OpenCode (%s/60)\n' "$attempt"
sleep 3
done
docker exec "$name" /usr/local/bin/healthcheck.sh
}
cleanup() {
docker logs "$name"
docker logs "$name" || true
docker exec "$name" sh -c 'for log in "$XDG_DATA_HOME"/opencode/log/*.log; do [ ! -f "$log" ] || cat "$log"; done' || true
docker rm -f "$name"
docker rm -f "$name" || true
docker volume rm "$volume" || true
}
trap cleanup EXIT
port=$(docker port "$name" 4096/tcp | cut -d: -f2)
url="http://127.0.0.1:$port"
start_opencode

for attempt in $(seq 1 60); do
if docker exec "$name" /usr/local/bin/healthcheck.sh; then
break
fi
printf 'Waiting for OpenCode (%s/60)\n' "$attempt"
sleep 3
done
docker exec "$name" /usr/local/bin/healthcheck.sh
for path in /api/info /api/integration; do
test "$(curl -s -o /dev/null -w '%{http_code}' "$url$path")" = 401
test "$(curl -s -o /dev/null -w '%{http_code}' --user opencode:wrong-password "$url$path")" = 401
Expand All @@ -97,10 +106,27 @@ docker exec "$name" sh -ec '
cmp /etc/opencode/AGENTS.md "$XDG_CONFIG_HOME/opencode/AGENTS.md"
'

# Sessions, logs, config, and state must survive recreating the container on the same volume.
location='location%5Bdirectory%5D=/data/workspace'
session=$(curl --fail -s --user "opencode:$password" -H 'Content-Type: application/json' \
-d '{"title":"Persistence check","agent":"build","location":{"directory":"/data/workspace"}}' \
"$url/api/session?$location" | jq -er '.data.id')
docker exec "$name" sh -ec 'touch "$XDG_CONFIG_HOME/gh/persist-check" "$XDG_STATE_HOME/opencode/persist-check"'
docker rm -f "$name"
start_opencode
curl --fail -s --user "opencode:$password" "$url/api/session/$session?$location" |
jq -e --arg id "$session" '.data.id == $id' >/dev/null
docker exec "$name" sh -ec '
test -f "$XDG_CONFIG_HOME/gh/persist-check"
test -f "$XDG_STATE_HOME/opencode/persist-check"
starts=$(grep "message=\"cli starting\"" "$XDG_DATA_HOME/opencode/log/opencode.log" | grep -c -e --hostname)
test "$starts" -ge 2
'

# 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" \
timeout 15 docker run --rm -v /data -e "OPENCODE_PASSWORD=$password" -e "OPENCHAMBER_UI_PASSWORD=$ui" \
"$image" openchamber serve --foreground || status=$?
test "$status" = 1
done
Expand All @@ -113,6 +139,7 @@ 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 \
-v "$volume:/data" \
-e OPENCODE_HOST=http://opencode:4096 \
-e OPENCODE_SKIP_START=true \
-e "OPENCODE_PASSWORD=$password" \
Expand All @@ -132,6 +159,7 @@ for attempt in $(seq 1 40); do
sleep 3
done
docker exec "$chamber" /usr/local/bin/openchamber-healthcheck.sh
docker exec "$chamber" test -d /data/workspace/.chats

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' \
Expand Down
10 changes: 8 additions & 2 deletions scripts/test-router-integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ try {
const lead = agents.find(a => a.id === "build");
assert.ok(lead, "native build agent is available");
assert.ok(!agents.some(a => a.id.startsWith("orch-")));
function permission(agent, action) {
return agent.permissions.filter(rule => new RegExp(`^${rule.action.split("*").map(RegExp.escape).join(".*")}$`).test(action)).at(-1)?.effect;
const glob = pattern => new RegExp(`^${[...pattern].map(c => c === "*" ? ".*" : c === "?" ? "." : RegExp.escape(c)).join("")}$`, "s");
function permission(agent, action, resource = "") {
return agent.permissions.filter(rule => glob(rule.action).test(action) && glob(rule.resource).test(resource)).at(-1)?.effect;
}
for (const action of ["execute", "grafana_list_datasources", "lunchmoney_get_user", "karakeep_search-bookmarks", "notion_notion-search", "robinhood_test_tool", "context7_query-docs"])
assert.equal(permission(lead, action), "allow", `lead can use ${action}`);
Expand All @@ -108,6 +109,11 @@ try {
assert.equal(permission(explore, action), "deny", `explore cannot use ${action}`);
for (const action of ["edit", "shell"])
assert.equal(permission(general, action), "allow", `general can implement and test with ${action}`);
for (const agent of [lead, general]) {
assert.equal(permission(agent, "shell", "rm -rf node_modules"), "allow", `${agent.id} can delete project files`);
for (const command of ["rm -rf ~/.config", 'rm -rf "$HOME/.local/share/opencode"', 'rm -rf "$XDG_CONFIG_HOME"', "rm -rf .config .local .cache", "rm -rf ~", "rm -rf /data", "rm -rf /data/", "rm -rf /data/.*"])
assert.equal(permission(agent, "shell", command), "ask", `${agent.id} asks before ${command}`);
}
const skills = (await request("/api/skill")).data;
for (const id of ["using-superpowers", "brainstorming", "test-driven-development", "using-git-worktrees", "verification-before-completion"])
assert.ok(skills.some(skill => skill.id === id), `missing Superpowers skill ${id}`);
Expand Down
11 changes: 10 additions & 1 deletion tests/routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,20 @@ test("existing providers, MCP integrations and quota are preserved", () => {
assert.deepEqual(Object.keys(config.mcp.servers).sort(), ["context7", "grafana", "karakeep", "lunchmoney", "notion", "robinhood"]);
assert.ok(config.plugins.includes("@slkiser/opencode-quota@5.0.2"));
assert.ok(!config.permissions?.some(rule => rule.action === "*" && rule.effect === "allow"));
assert.deepEqual(config.agents.build.permissions, [{ action: "*", resource: "*", effect: "allow" }]);
assert.deepEqual(config.agents.build.permissions[0], { action: "*", resource: "*", effect: "allow" });
for (const name of ["general", "explore"])
assert.ok(config.agents[name].permissions.some(rule => rule.action === "robinhood_*" && rule.resource === "*" && rule.effect === "deny"), `${name} denies Robinhood tools`);
});

test("agents ask before deleting server state under the home directory", async () => {
const asks = ["rm *.config*", "rm *.local*", "rm *HOME*", "rm *~*", "rm */data", "rm */data/", "rm */data/.*"]
.map(resource => ({ action: "shell", resource, effect: "ask" }));
for (const name of ["build", "general"])
assert.deepEqual(config.agents[name].permissions.slice(-asks.length), asks, `${name} ends with the state-deletion asks`);
const instructions = await readFile(new URL("../AGENTS.md", import.meta.url), "utf8");
assert.match(instructions, /Never delete, move, or reset `\/data\/\.config` or `\/data\/\.local`/);
});

test("runtime conversion preserves JSONC overrides and rejects malformed input", async () => {
const home = await mkdtemp(join(tmpdir(), "opencode-config-test-"));
try {
Expand Down
Loading