From 40127ef9a1ef7400ebd96cfcfb39f49664fbac9f Mon Sep 17 00:00:00 2001 From: Joseph Marrero Date: Wed, 21 Oct 2026 03:35:02 +0000 Subject: [PATCH 1/2] bot-coordinator: Empty a backend's login tables in its archived database opencode 2 keeps its logins (and any OpenCode Console account) in its SQLite database instead of auth.json, so archiving its data directory as it is would put the Copilot token into every archive, which #18 was careful to avoid. A backend's new `scrub` names databases in an archived directory and the tables to empty there; the rows are deleted with secure_delete and the file vacuumed, and its write-ahead log is checkpointed first, since a stopped session can leave writes there. A database that can't be scrubbed is dropped from the archive rather than kept. Prep for moving the opencode backend to opencode 2. Generated-by: AI --- bot/coordinator/backends.toml | 7 +-- bot/coordinator/coordinator-loop.py | 55 ++++++++++++++++++++-- bot/test_coordinator_loop.py | 72 ++++++++++++++++++++++++++++- 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/bot/coordinator/backends.toml b/bot/coordinator/backends.toml index f91b7c6..4fd5959 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 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/test_coordinator_loop.py b/bot/test_coordinator_loop.py index a60db9f..d2a4501 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 @@ -1725,6 +1726,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 +1965,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 From 41d1329b232e26a721676058f2bffced93869cdc Mon Sep 17 00:00:00 2001 From: Joseph Marrero Date: Wed, 21 Oct 2026 03:35:53 +0000 Subject: [PATCH 2/2] bot: Move from opencode 1.19 (opencode-ai) to opencode 2 (@opencode/cli) opencode 2 is a separate package with a new config format and a new config merge, and it no longer reads OPENCODE_AUTH_CONTENT, which botd, bot-opencode and the coordinator's opencode backend all relied on (jmarrero-forge/tracker#14). The system config (/etc/opencode) now loads after every file a session can write, and opencode 2 joins agents' rules as lists in load order instead of merging them by key, so the builder's shell rules come last whatever a session's own config says. Its policies are a separate layer that can only take things away, evaluated after every other file's: they hold the Copilot provider pin and now also refuse plugins, MCP servers and the question tool, so code a session plants in its config can't run either. The builder's 9725 patterns are the same as before, only in the new format (bash -> shell, task -> subagent), with the shell and its parser pinned. subagent_depth goes back to its default: opencode 1 never applied the 2 it was set to (it refused the task tool to every subagent), and opencode 2 would. check-opencode-config.py is ported to all of that: it plants config of each kind (and a plugin and an MCP server) with project config off and on, with and without the loop's clean, and into a running server, which reloads config files as they change. So the per-start clean is no longer what protects those rules. It stays, because opencode 2 takes a login only into a new database: opencode-copilot imports OPENCODE_AUTH_CONTENT with `opencode auth import` and refuses an existing database, and the coordinator's archived data directory (its logins scrubbed), botd's fresh job containers and a database of bot-opencode's own per run give it one. The secret keeps opencode 1's auth.json format, so the stored login and the usage probe work unchanged; copilot-login.sh converts a new login into it. Every run uses --standalone, a private server tied to the client, because by default opencode 2 attaches to a shared background service that would outlive the session and its generation. The TUI has no --model any more, so the backend passes the models in OPENCODE_CONFIG_CONTENT, rather than as {env:...} in the system config, where an unset variable would make the model invalid. Generated-by: AI --- bot/SETUP.md | 3 +- bot/agent.Containerfile | 21 +- bot/config.example.toml | 7 +- bot/coordinator/Containerfile | 6 +- bot/coordinator/README.md | 103 +++--- bot/coordinator/backend-opencode.md | 32 +- bot/coordinator/backends.toml | 31 +- bot/coordinator/bot-coordinator.container | 4 +- bot/coordinator/bot-opencode | 27 +- bot/coordinator/check-opencode-config.py | 364 +++++++++++++--------- bot/coordinator/opencode-config.py | 86 +++-- bot/coordinator/operator-notes.md | 2 +- bot/copilot-login.sh | 18 +- bot/opencode-copilot | 101 ++++++ bot/test_coordinator_loop.py | 50 ++- bot/test_opencode_copilot.py | 89 ++++++ 16 files changed, 671 insertions(+), 273 deletions(-) create mode 100644 bot/opencode-copilot create mode 100644 bot/test_opencode_copilot.py 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 4fd5959..cdca2f8 100644 --- a/bot/coordinator/backends.toml +++ b/bot/coordinator/backends.toml @@ -58,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/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 d2a4501..bbe00fc 100644 --- a/bot/test_coordinator_loop.py +++ b/bot/test_coordinator_loop.py @@ -613,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") @@ -1567,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.""" @@ -1591,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", 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()