diff --git a/AGENTS.md b/AGENTS.md index fa859b2..a874df5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 9603ec1..824a5d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 99ef32a..818bd11 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,18 @@ docker compose up -d Open 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: @@ -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//`. - [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). diff --git a/entrypoint.sh b/entrypoint.sh index 5dc185f..1308481 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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" diff --git a/opencode.jsonc b/opencode.jsonc index ab67fcc..25f1a04 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -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": { @@ -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": { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 596dc11..4017143 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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 @@ -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 @@ -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" \ @@ -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' \ diff --git a/scripts/test-router-integration.mjs b/scripts/test-router-integration.mjs index 76c7a8a..703ef7c 100644 --- a/scripts/test-router-integration.mjs +++ b/scripts/test-router-integration.mjs @@ -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}`); @@ -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}`); diff --git a/tests/routing.test.mjs b/tests/routing.test.mjs index f08b8ff..d13746b 100644 --- a/tests/routing.test.mjs +++ b/tests/routing.test.mjs @@ -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 {