diff --git a/bot/SETUP.md b/bot/SETUP.md index 201d52d..6aecafb 100644 --- a/bot/SETUP.md +++ b/bot/SETUP.md @@ -262,7 +262,8 @@ they're given, so revoke and recreate it if one ever leaks. bot/copilot-login.sh The token only grants Copilot; revoke it under GitHub Settings → - Applications. + Applications. The secret holds the login in opencode 1's `auth.json` + format, so one stored before the move to opencode 2 still works. 3. Codex is not used: cgwalters uses opencode for OpenAI models, and Codex has no bot-specific token, only your own ChatGPT login. diff --git a/bot/agent.Containerfile b/bot/agent.Containerfile index 97487bf..2aa90b7 100644 --- a/bot/agent.Containerfile +++ b/bot/agent.Containerfile @@ -16,16 +16,23 @@ RUN dnf install -y \ dnf clean all # opencode is pinned to the version the coordinator's opencode backend was -# tested with: its Copilot provider pin and its builder agent's rules rest on -# how opencode merges its config sources. Before each bump, re-run the -# coordinator image's config check, which plants config of each kind and -# sends the TUI a SIGUSR2 (config reload). It arrives with -# jmarrero/atomic-setup#18 (bot/coordinator/check-opencode-config.py); once -# that is merged: +# tested with: its Copilot provider pin, its policies and its builder +# agent's rules rest on how opencode merges its config sources. Before each +# bump, re-run the coordinator image's config check, which plants config of +# each kind, with and without the loop's pre-start clean, and plants more +# into a running server (opencode reloads config files as they change): # podman run --rm --entrypoint python3 localhost/bot-coordinator \ # /usr/local/share/bot-coordinator/check-opencode-config.py -RUN npm install -g @anthropic-ai/claude-code @openai/codex @github/copilot opencode-ai@1.19.0 && \ +RUN npm install -g @anthropic-ai/claude-code @openai/codex @github/copilot @opencode/cli@2.1.1 && \ npm cache clean --force +# opencode 2 no longer reads OPENCODE_AUTH_CONTENT: this runs it with that +# login (see the script). +COPY --chmod=755 opencode-copilot /usr/local/bin/opencode-copilot +# opencode's own update check reads only the global config, which a job or +# session can write; the npm install isn't the agent user's to replace anyway. +# Its database's name, which would otherwise depend on the release channel: +# opencode-copilot and the coordinator's archive scrub look for this one. +ENV OPENCODE_DISABLE_AUTOUPDATE=1 OPENCODE_DB=opencode.db # The bot's commit identity, for any commit made in a job. RUN git config --system user.name "Joseph Marrero" && \ diff --git a/bot/config.example.toml b/bot/config.example.toml index c40f2ac..04c34de 100644 --- a/bot/config.example.toml +++ b/bot/config.example.toml @@ -58,7 +58,10 @@ CLAUDE_CODE_OAUTH_TOKEN = "jmarrero-bot-claude-token" [agents.opencode] # GitHub Copilot models through opencode (bot/copilot-login.sh stores the -# login as a podman secret). List models with: opencode models github-copilot -command = ["opencode", "run", "--auto", "-m", "github-copilot/gpt-6.1-sol", "{prompt}"] +# login as a podman secret, which opencode-copilot hands to opencode), on a +# private server rather than opencode's shared background one. List models +# with: podman run --rm --secret jmarrero-bot-copilot-auth,type=env,target=OPENCODE_AUTH_CONTENT \ +# localhost/bootc-bot-agent opencode-copilot models --standalone +command = ["opencode-copilot", "run", "--standalone", "--auto", "-m", "github-copilot/gpt-6.1-sol", "{prompt}"] [agents.opencode.secrets] OPENCODE_AUTH_CONTENT = "jmarrero-bot-copilot-auth" diff --git a/bot/coordinator/Containerfile b/bot/coordinator/Containerfile index b690af4..2582f77 100644 --- a/bot/coordinator/Containerfile +++ b/bot/coordinator/Containerfile @@ -11,9 +11,9 @@ COPY --chmod=755 coordinator-loop.py /usr/local/bin/bot-coordinator-loop COPY operator-goals.md operator-notes.md backends.toml backend-opencode.md /usr/local/share/bot-coordinator/ COPY --chmod=755 bot-opencode /usr/local/bin/bot-opencode COPY opencode-config.py check-opencode-config.py /usr/local/share/bot-coordinator/ -# opencode's config, generated, as its system-managed config: root-owned, -# and loaded after every file the session could write (the base image runs -# as agent). +# opencode's config, generated, as its system config: root-owned, and +# loaded after every file the session could write, its policies evaluated +# last (the base image runs as agent). USER root RUN mkdir -p /etc/opencode && \ python3 /usr/local/share/bot-coordinator/opencode-config.py /etc/opencode/opencode.json diff --git a/bot/coordinator/README.md b/bot/coordinator/README.md index 8493916..5d59f2c 100644 --- a/bot/coordinator/README.md +++ b/bot/coordinator/README.md @@ -73,8 +73,12 @@ notes and restart the coordinator to change that. Runs are logged in The session runs on one of an ordered list of backends, from `~/.config/bot-harness/backends.toml` (default: [`backends.toml`](backends.toml), which documents the format): Claude Code -(`COORDINATOR_MODEL`, `COORDINATOR_EFFORT`), then opencode in its TUI with -`--auto` (`BOT_OPENCODE_MODEL`, the Copilot login). Each has a launch +(`COORDINATOR_MODEL`, `COORDINATOR_EFFORT`), then opencode 2 in its TUI +with `--auto`, on a private server of its own (`--standalone`, not +opencode's shared background service), with `BOT_OPENCODE_MODEL` and +`BOT_OPENCODE_REVIEW_MODEL` (both needed, passed in +`OPENCODE_CONFIG_CONTENT`: the TUI has no `--model`) and the Copilot login +(which `opencode-copilot` hands it). Each has a launch command, the secret it needs, notes for the skill, and a usage probe: - Claude: the 7-day percent that `bot-heartbeat statusline` saves in @@ -130,8 +134,8 @@ start that fails 3 times in a row holds that backend for an hour and tries the next (with none left, the loop exits and the container restarts). Each backend's `clean` paths are emptied before it starts (opencode's -config, cache and data directories; see below), and a failure to do that -is a failed start. +config, cache, state and data directories; see below), and a failure to do +that is a failed start. A session can also start and not work: a refused model, a dialog, no quota. So the first thing `COORDINATOR_PROMPT` and every start note @@ -165,9 +169,10 @@ off. opencode's config is [`opencode-config.py`](opencode-config.py)'s output, which the image installs as `/etc/opencode/opencode.json`: root-owned, and -opencode's system-managed config. It pins opencode to the Copilot -provider, turns off the update prompt, sharing and the `question` tool, -and defines the `second-opinion` and `builder` agents; it applies to +opencode's system config. It turns off updates and sharing, defines the +`second-opinion` and `builder` agents, and holds the policies: only the +Copilot provider, no plugins, no MCP servers and no `question` tool (whose +dialog would take the next typed message as its answer). It applies to `bot-opencode`'s runs too. To change it, edit the generator and rebuild. What this protects against is mistakes, not a session that sets out to @@ -175,39 +180,61 @@ change its own config: the container, its credentials and the trust rules are the boundary, as for Claude, and the same user can edit its home, put commands first in `PATH` or signal its own processes. -- The provider pin always wins: opencode loads the managed config last, - also when the TUI reloads its config (SIGUSR2). -- The builder rules are merged with those of a session's own config - directories, keeping the order their keys first appeared in, so a file - there that lists the managed rules first and its own after them - outranks them (on opencode 1.19.0, as the check below shows). So before - each opencode start the loop empties `~/.opencode` and opencode's config - and cache directories (config, agents, plugins, cached packages), and - moves its data directory (sessions, logs, and the account state that - could pull in a remote config) to - `~/.local/state/bot-coordinator/archive/opencode/`, where the 5 newest - are kept, within 2 GiB together (the oldest go first; a single entry - bigger than that is still kept, so it isn't a hard bound). The login - (`auth.json`) is moved out before and back after, so no archive holds - it, and is put back even if archiving fails. The - paths follow `XDG_CONFIG_HOME`, `XDG_CACHE_HOME` and `XDG_DATA_HOME`. It - then links the rendered `AGENTS.md` afresh. If anything of that fails - (`clean`, `archive`, `keep` and `relink` in `backends.toml`), so does - the start, like any other failed start. Project config is off - (`OPENCODE_DISABLE_PROJECT_CONFIG`). +opencode 2 loads the system config after every file a session can write +(global, project, `.opencode`, agent files, `OPENCODE_CONFIG_CONTENT`), and +evaluates its policies after theirs. So nothing a session writes outranks +it, also as opencode reloads config files when they change: + +- Agents' rules are lists, joined in load order, last match wins: the + builder's rules come after any a session adds, however it orders them. + (opencode 1 merged them by key, so a file that listed the managed rules + first and its own after outranked them.) +- Policies can only take things away (a provider, a plugin, a tool), and + the system config's come last. The one thing after them is an OpenCode + Console organization's policies, which needs a Console login: a fresh + database at each start (below) leaves none. - [`check-opencode-config.py`](check-opencode-config.py), run in the - image after each opencode bump, checks this: planted config of each kind - after the clean, and the provider pin after a SIGUSR2 reload; it reports - whether the reload picks up a planted builder rule. -- That protects each start. A session that plants config itself can still - get it read later: by a SIGUSR2 reload, or as instructions (the global - `AGENTS.md` link's target is re-read on every turn, and an `AGENTS.md` - next to a file it reads is attached to it, as Claude Code does with - `CLAUDE.md`). + image after each opencode bump, checks this: config of each kind planted + (a project and a global `opencode.json`, `~/.opencode`, an agent file, a + global file that repeats the managed rules and adds its own, a plugin, an + MCP server), with project config off and on, with and without the loop's + clean, and planted into a running server, must leave the builder + refusing `git push`, the `question` tool refused, only the Copilot + provider, no plugin or MCP server started, and the login imported. + +So for these rules the per-start clean is no longer needed. It stays for +the rest: the login (opencode 2 keeps it in its database, and takes it +only into a new one) and what a session could leave that isn't a rule +(instructions, agents' prompts, commands, cached packages). Before each +opencode start the loop empties `~/.opencode` and opencode's config, cache +and state directories, and moves its data directory (its database, with +the sessions, the login and any account that could pull in a remote +config, and its logs) to `~/.local/state/bot-coordinator/archive/opencode/`, +where the 5 newest are kept, within 2 GiB together (the oldest go first; a +single entry bigger than that is still kept, so it isn't a hard bound). The +archived database's login and account tables are emptied there (or the +database is dropped if that fails), so no archive holds the login. An +opencode 1 `auth.json` found there is moved out first and put back +(opencode 2 doesn't read it: delete it by hand once). The +paths follow `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME` and +`XDG_DATA_HOME`. It then links the rendered `AGENTS.md` afresh. If anything +of that fails (`clean`, `archive`, `scrub`, `keep` and `relink` in +`backends.toml`), so does the start, like any other failed start. Project +config is off (`OPENCODE_DISABLE_PROJECT_CONFIG`). Then +[`opencode-copilot`](../opencode-copilot) imports the login from +`OPENCODE_AUTH_CONTENT` into the new database (`opencode auth import`) and +starts opencode. + +A session that plants config can still get it used until the next start: +opencode reloads config as it changes, so planted instructions, agents' +prompts, formatter commands or a provider's address take effect at once; +the global `AGENTS.md` link's target is re-read on every turn, and an +`AGENTS.md` next to a file it reads is attached to it, as Claude Code does +with `CLAUDE.md`. Claude Code's `builder` subagent has a hook that refuses commits, pushes and GitHub or board writes; under opencode, `builder` is an opencode agent -whose bash permissions do that job. opencode matches each command of a +whose shell permissions do that job (and it may not start subagents). opencode matches each command of a command line (as its shell parser splits it: `a && b`, `a | b`, `$(b)` are separate commands) against every pattern, `*` matching anything and `?` one character, and the last match wins. So after the `*` allow there @@ -257,7 +284,9 @@ $(printf push)`, `sh -c "$CMD"`), four or more spaces or tabs between the words, a shell run without options (`bash script.sh`), a script fed to a shell through a pipe or a heredoc (`echo 'git push' | sh`, `bash < LOG 2>&1 &`, - and read its log when you next wake. Never sleep in a loop. +- **The Bash tool's `run_in_background`** is the `shell` tool's + `background: true` here: you are told when the command ends. Foreground + commands time out, so run anything long (a devspace start, a test run) + that way, or inside a background subagent. Never sleep in a loop. - **Second opinions.** You are already the Copilot model, so don't use - `bot-opencode` for workers or reviews (a Bash call can't wait for it). - The other model family is the `second-opinion` subagent: every result - gets one review from a `general` task and one from a `second-opinion` - task, briefed like reviewers ("Read + `bot-opencode` for workers or reviews. The other model family is the + `second-opinion` subagent (its model is set for you): every result gets + one review from a `general` subagent and one from a `second-opinion` + subagent, briefed like reviewers ("Read ~/.agents/skills/coordinator/reviewer-preamble.md and follow it"). Weigh both, as the notes on opencode say. - **Only one model family.** If `second-opinion` fails (its model is - refused or out of quota), have a second `general` task review it with a + refused or out of quota), have a second `general` subagent review it with a fresh context, write "single-family review" on the PR and in the item's Why, and don't merge it yourself, even in the harness repositories: it waits for the operator, or for a second family to review it. diff --git a/bot/coordinator/backends.toml b/bot/coordinator/backends.toml index f91b7c6..cdca2f8 100644 --- a/bot/coordinator/backends.toml +++ b/bot/coordinator/backends.toml @@ -15,9 +15,10 @@ # env: extra variables for its session. # clean: files and directories emptied before each start, so that nothing a # session left there carries over; archive: moved aside (the 5 newest -# kept) and made afresh; keep: regular files that survive either; relink: -# links (link = target) created afresh. Paths take ${VAR:-default}. If -# any of that fails, so does the start. +# kept) and made afresh; keep: regular files that survive either; scrub: +# SQLite databases in an archived directory (file name = [tables]) whose +# tables are emptied there; relink: links (link = target) created afresh. +# Paths take ${VAR:-default}. If any of that fails, so does the start. # notes: Markdown appended to the coordinator skill while it runs, found # next to this file, else in /usr/local/share/bot-coordinator/. # probe: where its usage comes from: "file" (bot-heartbeat statusline's @@ -57,37 +58,44 @@ probe = { type = "file", path = "~/.cache/bot-heartbeat/rate-limits.json", windo [[backend]] name = "opencode" +# The TUI on a private server of its own (not opencode's shared background +# service), with the Copilot login (opencode-copilot); its model comes from +# OPENCODE_CONFIG_CONTENT below, since the TUI has no --model. command = [ - "opencode", "--auto", "--model", "${BOT_OPENCODE_MODEL}", + "opencode-copilot", "--standalone", "--auto", ["--prompt", "${COORDINATOR_PROMPT}"], ] requires_env = ["OPENCODE_AUTH_CONTENT", "BOT_OPENCODE_MODEL"] notes = "backend-opencode.md" -# Before each start, config, agents, plugins and cached packages a session -# could have left are removed, and its data directory (its sessions, logs, -# and accounts that could pull in a remote config) is moved aside, to +# Before each start, config, agents, plugins, cached packages and state a +# session could have left are removed, and its data directory (its database +# with the sessions, the logins and accounts that could pull in a remote +# config, and its logs) is moved aside, to # ~/.local/state/bot-coordinator/archive/opencode/ (the 5 newest are kept), -# but for the login, which OPENCODE_AUTH_CONTENT overrides anyway. The +# with the logins and accounts emptied from the archived database. +# opencode-copilot then imports the login into the new database. The # rendered AGENTS.md is linked afresh. clean = [ "~/.opencode", "${XDG_CONFIG_HOME:-~/.config}/opencode", "${XDG_CACHE_HOME:-~/.cache}/opencode", + "${XDG_STATE_HOME:-~/.local/state}/opencode", ] archive = ["${XDG_DATA_HOME:-~/.local/share}/opencode"] keep = ["${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json"] +scrub = { "opencode.db" = ["credential", "account", "control_account"] } relink = { "${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md" = "~/.local/share/bot-skills/AGENTS.md" } leave_at = 95 return_below = 50 probe = { type = "copilot", auth_env = "OPENCODE_AUTH_CONTENT", interval_minutes = 10 } [backend.env] -# Workers as background task subagents, which wake the session when done. -OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS = "true" # Its config is the image's /etc/opencode/opencode.json (opencode-config.py): -# only the Copilot provider, no dialogs that would take typed text, the -# second-opinion and builder agents (see README.md, "The builder under -# opencode"). opencode loads that system-managed config after everything -# else, so files the session can write don't override it; no project config -# at all. +# only the Copilot provider, no plugins or MCP servers, no dialogs that would +# take typed text, the second-opinion and builder agents (see README.md, "The +# builder under opencode"). opencode loads that system config after +# everything else and evaluates its policies last, so files the session can +# write don't override it; no project config at all. OPENCODE_DISABLE_PROJECT_CONFIG = "true" +# The models: the session's, and the second-opinion reviewer's. +OPENCODE_CONFIG_CONTENT = '{"model": "${BOT_OPENCODE_MODEL}", "agents": {"second-opinion": {"model": "${BOT_OPENCODE_REVIEW_MODEL}"}}}' diff --git a/bot/coordinator/bot-coordinator.container b/bot/coordinator/bot-coordinator.container index d3386ef..2944c0d 100644 --- a/bot/coordinator/bot-coordinator.container +++ b/bot/coordinator/bot-coordinator.container @@ -18,8 +18,8 @@ Secret=jmarrero-bot-claude-token,type=env,target=CLAUDE_CODE_OAUTH_TOKEN # which is also the opencode coordinator backend's (see backends.toml). Secret=jmarrero-bot-copilot-auth,type=env,target=OPENCODE_AUTH_CONTENT Environment=BOT_OPENCODE_MODEL=github-copilot/gpt-6.1-sol -# The opencode coordinator's second model family, for reviews (its -# opencode.json reads it). +# The opencode coordinator's second model family, for reviews (backends.toml +# passes it on). Environment=BOT_OPENCODE_REVIEW_MODEL=github-copilot/claude-sonnet-5.5 # The persistent home: clones, Claude's sessions, caches, devspace keys. # No keep-id: the container's users map to subordinate ids, not to you. diff --git a/bot/coordinator/bot-opencode b/bot/coordinator/bot-opencode index a352c12..16a67ed 100644 --- a/bot/coordinator/bot-opencode +++ b/bot/coordinator/bot-opencode @@ -4,13 +4,15 @@ # notes in the coordinator skill for when to use it. # # bot-opencode [--model PROVIDER/MODEL] [--dir DIR] [--name NAME] PROMPT +# bot-opencode --models # -# Runs `opencode run --auto` in DIR (default: the current directory) and -# prints its final output; the whole run is kept in -# ~/.local/state/bot-opencode/NAME-TIMESTAMP.log. Start it with the Bash -# tool's run_in_background, so its exit wakes the session. The login comes -# from OPENCODE_AUTH_CONTENT (a podman secret); the model from --model or -# BOT_OPENCODE_MODEL. +# Runs `opencode run --auto` in DIR (default: the current directory), on a +# private server and database of its own, and prints its final output; the +# whole run is kept in ~/.local/state/bot-opencode/NAME-TIMESTAMP.log. Start +# it with the Bash tool's run_in_background, so its exit wakes the session. +# The login comes from OPENCODE_AUTH_CONTENT (a podman secret, through +# opencode-copilot); the model from --model or BOT_OPENCODE_MODEL, and +# --models lists the ones it can use. set -euo pipefail model=${BOT_OPENCODE_MODEL:-github-copilot/gpt-6.1-sol} @@ -26,17 +28,26 @@ while [ $# -gt 1 ]; do *) break ;; esac done -[ $# -eq 1 ] || { sed -n '6,7p' "$0" | sed 's/^# //' >&2; exit 2; } +[ $# -eq 1 ] || { sed -n '6,8p' "$0" | sed 's/^# //' >&2; exit 2; } : "${OPENCODE_AUTH_CONTENT:?no Copilot login (OPENCODE_AUTH_CONTENT)}" [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "bot-opencode: bad --name" >&2; exit 2; } logdir=$HOME/.local/state/bot-opencode mkdir -p "$logdir" +# A database of its own, which opencode-copilot needs to hand it the login, +# and which goes afterwards, since it holds the login: off the persistent +# home, so that a killed run doesn't leave it there. +db=$(mktemp -d "${TMPDIR:-/tmp}/bot-opencode.XXXXXX") +trap 'rm -rf "$db"' EXIT +if [ "$1" = --models ]; then + OPENCODE_DB=$db/opencode.db opencode-copilot models --standalone + exit +fi log=$logdir/$name-$(date -u +%Y%m%dT%H%M%SZ).log echo "bot-opencode: $model in $dir; log $log" >&2 cd "$dir" set +e -opencode run --auto -m "$model" "$1" > >(tee "$log") 2>>"$log" +OPENCODE_DB=$db/opencode.db opencode-copilot run --standalone --auto -m "$model" "$1" > >(tee "$log") 2>>"$log" rc=$? set -e echo "bot-opencode: exit $rc (log $log)" >&2 diff --git a/bot/coordinator/check-opencode-config.py b/bot/coordinator/check-opencode-config.py index 8c62262..ffab275 100644 --- a/bot/coordinator/check-opencode-config.py +++ b/bot/coordinator/check-opencode-config.py @@ -5,44 +5,65 @@ podman run --rm --entrypoint python3 localhost/bot-coordinator \\ /usr/local/share/bot-coordinator/check-opencode-config.py -planted: for each config source a session can write (a project - opencode.json, ~/.opencode, ~/.config/opencode JSON, an agent file, and a - crafted ~/.opencode file that lists the managed rules first), plant one - that re-enables opencode's free provider and allows `git push` for the - builder, and check, with and without the loop's own pre-start clean, - whether the Copilot pin holds and the builder still refuses `git push`. - After the clean both must hold, whatever opencode's merge order. -sigusr2: start the TUI, plant the crafted file, send SIGUSR2 (the TUI's - config reload), and check that the provider pin still holds; report - whether the running builder now allows `git push` (README.md says that - it may: compare). With no TUI process to signal, it fails as not tested. - -Exits non-zero if a check that must hold doesn't. The builder's rules are -read with a copy of the managed config that enables opencode's free -provider in place of Copilot, through opencode's managed-config test -directory, since listing agents needs a provider. +For each config source a session can write (a project opencode.json, +~/.opencode, ~/.config/opencode JSON, an agent file, a crafted global file +that repeats the managed rules and adds its own after them, a plugin, an MCP +server), it plants one that enables opencode's free provider, lets the +builder run `git push`, lifts the policies and starts code, and starts +opencode's server (with a placeholder Copilot login, through +opencode-copilot): with project config off (as the coordinator runs) and +on (as bot-opencode may), and with and without the loop's pre-start clean. +Every run must keep: + +- the builder refusing `git push` (its rules, then the policies); +- the question tool refused, for every agent; +- only the Copilot provider (by the policies, and in the provider list); +- no plugin or MCP server started; +- the login imported from OPENCODE_AUTH_CONTENT. + +reload: then plants the crafted file and the plugin into a running server, +which reloads config files as they change, and checks the same again. + +Decisions are made as opencode 2 makes them (permission.ts, then the +policy plugin): the agent's rules, last match wins, a deny final; then the +tool.use policies of every config document, the system config's last, last +match wins. Exits non-zero if any check fails. """ +import base64 import contextlib import importlib.machinery import importlib.util import json import os import re +import secrets import shutil -import signal import socket import subprocess import sys import tempfile import time +import urllib.error +import urllib.parse import urllib.request OPENCODE = shutil.which("opencode") or "opencode" +WRAPPER = shutil.which("opencode-copilot") or "opencode-copilot" MANAGED = "/etc/opencode/opencode.json" LOOP = "/usr/local/bin/bot-coordinator-loop" BACKENDS = "/usr/local/share/bot-coordinator/backends.toml" -EVIL = {"enabled_providers": ["github-copilot", "opencode"], - "agent": {"builder": {"permission": {"bash": {"git push*": "allow"}}}}} +COPILOT = "github-copilot" +# A placeholder login in opencode 1's auth.json format, which is what the +# podman secret holds. Its token is fake: Copilot refuses it. +FAKE_TOKEN = "gho_opencode_check_placeholder" +LOGIN = json.dumps({COPILOT: {"type": "oauth", "refresh": FAKE_TOKEN, "access": FAKE_TOKEN, "expires": 0}}) +LIFTED = [{"action": "provider.use", "resource": "*", "effect": "allow"}, + {"action": "integration.use", "resource": "*", "effect": "allow"}, + {"action": "tool.use", "resource": "*", "effect": "allow"}] +PUSH_ALLOWED = [{"action": "shell", "resource": "git push*", "effect": "allow"}, + {"action": "question", "resource": "*", "effect": "allow"}] +SOURCES = ["project", "home .opencode", "global json", "agent file", "crafted global", "plugin", "mcp"] +STARTUP = 90 def match(text, pattern): @@ -54,9 +75,24 @@ def match(text, pattern): return re.fullmatch(rx, text.replace("\\", "/"), re.S) is not None -def decision(rules, command): - hits = [r["action"] for r in rules if r.get("permission") == "bash" and match(command, r["pattern"])] - return hits[-1] if hits else "ask" +def statements(entries): + """The policies in the order opencode evaluates them (ManagedPolicy.statements).""" + docs = [e for e in entries if e.get("type") == "document"] + return [p for d in reversed(docs) for p in (d.get("info") or {}).get("policies") or []] + + +def policy(entries, action, resource, default="allow"): + hits = [p["effect"] for p in statements(entries) if p["action"] == action and match(resource, p["resource"])] + return hits[-1] if hits else default + + +def decision(agent, entries, action, resource): + """What opencode decides for AGENT's tool call ACTION on RESOURCE.""" + hits = [r["effect"] for r in agent["permissions"] if match(action, r["action"]) and match(resource, r["resource"])] + effect = hits[-1] if hits else "ask" + if effect == "deny": + return "deny" + return "deny" if policy(entries, "tool.use", f"{action}:{resource}") == "deny" else effect def loop_module(home): @@ -72,43 +108,65 @@ def loop_module(home): os.environ["HOME"] = saved +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + class Home: - def __init__(self): + def __init__(self, project_config): self.home = tempfile.mkdtemp(prefix="oc-check-") self.project = os.path.join(self.home, "src", "github", "bot") - self.managed = os.path.join(self.home, "managed") os.makedirs(self.project) - os.makedirs(self.managed) subprocess.run(["git", "init", "-q", self.project], check=True) - conf = json.load(open(MANAGED)) - conf["enabled_providers"] = ["opencode"] - json.dump(conf, open(os.path.join(self.managed, "opencode.json"), "w")) self.env = {k: v for k, v in os.environ.items() if not k.startswith(("XDG_", "OPENCODE_"))} - self.env.update(HOME=self.home, OPENCODE_DISABLE_PROJECT_CONFIG="true", - BOT_OPENCODE_REVIEW_MODEL="github-copilot/claude-sonnet-5.5") + self.env.update(HOME=self.home, OPENCODE_AUTH_CONTENT=LOGIN, OPENCODE_DISABLE_AUTOUPDATE="1") + if not project_config: + self.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + self.markers = {name: os.path.join(self.home, f"{name}-started") for name in ("plugin", "mcp")} + + def path(self, rel): + return os.path.join(self.home, rel) def write(self, rel, text): - path = os.path.join(self.home, rel) + path = self.path(rel) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: f.write(text) + def evil(self, rules=()): + return json.dumps({"policies": LIFTED, "enabled_providers": [COPILOT, "opencode"], + "permissions": PUSH_ALLOWED, + "agents": {"builder": {"permissions": [*rules, *PUSH_ALLOWED]}, + "build": {"permissions": PUSH_ALLOWED}}}) + + def plugin(self): + """A plugin that leaves a marker when it is loaded.""" + return (f'import fs from "node:fs"\nfs.writeFileSync({json.dumps(self.markers["plugin"])}, "x")\n' + 'export default { id: "evil", setup: async () => ({}) }\n') + def plant(self, source): if source == "project": - self.write(os.path.relpath(os.path.join(self.project, "opencode.json"), self.home), json.dumps(EVIL)) + self.write(os.path.relpath(os.path.join(self.project, "opencode.json"), self.home), self.evil()) elif source == "home .opencode": - self.write(".opencode/opencode.json", json.dumps(EVIL)) + self.write(".opencode/opencode.json", self.evil()) elif source == "global json": - self.write(".config/opencode/opencode.json", json.dumps(EVIL)) + self.write(".config/opencode/opencode.json", self.evil()) elif source == "agent file": - self.write(".config/opencode/agent/builder.md", - '---\ndescription: x\nmode: subagent\npermission:\n bash:\n "git push*": allow\n---\nplanted\n') - elif source == "crafted .opencode": - # The managed rules first, so that the planted one comes after them. - rules = json.load(open(MANAGED))["agent"]["builder"]["permission"]["bash"] - crafted = {"agent": {"builder": {"permission": {"bash": dict(rules, **{"git push*": "allow"})}}}, - "enabled_providers": ["github-copilot", "opencode"]} - self.write(".opencode/opencode.json", json.dumps(crafted)) + self.write(".config/opencode/agents/builder.md", + "---\ndescription: x\nmode: subagent\npermissions:\n" + " - action: shell\n resource: \"git push*\"\n effect: allow\n---\nplanted\n") + elif source == "crafted global": + # The managed rules first, so that the planted ones come after them. + with open(MANAGED) as f: + managed = json.load(f)["agents"]["builder"]["permissions"] + self.write(".config/opencode/opencode.json", self.evil(managed)) + elif source == "plugin": + self.write(".config/opencode/plugins/evil.js", self.plugin()) + elif source == "mcp": + self.write(".config/opencode/opencode.json", json.dumps( + {"mcp": {"evil": {"type": "local", "command": ["touch", self.markers["mcp"]]}}})) def clean(self): loop = loop_module(self.home) @@ -116,126 +174,152 @@ def clean(self): saved = os.environ["HOME"] os.environ["HOME"] = self.home try: - loop.clean(b.clean, b.keep, b.relink, {}, b.archive, os.path.join(self.home, "archive"), "check") + loop.clean(b.clean, b.keep, b.relink, {}, b.archive, os.path.join(self.home, "archive"), "check", b.scrub) finally: os.environ["HOME"] = saved - def run(self, *args, managed_copy=False): - env = dict(self.env, **({"OPENCODE_TEST_MANAGED_CONFIG_DIR": self.managed} if managed_copy else {})) - # To a file: opencode cuts what it writes to a pipe at 64 KB. - out = os.path.join(self.home, "out.json") - with open(out, "w") as f: - subprocess.run([OPENCODE, *args], cwd=self.project, env=env, stdout=f, stderr=subprocess.DEVNULL, - timeout=120) - with open(out) as f: - return json.load(f) - - def providers(self): - return self.run("debug", "config").get("enabled_providers") - def builder(self): - return decision(self.run("debug", "agent", "builder", managed_copy=True)["permission"], "git push") +class Server: + """opencode's server for HOME, started through opencode-copilot, as the + TUI's standalone server and bot-opencode's runs are.""" + def __init__(self, h): + self.h, self.port, self.password = h, free_port(), secrets.token_urlsafe(16) + self.log = open(h.path("serve.log"), "w") + self.notes = "" + self.proc = subprocess.Popen([WRAPPER, "serve", "--port", str(self.port)], cwd=h.project, + env=dict(h.env, OPENCODE_PASSWORD=self.password), + stdout=self.log, stderr=subprocess.STDOUT) -def planted(): - ok = True - for source in ["project", "home .opencode", "global json", "agent file", "crafted .opencode"]: - for cleaned in (False, True): - h = Home() - h.plant(source) - if cleaned: - h.clean() - providers, builder = h.providers(), h.builder() - good = providers == ["github-copilot"] and builder == "deny" - must = cleaned - ok &= good or not must - print(f"{'ok ' if good else ('FAIL' if must else 'note')} {source:18} " - f"{'after clean' if cleaned else 'no clean '}: providers {providers}, builder `git push` {builder}") - shutil.rmtree(h.home, ignore_errors=True) + def get(self, path): + query = urllib.parse.urlencode({"location[directory]": self.h.project}) + req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}?{query}") + req.add_header("Authorization", + "Basic " + base64.b64encode(f"opencode:{self.password}".encode()).decode()) + with urllib.request.urlopen(req, timeout=10) as r: + body = json.load(r) + return body["data"] if isinstance(body, dict) and "data" in body else body + + def ready(self): + """Waits until the agents are loaded (the first answers come before + the plugins that add them).""" + deadline = time.monotonic() + STARTUP + while time.monotonic() < deadline: + if self.proc.poll() is not None: + raise RuntimeError(f"opencode's server exited ({self.proc.returncode}); see {self.log.name}") + with contextlib.suppress(OSError, ValueError): + if any(a["id"] == "builder" for a in self.get("/api/agent")): + return + time.sleep(1) + raise RuntimeError("opencode's server never listed the builder") + + def stop(self): + self.proc.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + self.proc.wait(10) + self.proc.kill() + self.log.close() + + +def observe(s): + """What must hold, read from a running server: {check: (ok, detail)}.""" + entries, agents = s.get("/api/config"), {a["id"]: a for a in s.get("/api/agent")} + providers = sorted(p["id"] for p in s.get("/api/provider")) + credentials = [c.get("integrationID") for c in s.get("/api/credential")] + builder = decision(agents["builder"], entries, "shell", "git push") + questions = {name: decision(a, entries, "question", "*") for name, a in agents.items()} + free = policy(entries, "provider.use", "opencode") + code = {name: (policy(entries, "integration.use", f"{name}:x"), os.path.exists(marker)) + for name, marker in s.h.markers.items()} + # Whether the planted rules were read at all, to show that they lost + # rather than went unread. + rules = agents["builder"]["permissions"] + planted = [i for i, r in enumerate(rules) if r["action"] == "shell" and r["resource"] == "git push*"] + lifted = sum(p in LIFTED for p in statements(entries)) + s.notes = (f"planted builder rule read at {planted[-1] + 1} of {len(rules)}" if planted else + "no planted builder rule read") + f", {lifted} planted policies read" + return { + "builder `git push`": (builder == "deny", builder), + "question tool": (set(questions.values()) == {"deny"}, + ", ".join(sorted(f"{n} {d}" for n, d in questions.items() if d != "deny")) or "deny"), + "providers": (free == "deny" and providers == [COPILOT], + f"opencode's free provider {free}, listed {providers}"), + "plugins, MCP": (all(p == "deny" and not started for p, started in code.values()), + ", ".join(f"{n} {p}{', started' if started else ''}" for n, (p, started) in code.items())), + "login": (COPILOT in credentials, f"credentials {credentials}"), + } + + +def report(label, results, notes=""): + ok = bool(results) and all(good for good, _ in results.values()) + print(f"{'ok ' if ok else 'FAIL'} {label}{f' ({notes})' if notes else ''}") + for check, (good, detail) in results.items(): + if not good: + print(f" {check}: {detail}") return ok -def free_port(): - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] +def run(h, wait=0): + s = Server(h) + try: + s.ready() + time.sleep(wait) + return observe(s), s.notes + finally: + s.stop() -class Tui: - """The TUI in tmux, its server on a port of its own.""" +def planted(): + ok = True + for project_config in (False, True): + for source in SOURCES: + for cleaned in (False, True): + h = Home(project_config) + try: + h.plant(source) + if cleaned: + h.clean() + # Plugins and MCP servers start in the background. + results, notes = run(h, wait=5 if source in ("plugin", "mcp") else 0) + except Exception as e: # noqa: BLE001 - reported as a failed check + results, notes = {"start": (False, repr(e))}, "" + ok &= report(f"{source:15} project config {'on ' if project_config else 'off'} " + f"{'after clean' if cleaned else 'no clean'}", results, notes) + shutil.rmtree(h.home, ignore_errors=True) + return ok - def __init__(self, h, env): - self.h, self.port = h, free_port() - self.env = dict(env, TMUX_TMPDIR=h.home) - self.cmd = f"{OPENCODE} --port {self.port}" - subprocess.run(["tmux", "new-session", "-d", "-s", f"check{self.port}", "-x", "200", "-y", "50", - "-c", h.project, self.cmd], env=self.env, check=True) - def get(self, path): - url = f"http://127.0.0.1:{self.port}{path}?directory={urllib.request.quote(self.h.project)}" - for _ in range(60): - try: - return json.load(urllib.request.urlopen(url, timeout=5)) - except (OSError, ValueError): - time.sleep(1) - raise RuntimeError(f"the TUI's server didn't answer {path}") - - def reload(self): - """Sends SIGUSR2 to the TUI; whether there was one to send it to.""" - pids = [int(p) for p in subprocess.run(["pgrep", "-f", self.cmd], capture_output=True, - text=True).stdout.split()] - for pid in pids: - with contextlib.suppress(OSError): - os.kill(pid, signal.SIGUSR2) - time.sleep(5) - return bool(pids) - - -def reload_run(managed_copy, read): - """In a cleaned home, starts a TUI, reads READ(tui), plants the crafted - file, sends SIGUSR2 and reads again: (before, after), or None when - there was no TUI process to signal. One TUI at a time: two would share - the data directory.""" - h = Home() - h.clean() - env = dict(h.env, **({"OPENCODE_TEST_MANAGED_CONFIG_DIR": h.managed} if managed_copy else {})) - tui = Tui(h, env) +def reload(): + """Plants into a running server, which reloads changed config files.""" + h = Home(False) + s = Server(h) try: - before = read(tui) - h.plant("crafted .opencode") - if not tui.reload(): - return None - return before, read(tui) + s.ready() + before = observe(s) + h.plant("crafted global") + h.write(".config/opencode/plugins/evil.js", h.plugin()) + time.sleep(10) + after = observe(s) + # The reload must have happened for this to mean anything. + seen = any(d.get("path", "").startswith(h.home) and (d.get("info") or {}).get("agents") + for d in s.get("/api/config")) + after["reload seen"] = (seen, "the planted file was read" if seen else "the planted file wasn't read") + notes = s.notes + except Exception as e: # noqa: BLE001 + before = after = {"start": (False, repr(e))} + notes = "" finally: - subprocess.run(["tmux", "kill-server"], env=tui.env, capture_output=True) + s.stop() shutil.rmtree(h.home, ignore_errors=True) - - -def sigusr2(): - def builder(tui): - agents = tui.get("/agent") - return decision(next(a for a in agents if a["name"] == "builder")["permission"], "git push") - - def providers(tui): - return tui.get("/config").get("enabled_providers") - # The builder's rules from the managed copy (listing them needs a - # provider); the provider pin from the real managed config. - rules, pin = reload_run(True, builder), reload_run(False, providers) - if rules is None or pin is None: - print("FAIL sigusr2: not tested: no TUI process to send SIGUSR2 to") - return False - ok = rules[0] == "deny" and pin == (["github-copilot"], ["github-copilot"]) - print(f"{'ok ' if ok else 'FAIL'} sigusr2: providers {pin[0]} at the start, {pin[1]} after a planted file " - f"and SIGUSR2; builder `git push` {rules[0]} at the start, {rules[1]} after " - f"({'the reload picks it up, as README.md says it may' if rules[1] != 'deny' else 'not picked up'})") - return ok + return (report("reload: before planting", before) + & report("reload: after planting into the running server", after, notes)) def main(): version = subprocess.run([OPENCODE, "--version"], capture_output=True, text=True).stdout.strip() print(f"opencode {version}") ok = planted() - ok &= sigusr2() + ok &= reload() sys.exit(0 if ok else 1) diff --git a/bot/coordinator/coordinator-loop.py b/bot/coordinator/coordinator-loop.py index bf7ccd2..0a13a55 100644 --- a/bot/coordinator/coordinator-loop.py +++ b/bot/coordinator/coordinator-loop.py @@ -59,6 +59,7 @@ import shlex import shutil import signal +import sqlite3 import subprocess import sys import tempfile @@ -161,6 +162,8 @@ def state_home(): # How many of a backend's earlier data directories are kept (archive), and # the most bytes they may take together. ARCHIVE_KEEP = 5 +# Table names a backend's scrub may empty. +SQL_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") ARCHIVE_MAX_BYTES = 2 * 1024 ** 3 # After this many failed starts in a row a backend is held like one whose # session ended at once, and another is tried. @@ -190,7 +193,7 @@ def state_home(): "none": (set(), set(), 600, 0), } BACKEND_KEYS = {"name", "command", "requires_env", "env", "notes", "leave_at", "return_below", "probe", - "proved_by_reading", "clean", "archive", "keep", "relink"} + "proved_by_reading", "clean", "archive", "keep", "relink", "scrub"} TOP_KEYS = {"handoff_timeout_minutes", "min_dwell_minutes", "start_ack_timeout_minutes", "backend"} @@ -294,6 +297,12 @@ def __init__(self, spec, where, base_dirs): self.relink = spec.get("relink", {}) if not (isinstance(self.relink, dict) and all(isinstance(v, str) and v for v in self.relink.values())): raise ConfigError(f"{where}: relink must be a table of link = target paths") + self.scrub = spec.get("scrub", {}) + if not (isinstance(self.scrub, dict) + and all(re.fullmatch(r"[^/]+", name) and name not in (".", "..") for name in self.scrub) + and all(isinstance(v, list) and v and all(isinstance(x, str) and SQL_NAME_RE.fullmatch(x) for x in v) + for v in self.scrub.values())): + raise ConfigError(f"{where}: scrub must be a table of database file name = [table names]") self.leave_at = percent(spec, "leave_at", DEFAULT_LEAVE_AT, where) self.return_below = percent(spec, "return_below", DEFAULT_RETURN_BELOW, where) if self.return_below > self.leave_at: @@ -722,11 +731,13 @@ def mark_time(value): return parse_time(value) -def clean(paths, keep=(), relink=None, environ=None, archive=(), archive_dir=None, stamp=None): +def clean(paths, keep=(), relink=None, environ=None, archive=(), archive_dir=None, stamp=None, scrub=None): """Prepares a backend's files for its start: - ARCHIVE: each path is moved into ARCHIVE_DIR as STAMP-NAME (only the ARCHIVE_KEEP newest are kept there), and made afresh; + - SCRUB: {file name: [tables]}: SQLite databases in an archived + directory whose tables (the logins) are emptied there; - PATHS: each is emptied (a directory) or removed (a file); - KEEP: full paths of regular files (not directories or symlinks) that survive either: kept in place, or copied back from the archive; @@ -811,7 +822,10 @@ def remove(path): os.makedirs(archive_dir, mode=0o700, exist_ok=True) prune_archive(archive_dir, tree_size(path)) # A rename, or a copy and delete across file systems. - shutil.move(path, os.path.join(archive_dir, f"{stamp}-{os.path.basename(path)}")) + archived = os.path.join(archive_dir, f"{stamp}-{os.path.basename(path)}") + shutil.move(path, archived) + for name, tables in (scrub or {}).items(): + scrub_database(os.path.join(archived, name), tables) except OSError as e: errors.append(f"{path}: {e.strerror or e}") finally: @@ -847,6 +861,39 @@ def remove(path): raise OSError("couldn't clean " + "; ".join(errors)) +def scrub_database(path, tables): + """Empties TABLES in the SQLite database PATH, where they exist, and + rewrites it so that their rows are gone from the file too. A database + that can't be scrubbed is removed instead: an archive must not keep a + login. Its write-ahead log goes with it either way, also when the + database itself is missing.""" + if os.path.islink(path) or (os.path.lexists(path) and not os.path.isfile(path)): + return + try: + if not os.path.lexists(path): + raise FileNotFoundError(path) + db = sqlite3.connect(path, isolation_level=None) + try: + db.execute("PRAGMA secure_delete = ON") + present = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + for table in tables: + if table in present: + db.execute(f'DELETE FROM "{table}"') + db.execute("PRAGMA wal_checkpoint(TRUNCATE)") + db.execute("VACUUM") + finally: + db.close() + except FileNotFoundError: + pass + except sqlite3.Error as e: + log(f"warning: couldn't scrub {path} ({e}); removing it from the archive") + with contextlib.suppress(FileNotFoundError): + os.unlink(path) + for suffix in ("-wal", "-shm", "-journal"): + with contextlib.suppress(FileNotFoundError): + os.unlink(path + suffix) + + def move_file(src, dst): """Moves the file SRC to DST: a rename, or across file systems a copy to a temporary name, renamed into place, and only then SRC's removal, so @@ -1293,7 +1340,7 @@ def start(self, name, now): # plugins: if it can't all go, the start fails. clean(backend.clean, backend.keep, backend.relink, self.environ, backend.archive, os.path.join(self.archive_root, name), - f"{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime(now))}-gen{gen}") + f"{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime(now))}-gen{gen}", backend.scrub) self.sessions.start(argv, dict(env, **{GENERATION_VAR: str(gen), BACKEND_VAR: name})) except Exception as e: # Stopped (in case it half started) and started again next tick: diff --git a/bot/coordinator/opencode-config.py b/bot/coordinator/opencode-config.py index 829f5b5..65cc312 100644 --- a/bot/coordinator/opencode-config.py +++ b/bot/coordinator/opencode-config.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 """Write opencode.json, the opencode coordinator backend's config. The -image installs it as /etc/opencode/opencode.json, opencode's system-managed -config, which it loads last: its provider pin always wins. Its builder -rules can still be outranked by rules a session writes into its own config -directories, which the loop empties before each start (see README.md). It -applies to bot-opencode's runs too. +image installs it as /etc/opencode/opencode.json, opencode's system config, +which opencode 2 loads after every file a session can write: its settings +and agents' rules come last, so they win, and its policies evaluate last, +after every other file's. It applies to bot-opencode's runs too. opencode-config.py OUT -The config is generated because of the builder agent's bash rules, which +The policies are rules no other config file can lift (only an OpenCode +Console organization's can, see README.md): only the Copilot provider (so +that a missing login doesn't fall back to opencode's own free model), no +plugins and no MCP servers (code a session could plant in its own config), +and no question tool, whose dialog would take the next typed message as its +answer. + +The config is generated because of the builder agent's shell rules, which stand in for Claude Code's builder hook (homegit's dotfiles/.claude/agents/builder.md): opencode has no hooks, only -permission patterns. opencode 1.18 matches each command of a command line -(as its shell parser splits it) against every pattern with its +permission patterns. opencode 2, as 1.18 did, matches each command of a +command line (as its shell parser splits it) against every pattern with its Wildcard.match (* is anything, ? one character, a trailing " *" may also match nothing), and the last matching rule wins. So there are only deny rules after the "*" allow: an allow after them would let through any @@ -72,7 +78,8 @@ "diff.") -def builder_rules(): +def builder_patterns(): + """The shell patterns the builder may not run, in order, each once.""" deny = [] for start in STARTS: for verb in GIT_WRITES: @@ -96,29 +103,56 @@ def builder_rules(): deny += [f"{start}{shell}{gap}-*{word}*" for shell in SHELLS for gap in GAPS for word in WRAPPED] deny += [f"{start}eval?*{word}*" for word in WRAPPED] deny += [f"*bot-{tool}*" for tool in BOT_WRITES] + ["*dco-signoff*"] - rules = {"*": "allow"} - rules.update((pattern, "deny") for pattern in deny) - return rules + return list(dict.fromkeys(deny)) + + +def rule(action, resource, effect): + return {"action": action, "resource": resource, "effect": effect} + + +def builder_rules(): + """The builder's permissions: no subagents, and every shell command but + the denied ones.""" + return ([rule("subagent", "*", "deny"), rule("shell", "*", "allow")] + + [rule("shell", pattern, "deny") for pattern in builder_patterns()]) def config(): return { "$schema": "https://opencode.ai/config.json", - # Only Copilot: without its login opencode would otherwise fall back - # to opencode's own free model. - "enabled_providers": ["github-copilot"], - # No update prompt and no question tool, whose dialogs would take the - # next typed message as their answer; no sharing. - "autoupdate": False, + "policies": [ + # Only Copilot: without its login opencode would otherwise fall + # back to opencode's own free model. + rule("provider.use", "*", "deny"), + rule("provider.use", "github-copilot", "allow"), + # No plugins or MCP servers: code, which a session could add + # to its own config. + rule("integration.use", "plugin:*", "deny"), + rule("integration.use", "mcp:*", "deny"), + # No question tool, whose dialog would take the next typed + # message as its answer. + rule("tool.use", "question:*", "deny"), + ], + # The same for the agents' own rules, as opencode 1 had it. + "permissions": [rule("question", "*", "deny")], + # No updates (and so no update dialog) and no sharing. The CLI's own + # update check reads only the global config, so the image also sets + # OPENCODE_DISABLE_AUTOUPDATE. + "update": "disable", "share": "disabled", - "permission": {"question": "deny"}, - # Workers may start their own reviewer. - "subagent_depth": 2, - "agent": { + # The shell and its parser the builder's rules were written against, + # whatever a session's own config says. + "shell": "/bin/bash", + "experimental": {"portable_shell_scanner": False}, + # No subagent starts one of its own (opencode's default depth, 1), as + # under opencode 1, whose subagent_depth 2 never applied: it refused + # the task tool to every subagent whose own rules didn't allow it. + "agents": { "second-opinion": { "mode": "subagent", - # Set in the quadlet; opencode fills in {env:...}. - "model": "{env:BOT_OPENCODE_REVIEW_MODEL}", + # Its model comes from the session's OPENCODE_CONFIG_CONTENT + # (backends.toml), from BOT_OPENCODE_REVIEW_MODEL: an + # unset variable here would make the model invalid. "description": "A reviewer on a different model family from the coordinator, for the second " "review of every result.", }, @@ -128,8 +162,8 @@ def config(): "named failing test passing when the fix is local, classify CI failures, bisect " "a build failure. Leaves its changes uncommitted for the caller. Never for " "reviews, design or security questions.", - "prompt": BUILDER_PROMPT, - "permission": {"task": "deny", "bash": builder_rules()}, + "system": BUILDER_PROMPT, + "permissions": builder_rules(), }, }, } diff --git a/bot/coordinator/operator-notes.md b/bot/coordinator/operator-notes.md index 3406d90..f33dd16 100644 --- a/bot/coordinator/operator-notes.md +++ b/bot/coordinator/operator-notes.md @@ -150,7 +150,7 @@ own). Run at most two at once. you before it reaches jmarrero. If it fails or wanders off, hand the item to a Claude worker and note it in the Why field. - The default model is `BOT_OPENCODE_MODEL`; `--model` overrides it - (`opencode models github-copilot` lists them). + (`bot-opencode --models` lists them). ### HackMD: use the bot's token only when jmarrero asks diff --git a/bot/copilot-login.sh b/bot/copilot-login.sh index 0c87548..175aeb8 100755 --- a/bot/copilot-login.sh +++ b/bot/copilot-login.sh @@ -1,9 +1,10 @@ #!/bin/bash # Log opencode in to GitHub Copilot and store the result as a podman secret, -# for agents that pass it as OPENCODE_AUTH_CONTENT. Works headless: opencode -# prints a code to enter at https://github.com/login/device on any browser. -# Log in there as the account that holds the Copilot license. The token only -# grants Copilot; revoke it under GitHub Settings -> Applications. +# for agents that pass it as OPENCODE_AUTH_CONTENT (opencode-copilot hands it +# to opencode). Works headless: opencode prints a code to enter at +# https://github.com/login/device on any browser. Log in there as the account +# that holds the Copilot license. The token only grants Copilot; revoke it +# under GitHub Settings -> Applications. # Usage: bot/copilot-login.sh [secret-name] set -euo pipefail @@ -14,9 +15,14 @@ image=localhost/bootc-bot-agent:latest tmp=$(mktemp -d "${XDG_RUNTIME_DIR:?}/copilot-login.XXXXXX") trap 'rm -rf "$tmp"' EXIT -echo "In the menu, pick GitHub Copilot." >&2 +# opencode 2 keeps the login in its database; the secret holds it in +# opencode 1's auth.json format, which opencode-copilot reads. +echo "Pick GitHub.com (or your Enterprise) when asked." >&2 podman run --rm -it --userns=keep-id:uid=2000,gid=2000 --user 2000:2000 \ - -v "$tmp:/home/agent/.local/share/opencode:Z" "$image" opencode auth login + -v "$tmp:/home/agent/.local/share/opencode:Z" "$image" sh -c \ + 'opencode auth login --standalone github-copilot && + opencode auth export --standalone github-copilot | opencode-copilot --login-from-export \ + > /home/agent/.local/share/opencode/auth.json' test -s "$tmp/auth.json" || { echo "error: no login was saved" >&2; exit 1; } python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); assert "github-copilot" in d, sorted(d)' \ diff --git a/bot/opencode-copilot b/bot/opencode-copilot new file mode 100644 index 0000000..7aa9484 --- /dev/null +++ b/bot/opencode-copilot @@ -0,0 +1,101 @@ +#!/usr/bin/python3 -I +"""opencode with the GitHub Copilot login from OPENCODE_AUTH_CONTENT, the +podman secret bot/copilot-login.sh stores (opencode 1's auth.json format). + + opencode-copilot OPENCODE-ARGS... + opencode auth export | opencode-copilot --login-from-export + +opencode 2 keeps logins in its database and no longer reads that variable, +so this imports the login there (`opencode auth import`, on a private +server) and then runs opencode. The database must be new: a fresh data +directory (a job container, the coordinator's archived one) or an +OPENCODE_DB of its own. In an existing one an import wouldn't replace the +login it may already have, so that's an error. + +--login-from-export turns `opencode auth export`'s Copilot login into that +format, for bot/copilot-login.sh.""" +import json +import os +import subprocess +import sys + +PROG = "opencode-copilot" +COPILOT = "github-copilot" +# A fixed id: a second import of the same database would be skipped. +CREDENTIAL_ID = "cred_bot_copilot" +# Where an Enterprise login's API is (opencode 1's auth.json kept only the +# first), carried both ways. +METADATA = ("enterpriseUrl", "apiEndpoint") + + +def database(): + """Where opencode keeps its database (cli/src/database-path.ts).""" + data = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "opencode") + return os.path.join(data, os.environ.get("OPENCODE_DB") or "opencode.db") + + +def credentials(content): + """opencode 1's auth.json entry for Copilot as `opencode auth export` + prints it, which `auth import` reads.""" + try: + login = json.loads(content)[COPILOT] + refresh = login["refresh"] + except (ValueError, KeyError, TypeError): + raise SystemExit(f"{PROG}: OPENCODE_AUTH_CONTENT holds no {COPILOT} login") from None + metadata = {key: login[key] for key in METADATA if isinstance(login.get(key), str)} + value = {"type": "oauth", "methodID": "device", "refresh": refresh, "access": login.get("access") or refresh, + "expires": login.get("expires") or 0} + if metadata: + value["metadata"] = metadata + return [{"id": CREDENTIAL_ID, "integrationID": COPILOT, "label": "OAuth", "active": True, "value": value}] + + +def redact(text, login): + """TEXT without the login's tokens, which an error may quote.""" + for token in {login[0]["value"]["refresh"], login[0]["value"]["access"]}: + text = text.replace(token, "[token]") + return text + + +def login_from_export(exported): + """The active Copilot login in `opencode auth export`'s output, in + opencode 1's auth.json format: the inverse of credentials().""" + try: + logins = [c for c in json.loads(exported) if c.get("integrationID") == COPILOT + and c.get("value", {}).get("type") == "oauth"] + except (ValueError, AttributeError, TypeError): + raise SystemExit(f"{PROG}: that isn't opencode auth export's output") from None + if not logins: + raise SystemExit(f"{PROG}: there's no {COPILOT} login in it") + value = max(logins, key=lambda c: c.get("active") is True)["value"] + if not isinstance(value.get("refresh"), str): + raise SystemExit(f"{PROG}: its {COPILOT} login has no token") + login = {"type": "oauth", "refresh": value["refresh"], "access": value.get("access") or value["refresh"], + "expires": value.get("expires") or 0} + metadata = value.get("metadata") or {} + login.update({key: metadata[key] for key in METADATA if isinstance(metadata.get(key), str)}) + return {COPILOT: login} + + +def main(): + if sys.argv[1:] == ["--login-from-export"]: + json.dump(login_from_export(sys.stdin.read()), sys.stdout) + print() + return + content = os.environ.get("OPENCODE_AUTH_CONTENT") + if not content: + sys.exit(f"{PROG}: no Copilot login (OPENCODE_AUTH_CONTENT)") + db = database() + if os.path.lexists(db): + sys.exit(f"{PROG}: {db} already exists, and opencode wouldn't take the login into it; " + f"use a fresh data directory or OPENCODE_DB") + login = credentials(content) + imported = subprocess.run(["opencode", "auth", "import", "--standalone"], input=json.dumps(login), + text=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + if imported.returncode != 0 or "Imported 1 credential" not in imported.stderr: + sys.exit(f"{PROG}: importing the login failed: {redact(imported.stderr.strip(), login)}") + os.execvp("opencode", ["opencode", *sys.argv[1:]]) + + +if __name__ == "__main__": + main() diff --git a/bot/test_coordinator_loop.py b/bot/test_coordinator_loop.py index a60db9f..bbe00fc 100644 --- a/bot/test_coordinator_loop.py +++ b/bot/test_coordinator_loop.py @@ -9,6 +9,7 @@ import os import re import shutil +import sqlite3 import subprocess import tempfile import time @@ -612,24 +613,43 @@ def test_the_default_config_loads_and_needs_no_one_in_particular(self): self.assertEqual(opencode.notes, os.path.join(here, "backend-opencode.md")) # The quadlet's environment. env = {"CLAUDE_CODE_OAUTH_TOKEN": "t", "OPENCODE_AUTH_CONTENT": "{}", "COORDINATOR_MODEL": "m", - "COORDINATOR_EFFORT": "high", "COORDINATOR_PROMPT": "Load it", "BOT_OPENCODE_MODEL": "gh/gpt"} + "COORDINATOR_EFFORT": "high", "COORDINATOR_PROMPT": "Load it", "BOT_OPENCODE_MODEL": "gh/gpt", + "BOT_OPENCODE_REVIEW_MODEL": "gh/claude"} self.assertEqual(claude.argv(env), ["claude", "--dangerously-skip-permissions", "--model", "m", "--effort", "high", "Load it"]) - self.assertEqual(opencode.argv(env), ["opencode", "--auto", "--model", "gh/gpt", "--prompt", "Load it"]) - # Its config is the image's managed /etc/opencode, which nothing the session writes overrides. - self.assertEqual(opencode.session_env(env), {"OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS": "true", - "OPENCODE_DISABLE_PROJECT_CONFIG": "true"}) + # Its own server, not opencode's shared background one; the login through opencode-copilot. + self.assertEqual(opencode.argv(env), ["opencode-copilot", "--standalone", "--auto", "--prompt", "Load it"]) + # Its config is the image's /etc/opencode, which nothing the session writes overrides; the + # models (the TUI has no --model) from OPENCODE_CONFIG_CONTENT. + session_env = opencode.session_env(env) + self.assertEqual(session_env["OPENCODE_DISABLE_PROJECT_CONFIG"], "true") + self.assertEqual(json.loads(session_env.pop("OPENCODE_CONFIG_CONTENT")), + {"model": "gh/gpt", "agents": {"second-opinion": {"model": "gh/claude"}}}) + self.assertEqual(set(session_env), {"OPENCODE_DISABLE_PROJECT_CONFIG"}) self.assertEqual(opencode.clean, ["~/.opencode", "${XDG_CONFIG_HOME:-~/.config}/opencode", - "${XDG_CACHE_HOME:-~/.cache}/opencode"]) + "${XDG_CACHE_HOME:-~/.cache}/opencode", + "${XDG_STATE_HOME:-~/.local/state}/opencode"]) self.assertEqual((opencode.archive, opencode.keep, opencode.relink), ( ["${XDG_DATA_HOME:-~/.local/share}/opencode"], ["${XDG_DATA_HOME:-~/.local/share}/opencode/auth.json"], {"${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md": "~/.local/share/bot-skills/AGENTS.md"})) + # The logins and accounts opencode 2 keeps in its database. + self.assertEqual(opencode.scrub, {"opencode.db": ["credential", "account", "control_account"]}) self.assertTrue(claude.proved_by_reading) conf = json.loads(occ.render()) - self.assertEqual(conf["agent"]["second-opinion"]["model"], "{env:BOT_OPENCODE_REVIEW_MODEL}") - self.assertEqual((conf["enabled_providers"], conf["autoupdate"], conf["share"], conf["permission"]), - (["github-copilot"], False, "disabled", {"question": "deny"})) - self.assertIn("git fetch origin pull/N/head", conf["agent"]["builder"]["prompt"]) + self.assertNotIn("model", conf["agents"]["second-opinion"]) # never an unset variable's empty model + self.assertEqual(conf["policies"], [ + {"action": "provider.use", "resource": "*", "effect": "deny"}, + {"action": "provider.use", "resource": "github-copilot", "effect": "allow"}, + {"action": "integration.use", "resource": "plugin:*", "effect": "deny"}, + {"action": "integration.use", "resource": "mcp:*", "effect": "deny"}, + {"action": "tool.use", "resource": "question:*", "effect": "deny"}]) + self.assertEqual((conf["update"], conf["share"], conf["permissions"], conf["shell"], conf["experimental"]), + ("disable", "disabled", [{"action": "question", "resource": "*", "effect": "deny"}], + "/bin/bash", {"portable_shell_scanner": False})) + self.assertIn("git fetch origin pull/N/head", conf["agents"]["builder"]["system"]) + # The review model is the session's to have. + self.assertEqual(opencode.missing({k: v for k, v in env.items() if k != "BOT_OPENCODE_REVIEW_MODEL"}), + "BOT_OPENCODE_REVIEW_MODEL is unset") # Without the Copilot login there is no failover, but Claude starts. self.assertEqual(opencode.missing({k: v for k, v in env.items() if k != "OPENCODE_AUTH_CONTENT"}), "OPENCODE_AUTH_CONTENT is unset") @@ -1566,7 +1586,7 @@ def test_a_half_typed_line_isnt_finished_in_the_next_session(self): class BuilderRulesTest(unittest.TestCase): - """The opencode builder's bash rules, as opencode 1.18 applies them: + """The opencode builder's shell rules, as opencode 2 applies them: each command node's text against every pattern with Wildcard.match (packages/core/src/util/wildcard.ts: * is anything, ? one character, a trailing " *" may also match nothing), the last match winning.""" @@ -1590,12 +1610,17 @@ def test_the_matcher_is_opencodes(self): def test_what_the_builder_may_run(self): conf = json.loads(occ.render()) - rules = list(conf["agent"]["builder"]["permission"]["bash"].items()) + permissions = conf["agents"]["builder"]["permissions"] + self.assertEqual(permissions[0], {"action": "subagent", "resource": "*", "effect": "deny"}) + self.assertEqual({r["action"] for r in permissions[1:]}, {"shell"}) + rules = [(r["resource"], r["effect"]) for r in permissions[1:]] # Only denies after the "*" allow: an allow after them would let # through a denied command that mentions it. self.assertEqual(rules[0], ("*", "allow")) self.assertEqual({a for _, a in rules[1:]}, {"deny"}) - self.assertEqual(conf["agent"]["builder"]["permission"]["task"], "deny") + # Each pattern once: the same 9725 as opencode 1's rules. + self.assertEqual(len({p for p, _ in rules}), len(rules)) + self.assertEqual(len(rules) - 1, 9725) denied = [ "git commit -m x", "git -C /x commit -m x", "git -c user.name=a commit", "git push origin HEAD", "git push", "git am < x.patch", "git cherry-pick abc", "git rebase main", "git pull", "git update-ref HEAD x", @@ -1725,6 +1750,75 @@ def test_the_data_directory_is_archived_and_made_afresh_with_the_login(self): self.assertEqual([e for e in kept if os.path.exists(os.path.join(archive, e, "auth.json"))], []) self.assertEqual(os.stat(archive).st_mode & 0o777, 0o700) # private + def test_the_archived_database_keeps_no_login(self): + token = "gho_" + "s3cret" * 4 + data = os.path.join(self.home, ".local", "share", "opencode") + archive = os.path.join(self.tmp.name, "archive") + os.makedirs(data) + + def database(path, wal): + db = sqlite3.connect(path) + if wal: + db.execute("PRAGMA journal_mode = WAL") + db.execute("PRAGMA wal_autocheckpoint = 0") + db.execute("CREATE TABLE credential (id TEXT, value TEXT)") + db.execute("CREATE TABLE account (id TEXT, access_token TEXT)") + db.execute("CREATE TABLE session_v2 (id TEXT, title TEXT)") + db.execute("INSERT INTO credential VALUES ('c', ?)", (json.dumps({"refresh": token}),)) + db.execute("INSERT INTO account VALUES ('a', ?)", (token,)) + db.execute("INSERT INTO session_v2 VALUES ('s', 'kept history')") + db.commit() + return db + cases = [ + # name, how the database was left: closed, or mid-write with its log (a killed opencode) + ("closed", False), + ("its write-ahead log not yet checkpointed", True), + ] + for name, wal in cases: + with self.subTest(name): + shutil.rmtree(archive, ignore_errors=True) + staging = os.path.join(self.tmp.name, "staging") + shutil.rmtree(staging, ignore_errors=True) + os.makedirs(staging) + db = database(os.path.join(staging, "opencode.db"), wal) + # Copied while still open: as a killed process leaves it. + for f in os.listdir(staging): + shutil.copy(os.path.join(staging, f), os.path.join(data, f)) + db.close() + if wal: + self.assertTrue(os.path.exists(os.path.join(data, "opencode.db-wal"))) + cl.clean([], [], {}, {}, ["~/.local/share/opencode"], archive, "s", + {"opencode.db": ["credential", "account", "control_account"]}) + archived = os.path.join(archive, "s-opencode") + self.assertEqual(os.listdir(archived), ["opencode.db"]) # no log left + db = sqlite3.connect(os.path.join(archived, "opencode.db")) + self.assertEqual(db.execute("SELECT count(*) FROM credential").fetchone()[0], 0) + self.assertEqual(db.execute("SELECT count(*) FROM account").fetchone()[0], 0) + self.assertEqual(db.execute("SELECT title FROM session_v2").fetchall(), [("kept history",)]) + db.close() + with open(os.path.join(archived, "opencode.db"), "rb") as f: + self.assertNotIn(token.encode(), f.read()) + self.assertEqual(os.listdir(data), []) + + def test_a_database_that_cant_be_scrubbed_isnt_archived(self): + self.files(".local/share/opencode/log/x.log") + with open(os.path.join(self.home, ".local/share/opencode/opencode.db"), "w") as f: + f.write("not a database, but maybe a login") + archive = os.path.join(self.tmp.name, "archive") + cl.clean([], [], {}, {}, ["~/.local/share/opencode"], archive, "s", {"opencode.db": ["credential"]}) + self.assertEqual(os.listdir(os.path.join(archive, "s-opencode")), ["log"]) + # A write-ahead log without its database goes too. + self.files(".local/share/opencode/opencode.db-wal", ".local/share/opencode/log/x.log") + cl.clean([], [], {}, {}, ["~/.local/share/opencode"], archive, "t", {"opencode.db": ["credential"]}) + self.assertEqual(os.listdir(os.path.join(archive, "t-opencode")), ["log"]) + + def test_scrub_takes_only_file_and_table_names(self): + base = 'name = "x"\ncommand = ["x"]\n' + for scrub in ['{ "a/b.db" = ["credential"] }', '{ "x.db" = ["credential; DROP"] }', '{ "x.db" = [] }', + '{ ".." = ["credential"] }']: + with self.subTest(scrub), self.assertRaises(cl.ConfigError): + cl.Backend(tomllib.loads(base + f"scrub = {scrub}\n"), "backend", []) + def archive_setup(self): self.files(".local/share/opencode/opencode.db") data = os.path.join(self.home, ".local", "share", "opencode") @@ -1895,7 +1989,7 @@ def test_a_backend_is_cleaned_before_it_starts_and_a_failed_clean_is_a_failed_st sw.tick(T0) self.assertEqual(world.backends(), ["opencode"]) cleaned.assert_called_once_with(["CLEANED"], [], {}, env, [], os.path.join(d, "archive", "opencode"), - mock.ANY) + mock.ANY, {}) # A clean that fails: no start; held after three, then the loop ends. world.running.clear() world.alive = False diff --git a/bot/test_opencode_copilot.py b/bot/test_opencode_copilot.py new file mode 100644 index 0000000..32049e8 --- /dev/null +++ b/bot/test_opencode_copilot.py @@ -0,0 +1,89 @@ +"""Tests of opencode-copilot's login formats and its refusal of an existing database.""" +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPT = os.path.join(HERE, "opencode-copilot") +_loader = importlib.machinery.SourceFileLoader("opencode_copilot", SCRIPT) +oc = importlib.util.module_from_spec(importlib.util.spec_from_loader("opencode_copilot", _loader)) +_loader.exec_module(oc) + + +class LoginTest(unittest.TestCase): + def test_logins_go_both_ways(self): + cases = [ + # name, opencode 1's auth.json entry, the credential's metadata + ("github.com", {"type": "oauth", "refresh": "gho_a", "access": "gho_a", "expires": 0}, None), + ("Enterprise Cloud", {"type": "oauth", "refresh": "gho_b", "access": "gho_b", "expires": 0, + "enterpriseUrl": "acme.ghe.com"}, {"enterpriseUrl": "acme.ghe.com"}), + ("with its API endpoint", {"type": "oauth", "refresh": "gho_c", "access": "gho_c", "expires": 0, + "apiEndpoint": "https://api.business.githubcopilot.com"}, + {"apiEndpoint": "https://api.business.githubcopilot.com"}), + ] + for name, login, metadata in cases: + with self.subTest(name): + [credential] = oc.credentials(json.dumps({"github-copilot": login, "other": {"type": "api"}})) + self.assertEqual((credential["id"], credential["integrationID"], credential["active"]), + ("cred_bot_copilot", "github-copilot", True)) + self.assertEqual(credential["value"].get("metadata"), metadata) + self.assertEqual(credential["value"]["refresh"], login["refresh"]) + self.assertEqual(oc.login_from_export(json.dumps([credential])), {"github-copilot": login}) + + def test_the_active_copilot_login_is_taken_from_an_export(self): + def entry(token, active, integration="github-copilot"): + return {"id": token, "integrationID": integration, "label": "OAuth", "active": active, + "value": {"type": "oauth", "methodID": "device", "refresh": token, "access": token, "expires": 0}} + exported = [entry("old", False), entry("other", True, "openai"), entry("new", True)] + self.assertEqual(oc.login_from_export(json.dumps(exported))["github-copilot"]["refresh"], "new") + + def test_bad_logins_are_refused(self): + for content in ["", "not json", "{}", '{"github-copilot": {}}', "[]"]: + with self.subTest(content), self.assertRaises(SystemExit): + oc.credentials(content) + for exported in ["{}", "[]", '[{"integrationID": "openai", "value": {"type": "oauth"}}]', + '[{"integrationID": "github-copilot", "value": {"type": "oauth"}}]']: + with self.subTest(exported), self.assertRaises(SystemExit): + oc.login_from_export(exported) + + def test_a_failed_import_doesnt_show_the_token(self): + with tempfile.TemporaryDirectory() as d: + # An opencode that fails, quoting what it was given. + fake = os.path.join(d, "opencode") + with open(fake, "w") as f: + f.write("#!/bin/sh\ncat >&2\nexit 1\n") + os.chmod(fake, 0o755) + env = {"PATH": f"{d}:/usr/bin:/bin", "HOME": d, "XDG_DATA_HOME": d, + "OPENCODE_AUTH_CONTENT": json.dumps({"github-copilot": {"refresh": "gho_s3cret", "access": "tid_x"}})} + p = subprocess.run([sys.executable, "-I", SCRIPT, "run", "x"], env=env, capture_output=True, text=True) + self.assertEqual(p.returncode, 1) + self.assertIn("importing the login failed", p.stderr) + self.assertNotIn("gho_s3cret", p.stderr) + self.assertNotIn("tid_x", p.stderr) + self.assertIn("[token]", p.stderr) + + def test_an_existing_database_is_refused_before_opencode_runs(self): + with tempfile.TemporaryDirectory() as d: + data = os.path.join(d, "opencode") + os.makedirs(data) + open(os.path.join(data, "opencode.db"), "w").close() + # No opencode on PATH: it must stop before needing one. + env = {"PATH": "/nonexistent", "HOME": d, "XDG_DATA_HOME": d, + "OPENCODE_AUTH_CONTENT": json.dumps({"github-copilot": {"refresh": "gho_x"}})} + for extra, db in [({}, os.path.join(data, "opencode.db")), + ({"OPENCODE_DB": os.path.join(d, "own.db")}, os.path.join(d, "own.db"))]: + with self.subTest(db=db): + open(db, "a").close() + p = subprocess.run([sys.executable, "-I", SCRIPT, "run", "x"], env=dict(env, **extra), + capture_output=True, text=True) + self.assertEqual(p.returncode, 1) + self.assertIn(f"{db} already exists", p.stderr) + + +if __name__ == "__main__": + unittest.main()