From 3a52702a8c0dcf61b02d9bfc1aecb0ede4eb9c95 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 23:51:53 -0800 Subject: [PATCH 01/10] chore(quest): claim quest/m0/opencode-sdk.md Quest-Claim: 73aef194891d798b From a0b09af0243f78f32e8285f3997da36a3183b758 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 00:37:51 -0800 Subject: [PATCH 02/10] wip: drive opencode 2.x through @opencode/client --- bun.lock | 22 +- flake.lock | 17 - flake.nix | 15 +- package.json | 2 +- src/runner/opencode.ts | 533 +++++++++++++++++++------------ test/live/runner-sandbox.test.ts | 67 +++- test/runner.test.ts | 322 ++++++++++++------- 7 files changed, 594 insertions(+), 384 deletions(-) diff --git a/bun.lock b/bun.lock index 351bac3a..370e0612 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "quest", "dependencies": { - "@opencode-ai/sdk": "1.19.0", + "@opencode/client": "2.1.1", "commander": "15.0.0", "mdast-util-from-markdown": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.1", @@ -202,7 +202,11 @@ "@noble/hashes": ["@noble/hashes@2.4.0", "", {}, "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.19.0", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-lDFYTD6qxzq1mfwxF6Kq1DAs6rEwF4xoZ2I3G1sB7hAZ8Yb28JurtSmmcK7ZzZ8Z4qBUYbZimHJ6DM4JFq5amw=="], + "@opencode/client": ["@opencode/client@2.1.1", "", { "dependencies": { "@opencode/protocol": "2.1.1", "@opencode/schema": "2.1.1" }, "peerDependencies": { "effect": "4.0.1", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-0/JTWitCw2gkr4o+ra+76UeeSpsq7mTFUUtKMPMt4aFu+rYgZw9Lkw4EJgxYK7p3sefiiKBA8KnX0a3LbxIjkw=="], + + "@opencode/protocol": ["@opencode/protocol@2.1.1", "", { "dependencies": { "@opencode/schema": "2.1.1", "effect": "4.0.1" } }, "sha512-rFsaileXP/W7GNpt2C8GcF8WtLYzyoiaq/xMLkHfAk8xnH5jMNaWJL8DRawUM2FvCaI6H6SKeX4gw6eDGhyl6A=="], + + "@opencode/schema": ["@opencode/schema@2.1.1", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.1" } }, "sha512-Qh8EmvNeE/4UzSbkvvwQ2DHOOFZMk8dteeIsX//+qMhjTXJ+xGoWo5bsoauv6pYnqaQYtEwwpMaqk2N0rO4QaA=="], "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], @@ -360,8 +364,6 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -374,6 +376,8 @@ "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + "effect": ["effect@4.0.1", "", {}, "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], @@ -390,8 +394,6 @@ "hono": ["hono@4.13.14", "", {}, "sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -500,8 +502,6 @@ "obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -534,10 +534,6 @@ "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], @@ -574,8 +570,6 @@ "vitest": ["vitest@5.0.5", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.4.3", "obug": "^3.0.0", "picomatch": "^4.0.7", "std-env": "^4.3.0", "tinybench": "^6.2.2", "tinyexec": "^1.3.1", "tinyglobby": "^0.2.17", "why-is-node-running": "3.2.1" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.5", "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.5", "@vitest/coverage-v8": "5.0.5", "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "why-is-node-running": ["why-is-node-running@3.2.1", "", { "bin": "cli.js" }, "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ=="], "workerd": ["workerd@1.20261012.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261012.1", "@cloudflare/workerd-darwin-arm64": "1.20261012.1", "@cloudflare/workerd-linux-64": "1.20261012.1", "@cloudflare/workerd-linux-arm64": "1.20261012.1", "@cloudflare/workerd-windows-64": "1.20261012.1" }, "bin": "bin/workerd" }, "sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA=="], diff --git a/flake.lock b/flake.lock index dac966cf..48ad9e03 100644 --- a/flake.lock +++ b/flake.lock @@ -34,22 +34,6 @@ "type": "github" } }, - "nixpkgs-opencode": { - "locked": { - "lastModified": 1792116957, - "narHash": "sha256-EjZqHKObBfIOeHZnMRVV87HB3pPq/WBkmRJWyFxmZu8=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "f20cd90e28fe0403873f473b29e42662cd86e8c4", - "type": "github" - } - }, "quest-pinned": { "flake": false, "locked": { @@ -70,7 +54,6 @@ "inputs": { "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", - "nixpkgs-opencode": "nixpkgs-opencode", "quest-pinned": "quest-pinned" } }, diff --git a/flake.nix b/flake.nix index c84ae3c5..9c2601ba 100644 --- a/flake.nix +++ b/flake.nix @@ -3,10 +3,6 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; - # Temporary: opencode 1.19.0, matching the @opencode-ai/sdk pin, from the - # nixpkgs before opencode 2. /quest/m0/opencode-sdk.md migrates the runner - # to opencode 2 and removes this input. - nixpkgs-opencode.url = "github:NixOS/nixpkgs/f20cd90e28fe0403873f473b29e42662cd86e8c4"; flake-utils.url = "github:numtide/flake-utils"; # A reviewed quest for the dev shell's merge driver, so local merges resolve # link lists without running the checkout's own, possibly unreviewed, code. @@ -23,7 +19,6 @@ { self, nixpkgs, - nixpkgs-opencode, flake-utils, quest-pinned, ... @@ -43,7 +38,7 @@ # Claude Code is unfree; the runner package pins it so adapters run a tested version. config.allowUnfreePredicate = pkg: nixpkgs.lib.getName pkg == "claude-code"; }; - opencode = nixpkgs-opencode.legacyPackages.${system}.opencode; + inherit (pkgs) opencode; quest = pkgs.callPackage ./nix/package.nix { }; questPinned = pkgs.callPackage "${quest-pinned}/nix/package.nix" { }; # https://downloads.claude.ai/claude-code-releases/2.1.305/manifest.zst.json @@ -73,8 +68,8 @@ manifest = pkgs.lib.importJSON ./package.json; # package.json's packageManager pins Bun for CI runners without Nix. bunVersion = pkgs.lib.removePrefix "bun@" manifest.packageManager; - # The runner drives the pinned opencode through the SDK of the same release. - opencodeSdk = manifest.dependencies."@opencode-ai/sdk"; + # The runner drives the pinned opencode through the client of the same release. + opencodeClient = manifest.dependencies."@opencode/client"; in { packages = { @@ -124,8 +119,8 @@ touch "$out" ''; opencode-sdk = pkgs.runCommand "opencode-sdk" { } '' - if [ "${opencode.version}" != "${opencodeSdk}" ]; then - echo "package.json pins @opencode-ai/sdk ${opencodeSdk}, but the runner pins opencode ${opencode.version}" >&2 + if [ "${opencode.version}" != "${opencodeClient}" ]; then + echo "package.json pins @opencode/client ${opencodeClient}, but the runner pins opencode ${opencode.version}" >&2 exit 1 fi touch "$out" diff --git a/package.json b/package.json index b29888e7..8167721c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "fix": "prettier --write ." }, "dependencies": { - "@opencode-ai/sdk": "1.19.0", + "@opencode/client": "2.1.1", "commander": "15.0.0", "mdast-util-from-markdown": "2.1.0", "mdast-util-gfm-strikethrough": "2.0.1", diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 335f3d54..5b3e8ece 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -1,35 +1,37 @@ // The opencode adapter. It runs the unmodified `opencode` binary as a headless -// `opencode serve` on localhost behind a per-launch password and drives one -// session through its SDK: each turn is one prompt, and the session's events -// stream as raw output. Each account signs in with opencode's own -// `auth login` in its own XDG home; the runner reads only which providers that -// home signed in to, never the credentials. The kix tools attach as a remote -// MCP server, and opencode's question tool is denied so every question goes -// through kix's `ask` instead. +// `opencode serve --stdio` on localhost behind a per-launch password and drives +// one session through `@opencode/client`: each turn is one prompt, and the +// session's events stream as raw output. Each account signs in with opencode's +// own `auth login` in its own XDG home and may use only the providers it signed +// in to then; the runner reads which those are, never the credentials. The kix +// tools attach as a remote MCP server, and opencode's question tool is denied so +// every question goes through kix's `ask` instead. // // As with Claude Code, the sandbox confines the agent's commands, not the CLI: -// `opencode serve`, which holds the account's credentials, the server password, -// and the tools' token, runs outside it, and its own file tools follow -// permission rules and a plugin instead. opencode runs every command through -// its configured shell, which here is a script that clears the server's +// the server, which holds the account's credentials, the server password, and +// the tools' token, runs outside it, and its own file tools follow permission +// rules and a plugin instead. opencode runs every command through its +// configured shell, which here is a script that clears the server's // environment and runs the command under sandbox-runtime (`srt`) with the // policy Claude Code's sandbox gets, the devShell sourced inside. Nothing the // checkout holds runs in the server: no project config, plugins, tools, or MCP -// servers, and no language servers or formatters. opencode 1.19 still loads the -// plugins a checkout's own config names, so that config must not exist at all. -import { spawn } from "node:child_process"; +// servers, and no language servers or formatters. Before each turn the runner +// checks that opencode loaded no config but its own and the account's, and that +// its plugin is active, since opencode would only log a plugin it could not load. +import { execFile, spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, join, relative } from "node:path"; +import { promisify } from "node:util"; import { - type AssistantMessage, - createOpencodeClient, - type Event, - type Part, - type SessionStatus, -} from "@opencode-ai/sdk/v2/client"; + type LocationRef, + OpenCode, + type OpenCodeClient, + type OpenCodeEvent, + type SessionMessageAssistant, + type SessionStructuredError, +} from "@opencode/client"; import { boundedLines, LINE_LIMIT } from "../cli/agent"; import { @@ -44,6 +46,8 @@ import { } from "./adapter"; import { keptEnvironment, quote, runtimeSettings, type Sandbox } from "./sandbox"; +const run = promisify(execFile); + /** The XDG base directories opencode keeps its config, credentials, sessions, and caches in, with their defaults. */ const XDG = { XDG_CONFIG_HOME: ".config", @@ -80,29 +84,63 @@ export function environment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { ); } -/** The model the account picked with `--model`, from its own opencode config. */ -function accountModel(home: string): string | undefined { +/** opencode's environment in an account's home, as `HOME` too, since opencode loads plugins and skills from it. */ +function accountEnvironment(env: NodeJS.ProcessEnv, home: string): NodeJS.ProcessEnv { + return { ...environment(env), ...accountDirectories(home), HOME: home }; +} + +/** The account's own opencode config: which providers it may use, and the model it picked. */ +function accountConfig(home: string): string { + return join(accountDirectories(home).XDG_CONFIG_HOME, "opencode", "opencode.json"); +} + +interface AccountSettings { + /** opencode's provider IDs the account signed in to, the only ones its jobs may use. */ + providers: string[]; + /** The model the account picked with `--model`, or opencode's default. */ + model?: string; +} + +function readAccountSettings(home: string): Partial { + let parsed: unknown; try { - const { model } = JSON.parse(readFileSync(modelConfig(home), "utf8")) as { model?: unknown }; - return typeof model === "string" ? model : undefined; + parsed = JSON.parse(readFileSync(accountConfig(home), "utf8")); } catch { - return undefined; + return {}; } + const { model, policies } = (parsed ?? {}) as { model?: unknown; policies?: unknown }; + const allowed = Array.isArray(policies) + ? policies.flatMap((p: { action?: unknown; resource?: unknown; effect?: unknown }) => + p.action === "provider.use" && p.effect === "allow" && typeof p.resource === "string" ? [p.resource] : [], + ) + : undefined; + return { ...(allowed && { providers: allowed }), ...(typeof model === "string" && { model }) }; } -function modelConfig(home: string): string { - return join(accountDirectories(home).XDG_CONFIG_HOME, "opencode", "opencode.json"); +/** + * Write the account's opencode config: a policy that denies every provider but + * the ones it signed in to, so a key the runner's environment happens to hold + * never pays for its jobs, and the model it picked. + */ +function writeAccountSettings(home: string, settings: AccountSettings): void { + const file = accountConfig(home); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const policies = [ + { action: "provider.use", resource: "*", effect: "deny" }, + ...settings.providers.map((resource) => ({ action: "provider.use", resource, effect: "allow" })), + ]; + const config = { ...(settings.model && { model: settings.model }), policies }; + writeFileSync(file, `${JSON.stringify(config, null, "\t")}\n`, { mode: 0o600 }); } /** * opencode's config in a checkout. `OPENCODE_DISABLE_PROJECT_CONFIG` keeps - * most of it out, but opencode 1.19's newer config loader still reads these at - * the checkout's root and imports the plugins they name, in the server and - * outside the sandbox. So the agent cannot create or change them, and a launch - * refuses a checkout that holds them. An empty file there is harmless: opencode - * ignores an empty config, and finds no plugins in a file named `.opencode`. - * It is also what sandbox-runtime leaves behind as a mount point when a - * command is killed. + * opencode from loading any of it, and the runner checks that it did not + * before each turn, but the agent still cannot create or change these, and a + * launch refuses a checkout that holds them. An empty file there is harmless: + * opencode ignores an empty config, and finds no plugins in a file named + * `.opencode`. It is also what sandbox-runtime leaves behind as a mount point + * when a command is killed. */ export const PROJECT_CONFIG = [".opencode", "opencode.json", "opencode.jsonc"]; @@ -118,6 +156,9 @@ export function projectConfig(cwd: string): string[] { }); } +/** The runner's plugin's ID, which the runner checks is active before each turn. */ +const PLUGIN_ID = "quest-runner"; + /** * An opencode plugin that keeps the file tools inside the checkout and the * attempt's private directory, and lets the read-only ones also read @@ -127,17 +168,18 @@ export function projectConfig(cwd: string): string[] { * unconfined. Each path is resolved as the tool resolves it, then through * every symlink, and a path that does not exist yet through its nearest * existing directory. A dangling symlink is refused, since a write would - * follow it. `apply_patch` names its files in the patch's headers. Nothing - * writes the checkout's own opencode config, which opencode would load. The - * tool opens the path after this check, so a command running alongside it - * could still swap the path for a symlink in between: the check narrows that - * window rather than closing it. + * follow it. `patch` names its files in the patch's headers. Nothing writes + * the checkout's own opencode config. A tool's input may arrive as a JSON + * string, which opencode repairs into its object. The tool opens the path + * after this check, so a command running alongside it could still swap the + * path for a symlink in between: the check narrows that window rather than + * closing it. */ export const PLUGIN = `import { lstatSync, realpathSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; // Each file tool, and whether it only reads. -const TOOLS = { read: true, glob: true, grep: true, lsp: true, write: false, edit: false, apply_patch: false }; +const TOOLS = { read: true, glob: true, grep: true, write: false, edit: false, patch: false }; const HEADER = /^\\s*\\*\\*\\* (?:Add File|Update File|Delete File|Move to):(.*)$/; function real(path) { @@ -164,43 +206,51 @@ function inside(path, roots) { }); } +function parsed(input) { + if (typeof input !== "string") return input; + try { + return JSON.parse(input); + } catch { + return undefined; + } +} + export default { - id: "quest-runner", - server: async (_input, options) => { + id: ${JSON.stringify(PLUGIN_ID)}, + setup: async (ctx) => { + const options = ctx.options; const writes = options.writes.map(real); const reads = [...writes, ...options.reads.map(real)]; const config = options.config.map(real); - return { - "tool.execute.before": async ({ tool }, { args }) => { - if (!(tool in TOOLS)) return; - const paths = - tool === "apply_patch" - ? String(args?.patchText ?? "") - .split(/\\r\\n|\\r|\\n/) - .flatMap((line) => HEADER.exec(line)?.[1]?.trim() || []) - : [args?.filePath, args?.path].filter((path) => typeof path === "string" && path); - for (const path of paths) { - const target = real(resolve(options.cwd, path)); - if (!inside(target, TOOLS[tool] ? reads : writes)) - throw new Error(\`\${path} is outside the checkout, where the runner keeps the file tools\`); - if (!TOOLS[tool] && inside(target, config)) - throw new Error(\`\${path} is opencode's own config, which would run outside the sandbox\`); - } - }, - }; + await ctx.tool.hook("execute.before", ({ tool, input }) => { + if (!Object.hasOwn(TOOLS, tool)) return; + const args = parsed(input); + const paths = + tool === "patch" + ? String(args?.patchText ?? "") + .split(/\\r\\n|\\r|\\n/) + .flatMap((line) => HEADER.exec(line)?.[1]?.trim() || []) + : [args?.path].filter((path) => typeof path === "string" && path); + for (const path of paths) { + const target = real(resolve(options.cwd, path)); + if (!inside(target, TOOLS[tool] ? reads : writes)) + throw new Error(\`\${path} is outside the checkout, where the runner keeps the file tools\`); + if (!TOOLS[tool] && inside(target, config)) + throw new Error(\`\${path} is opencode's own config, which would run outside the sandbox\`); + } + }); }, }; `; /** * The shell opencode runs every command through, as ` -c `: - * the bash tool, the `!` shell, and commands in templates. It starts the - * command from the runner's kept variables rather than the server's - * environment, which holds the server password, the tools' token in - * `OPENCODE_CONFIG_CONTENT`, and any provider key opencode exported, then - * sources the private HOME and the devShell inside the sandbox before the - * command, as Claude Code does with `CLAUDE_ENV_FILE`. srt replaces the - * command's TMPDIR with its own `CLAUDE_CODE_TMPDIR`, or `/tmp/claude` + * the shell tool, and commands in templates. It starts the command from the + * runner's kept variables rather than the server's environment, which holds + * the tools' token in `OPENCODE_CONFIG_CONTENT` and any provider key opencode + * exported, then sources the private HOME and the devShell inside the sandbox + * before the command, as Claude Code does with `CLAUDE_ENV_FILE`. srt replaces + * the command's TMPDIR with its own `CLAUDE_CODE_TMPDIR`, or `/tmp/claude` * without it, so that names the attempt's directory too. */ export function shellScript(srt: string, settings: string, sandbox: Sandbox, env: NodeJS.ProcessEnv): string { @@ -226,53 +276,61 @@ function toolOutput(home: string): string { /** * opencode's config for a job. Every tool is allowed, as Claude Code's - * permission mode allows its own, except questions, which go through kix, - * WebFetch, which would run in the unconfined server, and the file tools' - * paths outside the checkout and the attempt's directory. The last matching - * rule wins. + * permission mode allows its own, except: questions, which go through kix; + * WebFetch, web search, and the desktop browser, which would run in the + * unconfined server; Code Mode's `execute`, which carries opencode's own + * tools, such as moving the session to another directory; and the file tools' paths outside the checkout + * and the attempt's directory. A tool whose last matching rule denies it + * everywhere is not offered at all. The kix tools stay direct tools rather than + * Code Mode's. `instructions` is the file holding the launch's system prompt. */ export function jobConfig(options: { launch: Launch; - model: string | undefined; shell: string; plugin: string; + instructions: string; }): Record { const { launch } = options; + const deny = (action: string, resource = "*") => ({ action, resource, effect: "deny" }); return { - // The account's pick outranks any config opencode loads before this one. - ...(options.model && { model: options.model }), - autoupdate: false, + update: "disable", share: "disabled", - // Only what the account signed in to, not keys the user's environment happens to hold. - enabled_providers: credentials(launch.home).map((c) => c.provider), shell: options.shell, - permission: { - "*": "allow", - question: "deny", - webfetch: "deny", - external_directory: { "*": "deny", [join(launch.sandbox.tmp, "*")]: "allow" }, - }, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + deny("question"), + deny("webfetch"), + deny("websearch"), + deny("execute"), + deny("browser"), + deny("external_directory"), + { action: "external_directory", resource: join(launch.sandbox.tmp, "*"), effect: "allow" }, + ], // Both would run the repository's own tools in the server. lsp: false, formatter: false, + instructions: [options.instructions], mcp: { - kix: { - type: "remote", - url: launch.tools.url, - headers: { Authorization: `Bearer ${launch.tools.token}` }, - oauth: false, + servers: { + kix: { + type: "remote", + url: launch.tools.url, + headers: { Authorization: `Bearer ${launch.tools.token}` }, + oauth: false, + codemode: false, + }, }, }, - plugin: [ - [ - pathToFileURL(options.plugin).href, - { + plugins: [ + { + package: options.plugin, + options: { cwd: launch.cwd, writes: [launch.cwd, launch.sandbox.tmp], reads: [toolOutput(launch.home)], config: PROJECT_CONFIG.map((name) => join(launch.cwd, name)), }, - ], + }, ], }; } @@ -280,25 +338,22 @@ export function jobConfig(options: { export interface Credential { /** opencode's provider ID, such as openai or opencode-go. */ provider: string; - /** How it signed in: oauth for a plan login, api for a key. */ + /** How it signed in: oauth for a plan login, key for an API key, external for a credential managed elsewhere. */ type: string; } -/** Which providers an account's home signed in to, from opencode's credential file, without the credentials. */ -export function credentials(home: string): Credential[] { - let parsed: unknown; - try { - parsed = JSON.parse( - readFileSync(join(accountDirectories(home).XDG_DATA_HOME, "opencode", "auth.json"), "utf8"), - ); - } catch { - return []; - } - if (!parsed || typeof parsed !== "object") return []; - return Object.entries(parsed).flatMap(([provider, value]) => { - const type = (value as { type?: unknown } | null)?.type; - return typeof type === "string" ? [{ provider: provider.replace(/\/+$/, ""), type }] : []; - }); +/** The stored credentials in `opencode auth list --format json` output, without the credentials themselves. */ +export function credentials(list: unknown): Credential[] { + if (!Array.isArray(list)) throw new Error("opencode auth list printed no list"); + return list.flatMap((integration: { id?: unknown; connections?: unknown }) => + typeof integration?.id === "string" && Array.isArray(integration.connections) + ? integration.connections.flatMap((connection: { type?: unknown; method?: unknown }) => + connection?.type === "credential" && typeof connection.method === "string" + ? [{ provider: integration.id as string, type: connection.method }] + : [], + ) + : [], + ); } /** What kix shows for a credential, and whether it is a plan rather than a metered key. */ @@ -325,59 +380,93 @@ export function signedIn(found: Credential[]): SignedIn | null { const LIMIT = /usage limit|usage_limit_reached|insufficient_quota|quota exceeded|exceeded your current quota|free usage exceeded/i; -/** - * Whether a retry opencode is waiting on is an account limit: an OpenCode Go - * limit retries when it resets, and other limits only say so in the message. - * opencode would otherwise wait until the reset, however far off. - */ -export function retryLimit(status: SessionStatus): Exit["limited"] { - if (status.type !== "retry") return null; - if (status.action?.reason === "account_rate_limit") return { until: status.next }; - if (status.action?.reason === "free_tier_limit" || LIMIT.test(status.message)) return { until: null }; - return null; -} - function object(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } -/** When a limit resets, from a ChatGPT-style error body or a `retry-after` header. */ -function resets(body: string | undefined, headers: Record | undefined, now: number): number | null { +/** When a limit resets, from a ChatGPT-style error body. */ +function resets(body: string | undefined, now: number): number | null { let error: Record | undefined; try { error = object(object(JSON.parse(body ?? ""))?.error); } catch {} if (typeof error?.resets_at === "number") return error.resets_at * 1000; if (typeof error?.resets_in_seconds === "number") return now + error.resets_in_seconds * 1000; - const after = Number(headers?.["retry-after"]); - return after > 0 ? now + after * 1000 : null; + return null; +} + +/** + * Whether a provider error is the account's limit: opencode's quota error, + * which it never retries, or a limit only the message names, which it would + * retry until the reset, however far off. + */ +export function accountLimit(error: SessionStructuredError, now = Date.now()): Exit["limited"] { + const body = error.response?.body; + if (error.type !== "provider.quota" && !LIMIT.test(`${error.message}\n${body ?? ""}`)) return null; + return { until: resets(body, now) }; } -/** How a turn ended, from its last assistant message and any limit seen while it ran. */ +/** + * How a turn ended, from its last assistant message, the error its run failed + * with if it never answered, and any limit seen while it ran. + */ export function turnExit( - info: AssistantMessage | undefined, - parts: Part[], + info: SessionMessageAssistant | undefined, + failed: SessionStructuredError | undefined, limited: Exit["limited"], now = Date.now(), ): Exit { - const error = info?.error; - if (!limited && error?.name === "APIError") { - const { message, responseBody, responseHeaders } = error.data; - if (LIMIT.test(`${message}\n${responseBody ?? ""}`)) - limited = { until: resets(responseBody, responseHeaders, now) }; - } - const text = parts - .flatMap((part) => (part.type === "text" && !part.synthetic ? [part.text] : [])) + const error = info?.error ?? failed; + if (!limited && error) limited = accountLimit(error, now); + const text = (info?.content ?? []) + .flatMap((part) => (part.type === "text" ? [part.text] : [])) .join("\n") .trim(); - const message = (error?.data as { message?: unknown } | undefined)?.message; - return { - limited, - failed: !info || error !== undefined, - reply: text || (typeof message === "string" ? message : error?.name), - }; + return { limited, failed: !info || error !== undefined, reply: text || error?.message }; } +/** + * Why a turn must not run, or null: opencode loaded config the runner did not + * write, which could outrank its sandbox settings, such as a machine's managed + * `/etc/opencode`, or the runner's plugin is not active. + */ +async function refusal(client: OpenCodeClient, location: LocationRef, home: string): Promise { + // opencode loads its plugins before it can name a model. + await client.model.default({ location }); + const own = join(accountDirectories(home).XDG_CONFIG_HOME, "opencode"); + const entries = await client.config.get({ location }); + const foreign = entries.flatMap((entry) => (entry.path && relative(own, entry.path).startsWith("..") ? [entry.path] : [])); + // Only the runner's own config arrives without a path. + const unnamed = entries.filter((entry) => !entry.path).length; + if (foreign.length || unnamed !== 1) + return `opencode loaded config the runner did not write (${foreign.join(", ") || `${unnamed} documents without a path`}), which could outrank its sandbox settings`; + const plugin = (await client.plugin.list({ location })).data.find((p) => p.id === PLUGIN_ID); + if (plugin?.state.status !== "active") + return `opencode did not load the runner's plugin, which keeps its file tools in the checkout${plugin?.state.status === "failed" ? `: ${plugin.state.error}` : ""}`; + return null; +} + +/** The last assistant message after `user`, the turn's prompt, newest first through the session's messages. */ +async function reply(client: OpenCodeClient, session: string, user: string): Promise { + let cursor: string | undefined; + let last: SessionMessageAssistant | undefined; + do { + const page = await client.message.list( + cursor ? { sessionID: session, limit: 200, cursor } : { sessionID: session, limit: 200, order: "desc" }, + ); + for (const message of page.data) { + if (message.id === user) return last; + if (message.type === "assistant") last ??= message; + } + cursor = page.cursor.next ?? undefined; + } while (cursor); + throw new Error(`opencode lost the turn's prompt ${user}`); +} + +/** Bun's fetch gives up on a request after about five minutes, which a turn's wait and event stream outlast. */ +const unbounded = ((request: Parameters[0], init?: RequestInit) => + fetch(request, { ...init, timeout: false } as RequestInit)) as typeof fetch; + export class Opencode implements Adapter { readonly name = "opencode"; @@ -388,11 +477,15 @@ export class Opencode implements Adapter { readonly env: NodeJS.ProcessEnv = process.env, ) {} - /** opencode's own sign-in, which asks for the provider and how to sign in to it. */ + /** + * opencode's own sign-in, which asks for the provider and how to sign in to + * it, in a private server rather than opencode's background service. The + * account's jobs may then use only the providers it signed in to. + */ async login(home: string): Promise { await new Promise((resolve, reject) => { - const child = spawn(this.binary, ["auth", "login"], { - env: { ...environment(this.env), ...accountDirectories(home) }, + const child = spawn(this.binary, ["auth", "login", "--standalone"], { + env: accountEnvironment(this.env, home), stdio: "inherit", }); child.once("error", reject); @@ -400,17 +493,29 @@ export class Opencode implements Adapter { code === 0 ? resolve() : reject(new Error(`opencode auth login exited ${code}`)), ); }); + const providers = [...new Set((await this.credentials(home)).map((c) => c.provider))]; + writeAccountSettings(home, { ...readAccountSettings(home), providers }); } async status(home: string): Promise { - return signedIn(credentials(home)); + return signedIn(await this.credentials(home)); + } + + /** The account's stored credentials, from opencode's own listing in a private server. */ + async credentials(home: string): Promise { + const { stdout } = await run(this.binary, ["auth", "list", "--standalone", "--format", "json"], { + env: accountEnvironment(this.env, home), + cwd: home, + encoding: "utf8", + }); + return credentials(JSON.parse(stdout)); } /** Pick the account's model in its own opencode config, which applies to every job it runs. */ static setModel(home: string, model: string): void { - const file = modelConfig(home); - mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); - writeFileSync(file, `${JSON.stringify({ model }, null, "\t")}\n`, { mode: 0o600 }); + const { providers } = readAccountSettings(home); + if (!providers) throw new Error("the account has not signed in"); + writeAccountSettings(home, { providers, model }); } launch(launch: Launch): Running { @@ -419,38 +524,47 @@ export class Opencode implements Adapter { throw new Error( `the checkout holds ${found.join(" and ")}, which opencode would load outside the sandbox; run this job on a Claude Code account`, ); + if (!readAccountSettings(launch.home).providers) + throw new Error( + "this opencode account was added before opencode 2 and does not record its providers; remove it and add it again with `quest runner account add opencode`", + ); const password = randomBytes(32).toString("base64url"); // Kept in the account's private home, which commands cannot read or write: // a session ID per kix session, and this launch's plugin, shell, sandbox - // settings, and opencode's own temporary files. + // settings, system prompt, and opencode's own temporary files. const sessions = join(launch.home, "kix-sessions"); const dir = join(launch.home, `kix-${launch.session}`); rmSync(dir, { recursive: true, force: true }); mkdirSync(join(dir, "tmp"), { recursive: true, mode: 0o700 }); - const plugin = join(dir, "plugin.js"); - writeFileSync(plugin, PLUGIN, { mode: 0o600 }); + // opencode loads a configured local plugin from a directory. + const plugin = join(dir, "plugin"); + mkdirSync(plugin); + writeFileSync(join(plugin, "package.json"), `{"type":"module"}\n`, { mode: 0o600 }); + writeFileSync(join(plugin, "server.js"), PLUGIN, { mode: 0o600 }); const settings = join(dir, "srt.json"); const denied = PROJECT_CONFIG.map((name) => join(launch.cwd, name)); writeFileSync(settings, JSON.stringify(runtimeSettings(launch.sandbox, denied)), { mode: 0o600 }); - // opencode names the shell in the bash tool's description. + const instructions = join(dir, "instructions.md"); + writeFileSync(instructions, launch.system, { mode: 0o600 }); + // opencode names the shell in the shell tool's description. const shell = join(dir, "sandboxed-bash"); writeFileSync(shell, shellScript(this.srt, settings, launch.sandbox, launch.env), { mode: 0o700 }); - const config = jobConfig({ launch, model: accountModel(launch.home), shell, plugin }); + const config = jobConfig({ launch, shell, plugin, instructions }); // The server runs outside the devShell, whose shellHook the job's revision controls, and with a - // HOME commands cannot write, since opencode loads plugins and instructions from it. - // The password and the tools' bearer token stay out of the command line, which other users can read. - const child = spawn(this.binary, ["serve", "--hostname=127.0.0.1", "--port=0"], { + // HOME commands cannot write, since opencode loads plugins and instructions from it. The password + // and the tools' bearer token stay out of the command line, which other users can read. With + // --stdio it prints its address as JSON, drops the password from its environment, and exits + // when its stdin closes, so it ends with the runner however the runner ends. + const child = spawn(this.binary, ["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"], { cwd: launch.cwd, env: { - ...environment(launch.env), - ...accountDirectories(launch.home), - HOME: launch.home, + ...accountEnvironment(launch.env, launch.home), TMPDIR: join(dir, "tmp"), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: password, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), }, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], // Its own process group, so stopping it also stops every tool it started. detached: true, }); @@ -459,8 +573,10 @@ export class Opencode implements Adapter { let listening: (url: string | null) => void = () => {}; const url = new Promise((resolve) => (listening = resolve)); const lines = boundedLines(LINE_LIMIT, (line) => { - const found = /^opencode server listening on (http:\/\/\S+)/.exec(line); - if (found) listening(found[1]!); + try { + const { url } = JSON.parse(line) as { url?: unknown }; + if (typeof url === "string") listening(url); + } catch {} }); child.stdout.setEncoding("utf8").on("data", (chunk: string) => { lines.data(chunk); @@ -482,19 +598,28 @@ export class Opencode implements Adapter { let stopping = false; let killer: ReturnType | undefined; let abort: () => Promise = async () => {}; + const events = new AbortController(); let watched: Promise = Promise.resolve(); const tail = () => stderr.trim().split("\n").slice(-20).join("\n"); const failure = (reply: string): Exit => ({ limited: null, failed: true, reply: reply || undefined }); + const terminate = () => { + child.stdin.end(); + signalGroup(child.pid, "SIGTERM"); + killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); + }; const turn = async (): Promise => { const base = await url; if (!base) return failure(tail()); - const client = createOpencodeClient({ + const client = OpenCode.make({ baseUrl: base, - directory: launch.cwd, headers: { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }, + fetch: unbounded, }); - let session: string | undefined; + const location = { directory: launch.cwd }; + const refused = await refusal(client, location, launch.home); + if (refused) return failure(refused); + let session: string; if (launch.resume) { try { session = readFileSync(join(sessions, launch.session), "utf8").trim(); @@ -502,62 +627,58 @@ export class Opencode implements Adapter { return failure(`opencode has no session for ${launch.session} in this account`); } } else { - const created = await client.session.create({}); - if (!created.data) - return failure(`opencode could not create a session: ${JSON.stringify(created.error)}`); - session = created.data.id; + session = (await client.session.create({ location })).id; mkdirSync(sessions, { recursive: true, mode: 0o700 }); writeFileSync(join(sessions, launch.session), session, { mode: 0o600 }); } - abort = () => client.session.abort({ sessionID: session }); + abort = () => client.session.interrupt({ sessionID: session }); if (stopping) return failure("stopped"); let limited: Exit["limited"] = null; - let connected: () => void = () => {}; - const ready = new Promise((resolve) => (connected = resolve)); + let executionError: SessionStructuredError | undefined; const ignore = () => {}; - // The stream ends when the server stops; aborting it instead leaves the SDK an unhandled rejection. + // Every event this server sends is this job's: its session's, its subagents', and its own. + const stream = client.event.subscribe({ signal: events.signal })[Symbol.asyncIterator](); + // The first event confirms the subscription, so nothing the prompt causes is missed. + const connected = await stream.next(); + if (connected.done) return failure(tail() || "opencode's event stream closed"); watched = (async () => { - const { stream } = await client.event.subscribe({}, { sseMaxRetryAttempts: 1 }); - for await (const event of stream as AsyncIterable) { - connected(); - const { properties } = event as { properties: { sessionID?: string } }; - // Token deltas repeat what the finished parts carry; other sessions' events are not this job's. - if (properties.sessionID !== session || event.type.endsWith(".delta")) continue; + for (let next = await stream.next(); !next.done; next = await stream.next()) { + const event: OpenCodeEvent = next.value; + if (event.type === "permission.asked") { + // Every rule allows or denies, so nothing should ask; a stray ask must not hang the turn. + client.permission + .reply({ sessionID: event.data.sessionID, requestID: event.data.id, decision: "once" }) + .catch(ignore); + } else if (event.type === "form.created") { + client.session.form + .cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id }) + .catch(ignore); + } + const data = event.data as { sessionID?: unknown }; + // Token deltas repeat what the finished parts carry; other sessions' events are not this turn's. + if (data?.sessionID !== session || event.type.endsWith(".delta")) continue; launch.output(`${JSON.stringify(event)}\n`); - if (event.type === "session.status") { - const hit = retryLimit(event.properties.status); + if (event.type === "session.retry.scheduled") { + const hit = accountLimit(event.data.error); if (hit && !limited) { limited = hit; abort().catch(ignore); } - } else if (event.type === "permission.asked") { - // Every rule allows or denies, so nothing should ask; a stray ask must not hang the turn. - client.permission.reply({ requestID: event.properties.id, reply: "once" }).catch(ignore); - } else if (event.type === "question.asked") { - client.question.reject({ requestID: event.properties.id }).catch(ignore); - } + } else if (event.type === "session.execution.failed") executionError = event.data.error; } - })() - .catch(ignore) - .finally(connected); - await ready; - const reply = await client.session.prompt({ - sessionID: session, - system: launch.system, - parts: [{ type: "text", text: launch.prompt }], - }); - if (!reply.data) - return { ...failure(`opencode refused the prompt: ${JSON.stringify(reply.error)}`), limited }; - return turnExit(reply.data.info, reply.data.parts, limited); + })().catch(ignore); + const prompt = await client.session.prompt({ sessionID: session, text: launch.prompt }); + await client.session.wait({ sessionID: session }); + return turnExit(await reply(client, session, prompt.id), executionError, limited); }; const done = turn() // The server's own last words explain a lost connection better than the client's error. .catch((error: unknown) => failure(tail() || (error instanceof Error ? error.message : String(error)))) .then(async (exit) => { - signalGroup(child.pid, "SIGTERM"); - killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); + events.abort(); + terminate(); await closed; await watched; clearTimeout(killer); @@ -569,10 +690,10 @@ export class Opencode implements Adapter { done, stop() { stopping = true; - // Abort the turn so opencode records it, then stop the server whether or not it answered. + // Interrupt the turn so opencode records it, then stop the server whether or not it answered. abort() .catch(() => {}) - .finally(() => signalGroup(child.pid, "SIGTERM")); + .finally(terminate); killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); }, }; diff --git a/test/live/runner-sandbox.test.ts b/test/live/runner-sandbox.test.ts index b18803d2..04eb951c 100644 --- a/test/live/runner-sandbox.test.ts +++ b/test/live/runner-sandbox.test.ts @@ -36,6 +36,8 @@ import { closeSandbox, openSandbox, quote, type Sandbox } from "../../src/runner /** What the scripted provider's key is, which no command may read. */ const KEY = "quest-boundary-provider-key"; +/** A pattern matching the key without holding it, since a command's own text reaches the model. */ +const KEY_PATTERN = quote(`${KEY.slice(0, -1)}[${KEY.at(-1)}]`); /** The kix tools' bearer token, which no command may see. */ const TOOLS_TOKEN = "quest-boundary-tools-token"; @@ -64,18 +66,20 @@ interface Attempt { clean?(paths: Paths): void; } -const auth = (paths: Paths) => join(accountDirectories(paths.account).XDG_DATA_HOME, "opencode", "auth.json"); +/** The database opencode keeps the account's credentials in. */ +const credentialStore = (paths: Paths) => + join(accountDirectories(paths.account).XDG_DATA_HOME, "opencode", "opencode.db"); -/** Commands avoid `cat`, `touch`, `mkdir`, and the like, which opencode's own path check refuses before srt sees them. */ +/** Commands avoid `cd` and the like, whose directory opencode's own path check refuses before srt sees it. */ const ATTEMPTS: Attempt[] = [ { name: "reads the account's credentials by absolute path", - command: (paths) => `head -c 4096 ${quote(auth(paths))}`, + command: (paths) => `grep -ao ${KEY_PATTERN} ${quote(credentialStore(paths))}`, escaped: (_paths, output) => output.includes(KEY), }, { name: "reads them through a symlink the checkout commits", - command: () => "head -c 4096 leak", + command: () => `grep -ao ${KEY_PATTERN} leak`, escaped: (_paths, output) => output.includes(KEY), }, { @@ -152,12 +156,12 @@ interface FileAttempt { const FILE_ATTEMPTS: FileAttempt[] = [ { name: "the read tool follows the committed symlink to the credentials", - call: { tool: "read", args: { filePath: "leak" } }, + call: { tool: "read", args: { path: "leak" } }, escaped: (_paths, output) => output.includes(KEY), }, { name: "the write tool writes .opencode/opencode.json", - call: { tool: "write", args: { filePath: ".opencode/opencode.json", content: `{"plugin":["./p.js"]}` } }, + call: { tool: "write", args: { path: ".opencode/opencode.json", content: `{"plugin":["./p.js"]}` } }, escaped: (paths) => existsSync(join(paths.checkout, ".opencode", "opencode.json")), }, ]; @@ -168,7 +172,7 @@ interface Call { args: Record; } -const bash = (command: string): Call => ({ tool: "bash", args: { command, description: "Run a step" } }); +const bash = (command: string): Call => ({ tool: "shell", args: { command } }); /** A command inside the sandbox that must work, so the refusals above are not every command failing. */ const ALLOWED = bash(`printf allowed > allowed.txt && printf ok > "$TMPDIR/allowed" && echo ALLOWED-$((6 * 7))`); @@ -188,6 +192,15 @@ let script: Call[] = []; /** Tools the script calls that opencode did not offer. */ const missing = new Set(); +/** + * Every tool the job offers the model, so a release adding one fails here + * until it is reviewed: the questions, WebFetch, web search, and Code Mode it + * denies are absent, and so are the kix tools, at a port nothing listens on. + */ +const OFFERED = ["edit", "glob", "grep", "read", "shell", "skill", "subagent", "todowrite", "write"]; +/** Standing instructions for the job, which must reach the model. */ +const SYSTEM = "Quest boundary instructions."; + const linux = process.platform === "linux"; describe.skipIf(!linux)("opencode's sandbox", () => { @@ -203,7 +216,7 @@ describe.skipIf(!linux)("opencode's sandbox", () => { const repository = join(runner, "repos", "fixture.git"); const seed = join(scratch, "seed"); git(scratch, "init", "-q", "-b", "main", seed); - symlinkSync(join(accountDirectories(account).XDG_DATA_HOME, "opencode", "auth.json"), join(seed, "leak")); + symlinkSync(join(accountDirectories(account).XDG_DATA_HOME, "opencode", "opencode.db"), join(seed, "leak")); writeFileSync(join(seed, "README.md"), "fixture\n"); git(seed, "add", "."); git(seed, "commit", "-qm", "base"); @@ -267,6 +280,9 @@ describe.skipIf(!linux)("opencode's sandbox", () => { const results = toolResults(); const context = `exit ${JSON.stringify(exit)}\n${output.slice(-4000)}`; expect([...missing], "opencode no longer offers a tool the script calls").toEqual([]); + const offered = requests.find((request) => request.tools?.length)?.tools?.map((t) => t.function.name); + expect(offered?.toSorted(), "opencode offers other tools than the reviewed ones").toEqual(OFFERED); + expect(JSON.stringify(requests[0]?.messages), "the job's instructions did not reach the model").toContain(SYSTEM); expect(exit.failed, context).toBe(false); expect(exit.reply).toBe("done"); expect(results, `the model saw ${results.length} of ${script.length} results\n${context}`).toHaveLength( @@ -315,7 +331,7 @@ async function run(adapter: Opencode, sandbox: Sandbox, output: (line: string) = session: "boundary", resume: false, prompt: "Run the commands you are given.", - system: "", + system: SYSTEM, tools: { url: "http://127.0.0.1:9/mcp", token: TOOLS_TOKEN }, sandbox, env: process.env, @@ -333,20 +349,21 @@ async function run(adapter: Opencode, sandbox: Sandbox, output: (line: string) = /** * Sign the account in to the scripted provider: an OpenAI-compatible API on - * localhost, with an API key no command may read, picked as the account's model. + * localhost, with an API key no command may read, picked as the account's + * model and its only provider. opencode imports the key into its own store, + * as its sign-in would. */ function signIn(account: string, baseURL: string) { const dirs = accountDirectories(account); - mkdirSync(join(dirs.XDG_DATA_HOME, "opencode"), { recursive: true }); - writeFileSync( - join(dirs.XDG_DATA_HOME, "opencode", "auth.json"), - JSON.stringify({ scripted: { type: "api", key: KEY } }), - ); mkdirSync(join(dirs.XDG_CONFIG_HOME, "opencode"), { recursive: true }); writeFileSync( join(dirs.XDG_CONFIG_HOME, "opencode", "opencode.json"), JSON.stringify({ model: "scripted/boundary", + policies: [ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "scripted", effect: "allow" }, + ], provider: { scripted: { npm: "@ai-sdk/openai-compatible", @@ -357,6 +374,26 @@ function signIn(account: string, baseURL: string) { }, }), ); + const exported = join(account, "credentials.json"); + writeFileSync( + exported, + JSON.stringify([ + { + id: "cred_boundary", + integrationID: "scripted", + label: "API key", + active: true, + value: { type: "key", key: KEY }, + }, + ]), + ); + const imported = spawnSync( + binaryPath("opencode", process.env), + ["auth", "import", "--standalone", exported], + { cwd: account, env: { PATH: process.env.PATH, HOME: account, ...dirs }, encoding: "utf8" }, + ); + rmSync(exported); + if (imported.status !== 0) throw new Error(`opencode auth import failed: ${imported.stderr}`); } interface ChatMessage { diff --git a/test/runner.test.ts b/test/runner.test.ts index 230fe236..5404c1f6 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -23,12 +23,12 @@ import { type Account, RunnerHome } from "../src/runner/home"; import { serveTools } from "../src/runner/mcp"; import { accountDirectories, + accountLimit, credentials, environment, Opencode, PLUGIN, projectConfig, - retryLimit, signedIn, turnExit, } from "../src/runner/opencode"; @@ -798,26 +798,25 @@ test("a webhook wait reads only on a wake and wakes immediately when its job sto }); describe("opencode", () => { - test("labels an account by the providers its home signed in to", () => { - const home = tempDir(); - expect(credentials(home)).toEqual([]); - const data = join(accountDirectories(home).XDG_DATA_HOME, "opencode"); - mkdirSync(data, { recursive: true }); - writeFileSync( - join(data, "auth.json"), - JSON.stringify({ openai: { type: "oauth", access: "a" }, "opencode-go": { type: "api", key: "k" } }), - ); - expect(signedIn(credentials(home))).toEqual({ + test("labels an account by the providers it stored credentials for", () => { + const list = [ + { id: "openai", connections: [{ type: "credential", id: "c1", label: "OAuth", method: "oauth" }] }, + { id: "opencode-go", connections: [{ type: "credential", id: "c2", label: "API key", method: "key" }] }, + // A key the environment holds is not the account's. + { id: "anthropic", connections: [{ type: "env", name: "ANTHROPIC_API_KEY" }] }, + ]; + expect(signedIn(credentials(list))).toEqual({ provider: "openai", kind: "subscription", label: "ChatGPT, OpenCode Go (opencode)", }); - expect(signedIn([{ provider: "wafer.ai", type: "api" }])).toEqual({ + expect(signedIn([{ provider: "wafer.ai", type: "key" }])).toEqual({ provider: "wafer-ai", kind: "api-key", label: "wafer.ai API key (opencode)", }); - expect(signedIn([])).toBeNull(); + expect(signedIn(credentials([]))).toBeNull(); + expect(() => credentials({})).toThrow(/no list/); }); test("keeps the user's provider credentials and opencode settings from the account", () => { @@ -845,46 +844,56 @@ describe("opencode", () => { writeFileSync(file, PLUGIN); const { default: plugin } = await import(pathToFileURL(file).href); const config = ["opencode.json", ".opencode"].map((name) => join(checkout, name)); - const hooks = await plugin.server({}, { cwd: checkout, writes: [checkout, tmp], reads: [output], config }); - const call = (tool: string, args: Record) => - hooks["tool.execute.before"]({ tool, sessionID: "s", callID: "c" }, { args }); + let hook: ((event: { tool: string; input: unknown }) => unknown) | undefined; + await plugin.setup({ + options: { cwd: checkout, writes: [checkout, tmp], reads: [output], config }, + tool: { + hook: async (name: string, callback: typeof hook) => { + expect(name).toBe("execute.before"); + hook = callback; + }, + }, + }); + const call = async (tool: string, input: unknown) => hook!({ tool, input }); const patch = (...headers: string[]) => ({ patchText: ["*** Begin Patch", ...headers, "+x", "*** End Patch"].join("\n"), }); - await call("read", { filePath: "a.txt" }); - await call("write", { filePath: "new/dir/b.txt" }); - await call("edit", { filePath: join(tmp, "notes.md") }); + await call("read", { path: "a.txt" }); + await call("write", { path: "new/dir/b.txt" }); + await call("edit", { path: join(tmp, "notes.md") }); await call("glob", { pattern: "**/*.ts" }); await call("grep", { pattern: "x", path: "." }); - await call("apply_patch", patch("*** Update File: a.txt", "*** Move to: c.txt")); + await call("patch", patch("*** Update File: a.txt", "*** Move to: c.txt")); // opencode's saved output is readable, not writable. - await call("read", { filePath: join(output, "tool_1") }); - await expect(call("write", { filePath: join(output, "tool_1") })).rejects.toThrow(/outside the checkout/); + await call("read", { path: join(output, "tool_1") }); + await expect(call("write", { path: join(output, "tool_1") })).rejects.toThrow(/outside the checkout/); // Commands are the sandbox's to confine. - await call("bash", { command: "cat /etc/passwd" }); - - for (const [tool, args] of [ - ["read", { filePath: "auth.json" }], - ["read", { filePath: "out/auth.json" }], - ["write", { filePath: "out/new.txt" }], - ["write", { filePath: "../outside/new.txt" }], + await call("shell", { command: "cat /etc/passwd" }); + + for (const [tool, input] of [ + ["read", { path: "auth.json" }], + ["read", { path: "out/auth.json" }], + // opencode repairs an input sent as a JSON string into its object. + ["read", JSON.stringify({ path: "auth.json" })], + ["write", { path: "out/new.txt" }], + ["write", { path: "../outside/new.txt" }], ["grep", { pattern: "x", path: outside }], ["glob", { pattern: "*", path: "out" }], - ["apply_patch", patch("*** Add File: out/x")], - ["apply_patch", patch("*** Update File: a.txt", "*** Move to: /etc/x")], + ["patch", patch("*** Add File: out/x")], + ["patch", patch("*** Update File: a.txt", "*** Move to: /etc/x")], ] as const) - await expect(call(tool, args), `${tool} ${JSON.stringify(args)}`).rejects.toThrow(/outside the checkout/); + await expect(call(tool, input), `${tool} ${JSON.stringify(input)}`).rejects.toThrow(/outside the checkout/); // A write would follow a symlink to nothing out of the checkout. - await expect(call("write", { filePath: "dangling" })).rejects.toThrow(/symlink to nothing/); + await expect(call("write", { path: "dangling" })).rejects.toThrow(/symlink to nothing/); // opencode would load its own config from the checkout, outside the sandbox. - await call("read", { filePath: "opencode.json" }); - for (const [tool, args] of [ - ["write", { filePath: "opencode.json" }], - ["edit", { filePath: ".opencode/plugin/x.js" }], - ["apply_patch", patch("*** Add File: .opencode/opencode.json")], + await call("read", { path: "opencode.json" }); + for (const [tool, input] of [ + ["write", { path: "opencode.json" }], + ["edit", { path: ".opencode/plugin/x.js" }], + ["patch", patch("*** Add File: .opencode/opencode.json")], ] as const) - await expect(call(tool, args)).rejects.toThrow(/opencode's own config/); + await expect(call(tool, input)).rejects.toThrow(/opencode's own config/); }); posix("refuses a checkout holding opencode's own config, which opencode would load unconfined", () => { @@ -905,44 +914,56 @@ describe("opencode", () => { ); }); - test("reads account limits from retries and errors, not brief rate limits", () => { - const retry = { type: "retry" as const, attempt: 1, next: 1_900_000_000_000 }; - expect( - retryLimit({ - ...retry, - message: "Go limit reached", - action: { reason: "account_rate_limit", provider: "opencode-go", title: "", message: "", label: "" }, - }), - ).toEqual({ until: 1_900_000_000_000 }); - expect(retryLimit({ ...retry, message: "The usage limit has been reached" })).toEqual({ until: null }); - expect(retryLimit({ ...retry, message: "Rate limit reached for requests" })).toBeNull(); - expect(retryLimit({ type: "busy" })).toBeNull(); + test("refuses an account that does not record the providers it signed in to", () => { + const launch = { cwd: tempDir(), home: tempDir() } as Parameters[0]; + expect(() => new Opencode("/opencode", "/srt").launch(launch)).toThrow(/add it again/); + expect(() => Opencode.setModel(launch.home, "openai/gpt-5.5")).toThrow(/not signed in/); + }); + test("reads account limits from errors, not brief rate limits", () => { const now = 1_000_000; - const api = (message: string, responseBody?: string, responseHeaders?: Record) => - ({ - role: "assistant", - error: { name: "APIError", data: { message, isRetryable: false, responseBody, responseHeaders } }, - }) as never; + const error = (type: string, message: string, body?: string) => ({ + type, + message, + ...(body !== undefined && { response: { body } }), + }); + expect(accountLimit(error("provider.quota", "Out of credits"), now)).toEqual({ until: null }); expect( - turnExit( - api("Too many", JSON.stringify({ error: { type: "usage_limit_reached", resets_at: 1_900_000 } })), - [], - null, + accountLimit( + error( + "provider.rate-limit", + "Too many", + JSON.stringify({ error: { type: "usage_limit_reached", resets_at: 1_900_000 } }), + ), now, - ).limited, + ), ).toEqual({ until: 1_900_000_000 }); - expect(turnExit(api("Quota exceeded.", "", { "retry-after": "60" }), [], null, now).limited).toEqual({ - until: now + 60_000, - }); - expect(turnExit(api("Bad request"), [], null, now)).toEqual({ + expect( + accountLimit( + error("provider.quota", "Quota", JSON.stringify({ error: { resets_in_seconds: 60 } })), + now, + ), + ).toEqual({ until: now + 60_000 }); + expect(accountLimit(error("provider.rate-limit", "Rate limit reached for requests"), now)).toBeNull(); + + const assistant = (content: unknown[], failure?: object) => + ({ type: "assistant", content, ...(failure && { error: failure }) }) as never; + expect(turnExit(assistant([], error("provider.quota", "The usage limit has been reached")), undefined, null, now)) + .toEqual({ limited: { until: null }, failed: true, reply: "The usage limit has been reached" }); + expect(turnExit(assistant([], error("provider.invalid-request", "Bad request")), undefined, null, now)).toEqual({ limited: null, failed: true, reply: "Bad request", }); expect( - turnExit({ role: "assistant" } as never, [{ type: "text", text: "All done" } as never], null, now), + turnExit(assistant([{ type: "reasoning", text: "hm" }, { type: "text", text: "All done" }]), undefined, null, now), ).toEqual({ limited: null, failed: false, reply: "All done" }); + // A run that failed before answering, such as on an unknown model. + expect(turnExit(undefined, error("unknown", "No model"), null, now)).toEqual({ + limited: null, + failed: true, + reply: "No model", + }); }); }); @@ -1092,10 +1113,13 @@ describe("account add", () => { const dir = tempDir(); const bin = join(dir, "bin"); mkdirSync(bin); - // opencode's sign-in stores the credential under its XDG data home. + // opencode's sign-in stores the credential in its own store, which `auth list` reports. + const list = JSON.stringify([ + { id: "opencode-go", connections: [{ type: "credential", id: "c", label: "API key", method: "key" }] }, + ]); writeFileSync( join(bin, "opencode"), - `#!/bin/sh\nmkdir -p "$XDG_DATA_HOME/opencode"\necho '{"opencode-go":{"type":"api","key":"k"}}' > "$XDG_DATA_HOME/opencode/auth.json"\n`, + `#!/bin/sh\necho "$*" >> "${join(dir, "calls")}"\ncase "$1 $2" in "auth list") echo '${list}' ;; esac\n`, ); chmodSync(join(bin, "opencode"), 0o755); const out: string[] = []; @@ -1128,7 +1152,18 @@ describe("account add", () => { "opencode", "opencode.json", ); - expect(JSON.parse(readFileSync(config, "utf8"))).toEqual({ model: "opencode-go/kimi-k3" }); + // Its jobs may use only the providers it signed in to. + expect(JSON.parse(readFileSync(config, "utf8"))).toEqual({ + model: "opencode-go/kimi-k3", + policies: [ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "opencode-go", effect: "allow" }, + ], + }); + // In a private server each time, not opencode's background service. + expect(readFileSync(join(dir, "calls"), "utf8")).toBe( + "auth login --standalone\nauth list --standalone --format json\nauth list --standalone --format json\n", + ); }); }); @@ -1337,23 +1372,26 @@ async function rpc(method, params) { `; /** - * A fake `opencode serve` that runs the same steps as the fake `claude` on - * each prompt: it checks the password, streams events, and ends a waiting - * turn when the session is aborted. + * A fake `opencode serve --stdio` that runs the same steps as the fake + * `claude` on each prompt: it checks the password, loads the runner's plugin, + * streams events, and ends a waiting turn when the session is interrupted. */ const FAKE_OPENCODE = ` const fs = require("node:fs"); const { join } = require("node:path"); const http = require("node:http"); +const { pathToFileURL } = require("node:url"); const argv = process.argv.slice(2); const logFile = join(__dirname, "..", "calls.log"); const log = (value) => fs.appendFileSync(logFile, JSON.stringify(value) + "\\n"); const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); const auth = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_SERVER_PASSWORD).toString("base64"); -const kix = config.mcp.kix; +const kix = config.mcp.servers.kix; const streams = []; const send = (event) => { for (const s of streams) s.write("data: " + JSON.stringify(event) + "\\n\\n"); }; -let aborted = () => {}; +const messages = {}; +const running = {}; +let interrupted = () => {}; let sessions = 0; let id = 0; async function rpc(method, params) { @@ -1364,17 +1402,25 @@ async function rpc(method, params) { }); return (await response.json()).result; } +// opencode loads a local plugin from its directory and fails it rather than the server. +const plugin = (async () => { + const { package: dir, options } = config.plugins[0]; + const { default: definition } = await import(pathToFileURL(join(dir, "server.js")).href); + let hooked = false; + await definition.setup({ options, tool: { hook: async () => (hooked = true) } }); + return { id: definition.id, source: { type: "local", path: dir }, features: {}, state: hooked ? { status: "active" } : { status: "failed", error: "no hook" } }; +})(); const body = (request) => new Promise((resolve) => { let text = ""; request.on("data", (chunk) => (text += chunk)); request.on("end", () => resolve(text ? JSON.parse(text) : {})); }); const json = (response, value) => response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(value)); -async function prompt(session, input) { +async function turn(session, text) { const steps = JSON.parse(fs.readFileSync(join(__dirname, "..", "steps.json"), "utf8")); const resume = fs.existsSync(logFile) && fs.readFileSync(logFile, "utf8").includes('"prompted":"' + session + '"'); log({ - argv, cwd: process.cwd(), stdin: input.parts[0].text, system: input.system, session, prompted: session, + argv, cwd: process.cwd(), stdin: text, system: fs.readFileSync(config.instructions[0], "utf8"), session, prompted: session, xdg: process.env.XDG_DATA_HOME, home: process.env.HOME, devShell: process.env.IN_DEVSHELL ?? null, gh: process.env.GH_TOKEN ?? null, projectConfig: process.env.OPENCODE_DISABLE_PROJECT_CONFIG ?? null, config, }); @@ -1385,35 +1431,55 @@ async function prompt(session, input) { // opencode runs a command as its shell's -c, with the server's own environment. if (step.shell) log({ shell: require("node:child_process").execFileSync(config.shell, ["-c", step.shell], { encoding: "utf8" }) }); - if (step.emit) send({ ...step.emit, properties: { ...step.emit.properties, sessionID: session } }); + if (step.emit) send({ ...step.emit, data: { ...step.emit.data, sessionID: session } }); if (step.tool) log({ tool: step.tool, reply: await rpc("tools/call", { name: step.tool, arguments: step.args }) }); if (step.wait) { - await new Promise((resolve) => (aborted = resolve)); + await new Promise((resolve) => (interrupted = resolve)); log({ aborted: session }); - return { info: { role: "assistant", error: { name: "MessageAbortedError", data: { message: "Aborted" } } }, parts: [] }; + return { type: "assistant", content: [], error: { type: "aborted", message: "Interrupted" } }; } } - return { info: { role: "assistant", finish: "stop" }, parts: [{ type: "text", text: "done" }] }; + return { type: "assistant", content: [{ type: "text", text: "done" }], finish: "stop" }; } const server = http.createServer(async (request, response) => { if (request.headers.authorization !== auth) return response.writeHead(401).end(); const path = new URL(request.url, "http://x").pathname; - if (path === "/event") { + if (path === "/api/event") { response.writeHead(200, { "Content-Type": "text/event-stream" }); streams.push(response); - return response.write("data: " + JSON.stringify({ type: "server.connected", properties: {} }) + "\\n\\n"); + return response.write("data: " + JSON.stringify({ type: "server.connected", data: {} }) + "\\n\\n"); + } + if (path === "/api/model/default") return json(response, { data: { id: "gpt-5.5", providerID: "openai" } }); + // The runner's config, and the account's config directory. + if (path === "/api/config") + return json(response, [ + { type: "directory", path: join(process.env.XDG_CONFIG_HOME, "opencode") }, + { type: "document", info: config }, + ]); + if (path === "/api/plugin") return json(response, { location: {}, data: [await plugin] }); + if (path === "/api/session") return json(response, { data: { id: "ses_" + ++sessions + "_" + process.pid } }); + const [, session, action] = /^\\/api(?:\\/experimental)?\\/session\\/([^/]+)\\/(\\w+)$/.exec(path) ?? []; + if (action === "prompt") { + const { text } = await body(request); + const user = { type: "user", id: "msg_" + ++id }; + messages[session] = [user, ...(messages[session] ?? [])]; + running[session] = turn(session, text).then((reply) => messages[session].unshift({ id: "msg_" + ++id, ...reply })); + return json(response, { data: { id: user.id } }); } - if (path === "/session") return json(response, { id: "ses_" + ++sessions + "_" + process.pid }); - const message = /^\\/session\\/([^/]+)\\/message$/.exec(path); - if (message) return json(response, await prompt(message[1], await body(request))); - if (/\\/abort$/.test(path)) { - aborted(); - return json(response, true); + if (action === "wait") { + await running[session]; + return response.writeHead(204).end(); + } + if (action === "message") return json(response, { data: messages[session], cursor: {} }); + if (action === "interrupt") { + interrupted(); + return json(response, { interrupted: true }); } response.writeHead(404).end(); }); process.on("SIGTERM", () => process.exit(143)); -server.listen(0, "127.0.0.1", () => console.log("opencode server listening on http://127.0.0.1:" + server.address().port)); +process.stdin.on("end", () => process.exit(0)).resume(); +server.listen(0, "127.0.0.1", () => console.log(JSON.stringify({ url: "http://127.0.0.1:" + server.address().port }))); `; /** A fake sandbox-runtime that records its settings and environment, then runs the command unconfined. */ @@ -1503,10 +1569,19 @@ function rig( }; home.saveAccounts([account]); if (cli === "opencode") { - const data = join(accountDirectories(home.accountHome("acct1")).XDG_DATA_HOME, "opencode"); - mkdirSync(data, { recursive: true }); - writeFileSync(join(data, "auth.json"), JSON.stringify({ openai: { type: "oauth", access: "secret" } })); - Opencode.setModel(home.accountHome("acct1"), "openai/gpt-5.5"); + // What `quest runner account add opencode --model openai/gpt-5.5` records after a ChatGPT sign-in. + const config = join(accountDirectories(home.accountHome("acct1")).XDG_CONFIG_HOME, "opencode"); + mkdirSync(config, { recursive: true }); + writeFileSync( + join(config, "opencode.json"), + JSON.stringify({ + model: "openai/gpt-5.5", + policies: [ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "openai", effect: "allow" }, + ], + }), + ); } const hub = new FakeHub(); const publishes: Sent[] = []; @@ -1894,6 +1969,7 @@ describe("quest runner", () => { const r = rig(undefined, "opencode"); r.steps({ start: [ + { emit: { type: "marker" } }, { write: "a.txt", content: "draft\n" }, { shell: 'printf "%s|%s|%s|%s|%s" "$IN_DEVSHELL" "$HOME" "${OPENCODE_SERVER_PASSWORD:-none}" "${QUEST_COMMAND:-none}" "$TMPDIR"', @@ -1914,7 +1990,7 @@ describe("quest runner", () => { expect(nix.nix.slice(0, 3)).toEqual(["print-dev-env", "--accept-flake-config", "--no-write-lock-file"]); const home = r.home.accountHome("acct1"); // The server runs outside the devShell, in the account's own homes, and loads nothing from the checkout. - expect(first.argv).toEqual(["serve", "--hostname=127.0.0.1", "--port=0"]); + expect(first.argv).toEqual(["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"]); expect(first.devShell).toBeNull(); expect(first.xdg).toBe(join(home, "data")); expect(first.home).toBe(home); @@ -1947,21 +2023,26 @@ describe("quest runner", () => { expect(first.stdin).toContain("kix adds for this job: Extra rule."); expect(first.system).toContain("checkpoint"); expect(first.config).toMatchObject({ - model: "openai/gpt-5.5", - enabled_providers: ["openai"], shell: join(home, `kix-${question.session}`, "sandboxed-bash"), - // The file tools stay in the checkout and the attempt's directory, and WebFetch is off. - permission: { - "*": "allow", - question: "deny", - webfetch: "deny", - external_directory: { "*": "deny", [`${tmp}/*`]: "allow" }, - }, + // The file tools stay in the checkout and the attempt's directory, and nothing that runs in the server is offered. + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + ...["question", "webfetch", "websearch", "execute", "browser", "external_directory"].map((action) => ({ + action, + resource: "*", + effect: "deny", + })), + { action: "external_directory", resource: `${tmp}/*`, effect: "allow" }, + ], lsp: false, formatter: false, - mcp: { kix: { type: "remote", headers: { Authorization: expect.stringMatching(/^Bearer /) } } }, + mcp: { + servers: { + kix: { type: "remote", codemode: false, headers: { Authorization: expect.stringMatching(/^Bearer /) } }, + }, + }, }); - expect(first.config.plugin[0][1]).toEqual({ + expect(first.config.plugins[0].options).toEqual({ cwd: checkout, writes: [checkout, tmp], reads: [join(home, "data", "opencode", "tool-output")], @@ -1969,13 +2050,13 @@ describe("quest runner", () => { }); // The launch's shell, plugin, and settings are gone once it ends. expect(existsSync(dirname(first.config.shell))).toBe(false); - // Events stream as raw output. - expect( - r.hub - .of("output") - .map((o) => o.text) - .join(""), - ).toContain("opencode server listening"); + // The server's own output and its events stream as raw output. + const output = r.hub + .of("output") + .map((o) => o.text) + .join(""); + expect(output).toContain('{"url":"http://127.0.0.1:'); + expect(output).toContain('"type":"marker"'); r.assign( job(r.upstream.commit, { @@ -2016,20 +2097,19 @@ describe("quest runner", () => { expect(cancelled).toMatchObject({ outcome: "cancelled" }); expect(r.calls().some((c) => c.aborted)).toBe(true); - const resets = Date.now() + 3_600_000; - const status = { - type: "retry", - attempt: 1, - message: "Go limit", - next: resets, - action: { reason: "account_rate_limit" }, + // A limit only the message names, which opencode would retry until the reset. + const resets = Math.floor(Date.now() / 1000) + 3600; + const error = { + type: "provider.rate-limit", + message: "You've hit your usage limit", + response: { body: JSON.stringify({ error: { type: "usage_limit_reached", resets_at: resets } }) }, }; - r.steps({ start: [{ emit: { type: "session.status", properties: { status } } }, { wait: true }] }); + r.steps({ start: [{ emit: { type: "session.retry.scheduled", data: { attempt: 1, at: 0, error } } }, { wait: true }] }); r.assign(job(r.upstream.commit, { id: "job2" })); const limited = await r.hub.next((m) => m.type === "result" && m.job === "job2"); await idle(r.runner); expect(limited).toMatchObject({ outcome: "limited" }); - expect(r.home.accounts()[0]!.limitedUntil).toBe(resets); + expect(r.home.accounts()[0]!.limitedUntil).toBe(resets * 1000); await r.runner.shutdown(); }); From bf4eecaa000fc823494550a777c4e31c9fc4c842 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 00:49:43 -0800 Subject: [PATCH 03/10] wip: verify the opencode 2.x boundary, docs, and quest removal --- .github/dependabot.yml | 7 ++-- README.md | 11 +++--- flake.nix | 2 +- quest/m0/README.md | 1 - quest/m0/kix/sandbox/README.md | 8 +++-- quest/m0/opencode-sdk.md | 62 -------------------------------- src/runner/opencode.ts | 16 +++++++-- test/live/runner-sandbox.test.ts | 19 +++++----- test/runner.test.ts | 42 ++++++++++++++-------- 9 files changed, 66 insertions(+), 102 deletions(-) delete mode 100644 quest/m0/opencode-sdk.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6d84898c..04768ac6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,8 +13,8 @@ updates: # Bump by hand once @cloudflare/vitest-plugin's peer range includes the new major. - dependency-name: vitest update-types: ["version-update:semver-major"] - # The flake's opencode-sdk check pins the SDK to nixpkgs' opencode; bump both together. - - dependency-name: "@opencode-ai/sdk" + # The flake's opencode-client check pins the client to nixpkgs' opencode; bump both together. + - dependency-name: "@opencode/client" - package-ecosystem: github-actions directory: / schedule: @@ -38,6 +38,3 @@ updates: patterns: ["quest-pinned"] nix: patterns: ["*"] - ignore: - # Pinned to the last nixpkgs with opencode 1.x until /quest/m0/opencode-sdk.md removes it. - - dependency-name: nixpkgs-opencode diff --git a/README.md b/README.md index b5a59f7c..703497b0 100644 --- a/README.md +++ b/README.md @@ -1205,10 +1205,13 @@ install dependencies; an untrusted one's reach no network host. npm's registry also accepts publishes, so a prompt-injected agent holding someone's npm token could publish the checkout there. The agent's file tools stay in the checkout (opencode's may also use the private temporary directory, and its read tool -the output it saved), WebFetch is off, and nothing the checkout configures is -loaded: not its `.claude` settings, hooks, and MCP servers, nor its -`opencode.json` and `.opencode` plugins, tools, and MCP servers, and opencode's -language servers and formatters are off. The CLI itself, Claude Code or +the output it saved), WebFetch and opencode's web search are off, and nothing +the checkout configures is loaded: not its `.claude` settings, hooks, and MCP +servers, nor its `opencode.json` and `.opencode` plugins, tools, and MCP +servers, and opencode's language servers and formatters are off. An opencode +job refuses to run if opencode loaded any config besides the runner's and the +account's, such as a machine's managed `/etc/opencode`, which would outrank +the runner's. The CLI itself, Claude Code or `opencode serve`, runs outside the sandbox and outside the devShell, and its commands start from a minimal environment, so its login, the opencode server's password, and the kix tools' token stay out of their reach. This is an OS diff --git a/flake.nix b/flake.nix index 9c2601ba..89cac1ff 100644 --- a/flake.nix +++ b/flake.nix @@ -118,7 +118,7 @@ fi touch "$out" ''; - opencode-sdk = pkgs.runCommand "opencode-sdk" { } '' + opencode-client = pkgs.runCommand "opencode-client" { } '' if [ "${opencode.version}" != "${opencodeClient}" ]; then echo "package.json pins @opencode/client ${opencodeClient}, but the runner pins opencode ${opencode.version}" >&2 exit 1 diff --git a/quest/m0/README.md b/quest/m0/README.md index 72a9dd70..fea5b7c3 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -108,4 +108,3 @@ ownership-manifest design: - [Remote control worktrees](/quest/m0/worktree-routes.md) - live-check `claude remote-control --spawn worktree` against the hooks, from a session spawned on claude.ai - [Session checkouts released](/quest/m0/session-checkouts.md) - a Claude session's own checkout is released when the session ends and reclaimed on resume - [claude rm upstream report](/quest/m0/claude-rm-upstream.md) - waiting on approval: ask Claude Code to run `WorktreeRemove` instead of advising an unlock -- [opencode 2.x](/quest/m0/opencode-sdk.md) - the runner drives the opencode 2.x binary through `@opencode/client`, removing the interim 1.19.0 pin diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index 53f19d0e..0ac1c7e6 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -64,9 +64,11 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) - opencode jobs' commands run under sandbox-runtime, set as opencode's shell, with the same policy ([moq-dev/quest#338](https://github.com/moq-dev/quest/pull/338)). - opencode 1.19 still imports the plugins a checkout's own `opencode.json` - or `.opencode/` names, even with project config disabled, so the runner - keeps the agent from creating them and refuses a checkout that holds them. + The runner keeps the agent from creating a checkout's own `opencode.json` + or `.opencode/`, refuses a checkout that holds them, and before each turn + refuses to run if opencode loaded any config but the runner's and the + account's, since opencode 1.19 imported a checkout's plugins even with + project config disabled. `test/live/runner-sandbox.test.ts` checks that boundary with the pinned opencode and srt and a scripted model. It runs in `just runner-sandbox` and a nightly GitHub-hosted workflow (`.github/workflows/sandbox.yml`), diff --git a/quest/m0/opencode-sdk.md b/quest/m0/opencode-sdk.md deleted file mode 100644 index 9411b64d..00000000 --- a/quest/m0/opencode-sdk.md +++ /dev/null @@ -1,62 +0,0 @@ -# [L] The runner drives opencode 2.x through @opencode/client - -## Goal - -The runner's opencode adapter (`src/runner/opencode.ts`) runs the opencode -2.x binary that nixpkgs ships as a server and drives it through -`@opencode/client` at the same version. The temporary second nixpkgs input -that keeps opencode at 1.19.0 is gone. The flake's `opencode-sdk` check -compares `@opencode/client` with `pkgs.opencode.version`. The runner's -opencode tests pass, and the opencode sandbox's boundary still holds on 2.x. - -## Plan - -Decided 2026-11-02 (maintainer, /quest-plan from the 2026-11-02 -`/quest-complete` session), replacing the 2026-11-01 plan to move to the -renamed `@opencode/sdk`. Checked against nixpkgs 674a202, which ships -opencode 2.1.1: - -- `@opencode-ai/sdk` stops at 1.19.5, so there is no 2.x of the package the - runner pins. -- `@opencode/sdk` 2.1.1 runs opencode inside the calling process. -- The client for a running server is `@opencode/client`, with a new API. -- The CLI has no `serve`: it runs a background `service` or a - `--standalone` server, so the adapter's `serve --port=0` launch and its - "listening on" handshake both break. - -Decision prompts, selected options marked ✅: - -- How the runner drives opencode 2: - - ✅ The 2.1.1 binary as a server, driven by `@opencode/client` 2.1.1. It - keeps the split the opencode sandbox - ([moq-dev/quest#338](https://github.com/moq-dev/quest/pull/338)) relies on: the server process holds the provider credentials, the server - password, and the kix tools' token, outside the commands' boundary. - - `@opencode/sdk` in the runner's process, dropping the binary. The - runner would hold the credentials, and the sandbox's process boundary - would need redesigning. -- Whether the nixpkgs bump waits for this: - - ✅ No. [moq-dev/quest#281](https://github.com/moq-dev/quest/pull/281) - lands now with opencode 1.19.0 from a second nixpkgs input pinned at - f20cd90, since the same nixpkgs also brings the runners' other agent - CLIs, such as claude-code. This quest removes that input. - - Keep #281 blocked until the migration. - -What the change does: - -- Launch the server the way 2.x supports, with `--standalone` unless - `service` fits the runner's per-job lifetime better. Replace the - "listening on" handshake with whatever 2.x reports for readiness. -- Move the adapter to `@opencode/client`. Point the flake's check and - Dependabot's ignore for the SDK (it must move with nixpkgs' opencode) at - `@opencode/client`. -- Recheck each of the sandbox's rules on 2.x: the `shell` wrapper through - srt, the commands' environment allowlist (`keptEnvironment`), - `OPENCODE_DISABLE_PROJECT_CONFIG`, `external_directory` and `webfetch` - denies, the plugin's realpath check, and `lsp`/`formatter` off. The - sandbox's boundary test, `test/live/runner-sandbox.test.ts`, runs the - pinned opencode only in `just runner-sandbox` and nightly CI, so run it - on the 2.x pin before landing; extend it to any rule 2.x changes, such as - its tool names or plugin hooks. -- Run the runner's live opencode checks that the - [Kix decision UI](/quest/m0/kix/README.md) rehearsal lists, if a runner is - available. diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 5b3e8ece..64588a23 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -111,7 +111,9 @@ function readAccountSettings(home: string): Partial { const { model, policies } = (parsed ?? {}) as { model?: unknown; policies?: unknown }; const allowed = Array.isArray(policies) ? policies.flatMap((p: { action?: unknown; resource?: unknown; effect?: unknown }) => - p.action === "provider.use" && p.effect === "allow" && typeof p.resource === "string" ? [p.resource] : [], + p.action === "provider.use" && p.effect === "allow" && typeof p.resource === "string" + ? [p.resource] + : [], ) : undefined; return { ...(allowed && { providers: allowed }), ...(typeof model === "string" && { model }) }; @@ -435,7 +437,9 @@ async function refusal(client: OpenCodeClient, location: LocationRef, home: stri await client.model.default({ location }); const own = join(accountDirectories(home).XDG_CONFIG_HOME, "opencode"); const entries = await client.config.get({ location }); - const foreign = entries.flatMap((entry) => (entry.path && relative(own, entry.path).startsWith("..") ? [entry.path] : [])); + const foreign = entries.flatMap((entry) => + entry.path && relative(own, entry.path).startsWith("..") ? [entry.path] : [], + ); // Only the runner's own config arrives without a path. const unnamed = entries.filter((entry) => !entry.path).length; if (foreign.length || unnamed !== 1) @@ -447,7 +451,11 @@ async function refusal(client: OpenCodeClient, location: LocationRef, home: stri } /** The last assistant message after `user`, the turn's prompt, newest first through the session's messages. */ -async function reply(client: OpenCodeClient, session: string, user: string): Promise { +async function reply( + client: OpenCodeClient, + session: string, + user: string, +): Promise { let cursor: string | undefined; let last: SessionMessageAssistant | undefined; do { @@ -486,6 +494,8 @@ export class Opencode implements Adapter { await new Promise((resolve, reject) => { const child = spawn(this.binary, ["auth", "login", "--standalone"], { env: accountEnvironment(this.env, home), + // Not the caller's directory, whose own opencode config the server would load. + cwd: home, stdio: "inherit", }); child.once("error", reject); diff --git a/test/live/runner-sandbox.test.ts b/test/live/runner-sandbox.test.ts index 04eb951c..218deaa8 100644 --- a/test/live/runner-sandbox.test.ts +++ b/test/live/runner-sandbox.test.ts @@ -197,7 +197,7 @@ const missing = new Set(); * until it is reviewed: the questions, WebFetch, web search, and Code Mode it * denies are absent, and so are the kix tools, at a port nothing listens on. */ -const OFFERED = ["edit", "glob", "grep", "read", "shell", "skill", "subagent", "todowrite", "write"]; +const OFFERED = ["edit", "glob", "grep", "read", "shell", "skill", "subagent", "write"]; /** Standing instructions for the job, which must reach the model. */ const SYSTEM = "Quest boundary instructions."; @@ -280,9 +280,10 @@ describe.skipIf(!linux)("opencode's sandbox", () => { const results = toolResults(); const context = `exit ${JSON.stringify(exit)}\n${output.slice(-4000)}`; expect([...missing], "opencode no longer offers a tool the script calls").toEqual([]); - const offered = requests.find((request) => request.tools?.length)?.tools?.map((t) => t.function.name); - expect(offered?.toSorted(), "opencode offers other tools than the reviewed ones").toEqual(OFFERED); - expect(JSON.stringify(requests[0]?.messages), "the job's instructions did not reach the model").toContain(SYSTEM); + const first = requests.find((request) => request.tools?.length); + const offered = first?.tools?.map((t) => t.function.name); + expect(offered && [...offered].sort(), "opencode offers other tools than the reviewed ones").toEqual(OFFERED); + expect(JSON.stringify(first?.messages), "the job's instructions did not reach the model").toContain(SYSTEM); expect(exit.failed, context).toBe(false); expect(exit.reply).toBe("done"); expect(results, `the model saw ${results.length} of ${script.length} results\n${context}`).toHaveLength( @@ -387,11 +388,11 @@ function signIn(account: string, baseURL: string) { }, ]), ); - const imported = spawnSync( - binaryPath("opencode", process.env), - ["auth", "import", "--standalone", exported], - { cwd: account, env: { PATH: process.env.PATH, HOME: account, ...dirs }, encoding: "utf8" }, - ); + const imported = spawnSync(binaryPath("opencode", process.env), ["auth", "import", "--standalone", exported], { + cwd: account, + env: { PATH: process.env.PATH, HOME: account, ...dirs }, + encoding: "utf8", + }); rmSync(exported); if (imported.status !== 0) throw new Error(`opencode auth import failed: ${imported.stderr}`); } diff --git a/test/runner.test.ts b/test/runner.test.ts index 5404c1f6..eec6b9db 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -939,24 +939,32 @@ describe("opencode", () => { ), ).toEqual({ until: 1_900_000_000 }); expect( - accountLimit( - error("provider.quota", "Quota", JSON.stringify({ error: { resets_in_seconds: 60 } })), - now, - ), + accountLimit(error("provider.quota", "Quota", JSON.stringify({ error: { resets_in_seconds: 60 } })), now), ).toEqual({ until: now + 60_000 }); expect(accountLimit(error("provider.rate-limit", "Rate limit reached for requests"), now)).toBeNull(); const assistant = (content: unknown[], failure?: object) => ({ type: "assistant", content, ...(failure && { error: failure }) }) as never; - expect(turnExit(assistant([], error("provider.quota", "The usage limit has been reached")), undefined, null, now)) - .toEqual({ limited: { until: null }, failed: true, reply: "The usage limit has been reached" }); - expect(turnExit(assistant([], error("provider.invalid-request", "Bad request")), undefined, null, now)).toEqual({ - limited: null, - failed: true, - reply: "Bad request", - }); expect( - turnExit(assistant([{ type: "reasoning", text: "hm" }, { type: "text", text: "All done" }]), undefined, null, now), + turnExit(assistant([], error("provider.quota", "The usage limit has been reached")), undefined, null, now), + ).toEqual({ limited: { until: null }, failed: true, reply: "The usage limit has been reached" }); + expect(turnExit(assistant([], error("provider.invalid-request", "Bad request")), undefined, null, now)).toEqual( + { + limited: null, + failed: true, + reply: "Bad request", + }, + ); + expect( + turnExit( + assistant([ + { type: "reasoning", text: "hm" }, + { type: "text", text: "All done" }, + ]), + undefined, + null, + now, + ), ).toEqual({ limited: null, failed: false, reply: "All done" }); // A run that failed before answering, such as on an unknown model. expect(turnExit(undefined, error("unknown", "No model"), null, now)).toEqual({ @@ -2038,7 +2046,11 @@ describe("quest runner", () => { formatter: false, mcp: { servers: { - kix: { type: "remote", codemode: false, headers: { Authorization: expect.stringMatching(/^Bearer /) } }, + kix: { + type: "remote", + codemode: false, + headers: { Authorization: expect.stringMatching(/^Bearer /) }, + }, }, }, }); @@ -2104,7 +2116,9 @@ describe("quest runner", () => { message: "You've hit your usage limit", response: { body: JSON.stringify({ error: { type: "usage_limit_reached", resets_at: resets } }) }, }; - r.steps({ start: [{ emit: { type: "session.retry.scheduled", data: { attempt: 1, at: 0, error } } }, { wait: true }] }); + r.steps({ + start: [{ emit: { type: "session.retry.scheduled", data: { attempt: 1, at: 0, error } } }, { wait: true }], + }); r.assign(job(r.upstream.commit, { id: "job2" })); const limited = await r.hub.next((m) => m.type === "result" && m.job === "job2"); await idle(r.runner); From 95ad1f025779b18ae0f8e01ffedffeed8459ad63 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 00:53:16 -0800 Subject: [PATCH 04/10] test(runner): refuse an opencode turn when opencode loaded foreign config --- test/runner.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/runner.test.ts b/test/runner.test.ts index eec6b9db..5bc97f95 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -1459,8 +1459,10 @@ const server = http.createServer(async (request, response) => { } if (path === "/api/model/default") return json(response, { data: { id: "gpt-5.5", providerID: "openai" } }); // The runner's config, and the account's config directory. + // The runner's config, the account's config directory, and any the steps add, such as a machine's. if (path === "/api/config") return json(response, [ + ...(JSON.parse(fs.readFileSync(join(__dirname, "..", "steps.json"), "utf8")).config ?? []), { type: "directory", path: join(process.env.XDG_CONFIG_HOME, "opencode") }, { type: "document", info: config }, ]); @@ -2127,6 +2129,22 @@ describe("quest runner", () => { await r.runner.shutdown(); }); + posix("refuses an opencode turn when opencode loaded config the runner did not write", async () => { + const r = rig(undefined, "opencode"); + r.steps({ + config: [{ type: "document", path: "/etc/opencode/opencode.json", info: { shell: "/bin/sh" } }], + start: [{ write: "a.txt", content: "x\n" }], + }); + r.assign(job(r.upstream.commit)); + const refused = await r.result(); + await idle(r.runner); + expect(refused).toMatchObject({ outcome: "failed" }); + expect(refused.summary).toContain("/etc/opencode/opencode.json"); + // Nothing was prompted. + expect(r.calls().filter((c) => c.prompted)).toEqual([]); + await r.runner.shutdown(); + }); + posix("cancels on request, publishing the partial work", async () => { const r = rig(); r.steps({ From 6653c7f9112900b9cc6eddf4daa5475fb446fbd7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 01:11:30 -0800 Subject: [PATCH 05/10] fix(runner): allow opencode's saved output and resolve the providers a sign-in enables --- src/runner/opencode.ts | 245 +++++++++++++++++++++++++++-------------- test/runner.test.ts | 71 +++++++++--- 2 files changed, 216 insertions(+), 100 deletions(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 64588a23..f7f863d7 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -18,11 +18,10 @@ // servers, and no language servers or formatters. Before each turn the runner // checks that opencode loaded no config but its own and the account's, and that // its plugin is active, since opencode would only log a plugin it could not load. -import { execFile, spawn } from "node:child_process"; +import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; -import { promisify } from "node:util"; import { type LocationRef, @@ -46,8 +45,6 @@ import { } from "./adapter"; import { keptEnvironment, quote, runtimeSettings, type Sandbox } from "./sandbox"; -const run = promisify(execFile); - /** The XDG base directories opencode keeps its config, credentials, sessions, and caches in, with their defaults. */ const XDG = { XDG_CONFIG_HOME: ".config", @@ -271,9 +268,13 @@ exec /usr/bin/env -i ${kept.join(" ")} "QUEST_COMMAND=$2" ${quote(srt)} --settin `; } -/** The directory opencode saves truncated tool output in, which its read tool may read. */ -function toolOutput(home: string): string { - return join(accountDirectories(home).XDG_DATA_HOME, "opencode", "tool-output"); +/** + * Where opencode saves the full output it truncates: a tool's, and a shell + * command's, under a directory per project. The read-only file tools may read them. + */ +function savedOutput(home: string): string[] { + const data = join(accountDirectories(home).XDG_DATA_HOME, "opencode"); + return [join(data, "tool-output"), join(data, "shell")]; } /** @@ -281,8 +282,9 @@ function toolOutput(home: string): string { * permission mode allows its own, except: questions, which go through kix; * WebFetch, web search, and the desktop browser, which would run in the * unconfined server; Code Mode's `execute`, which carries opencode's own - * tools, such as moving the session to another directory; and the file tools' paths outside the checkout - * and the attempt's directory. A tool whose last matching rule denies it + * tools, such as moving the session to another directory; and the file tools' + * paths outside the checkout, the attempt's directory, and opencode's saved + * output, which the plugin keeps read-only. A tool whose last matching rule denies it * everywhere is not offered at all. The kix tools stay direct tools rather than * Code Mode's. `instructions` is the file holding the launch's system prompt. */ @@ -306,7 +308,11 @@ export function jobConfig(options: { deny("execute"), deny("browser"), deny("external_directory"), - { action: "external_directory", resource: join(launch.sandbox.tmp, "*"), effect: "allow" }, + ...[launch.sandbox.tmp, ...savedOutput(launch.home)].map((dir) => ({ + action: "external_directory", + resource: join(dir, "*"), + effect: "allow", + })), ], // Both would run the repository's own tools in the server. lsp: false, @@ -329,7 +335,7 @@ export function jobConfig(options: { options: { cwd: launch.cwd, writes: [launch.cwd, launch.sandbox.tmp], - reads: [toolOutput(launch.home)], + reads: savedOutput(launch.home), config: PROJECT_CONFIG.map((name) => join(launch.cwd, name)), }, }, @@ -338,15 +344,15 @@ export function jobConfig(options: { } export interface Credential { - /** opencode's provider ID, such as openai or opencode-go. */ + /** opencode's integration ID, such as openai or opencode-go. */ provider: string; /** How it signed in: oauth for a plan login, key for an API key, external for a credential managed elsewhere. */ type: string; } -/** The stored credentials in `opencode auth list --format json` output, without the credentials themselves. */ +/** The credentials stored among opencode's integrations, without the credentials themselves. */ export function credentials(list: unknown): Credential[] { - if (!Array.isArray(list)) throw new Error("opencode auth list printed no list"); + if (!Array.isArray(list)) throw new Error("opencode listed no integrations"); return list.flatMap((integration: { id?: unknown; connections?: unknown }) => typeof integration?.id === "string" && Array.isArray(integration.connections) ? integration.connections.flatMap((connection: { type?: unknown; method?: unknown }) => @@ -358,6 +364,19 @@ export function credentials(list: unknown): Credential[] { ); } +/** + * The providers an account's stored credentials enable, which are all its jobs + * may use. An integration may enable providers of other IDs, as OpenCode + * Console does, and providers that need no sign-in belong to no stored credential. + */ +export function signedInProviders( + integrations: unknown, + providers: { id: string; integrationID?: string }[], +): string[] { + const stored = new Set(credentials(integrations).map((c) => c.provider)); + return providers.flatMap((p) => (stored.has(p.integrationID ?? p.id) ? [p.id] : [])); +} + /** What kix shows for a credential, and whether it is a plan rather than a metered key. */ function describe({ provider, type }: Credential): { name: string; plan: boolean } { if (provider === "openai" && type === "oauth") return { name: "ChatGPT", plan: true }; @@ -475,6 +494,84 @@ async function reply( const unbounded = ((request: Parameters[0], init?: RequestInit) => fetch(request, { ...init, timeout: false } as RequestInit)) as typeof fetch; +/** A client for the server at `url` behind `password`. */ +function connect(url: string, password: string): OpenCodeClient { + return OpenCode.make({ + baseUrl: url, + headers: { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }, + fetch: unbounded, + }); +} + +/** A running `opencode serve --stdio`. */ +interface Server { + child: ReturnType; + /** Its address, or null if it exited first. */ + url: Promise; + closed: Promise; + /** The end of its stderr, which explains a failure. */ + tail(): string; + /** Stop it, its process group, and every tool it started, killing them after a grace period. */ + stop(): void; +} + +/** + * Start opencode's server on localhost in `cwd`, passing each chunk of its + * output to `output`. With --stdio it prints its address as JSON, drops the + * password from its environment, and exits when its stdin closes, so it ends + * with the runner however the runner ends. `env` carries the password, which + * stays out of the command line other users can read. + */ +function serve(binary: string, cwd: string, env: NodeJS.ProcessEnv, output: (chunk: string) => void): Server { + const child = spawn(binary, ["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"], { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], + // Its own process group, so stopping it also stops every tool it started. + detached: true, + }); + let stderr = ""; + let listening: (url: string | null) => void = () => {}; + const url = new Promise((resolve) => (listening = resolve)); + const lines = boundedLines(LINE_LIMIT, (line) => { + try { + const { url } = JSON.parse(line) as { url?: unknown }; + if (typeof url === "string") listening(url); + } catch {} + }); + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { + lines.data(chunk); + output(chunk); + }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + stderr = (stderr + chunk).slice(-64 * 1024); + output(chunk); + }); + let killer: ReturnType | undefined; + const closed = new Promise((resolve) => { + child.once("error", (error) => { + stderr += `could not start ${binary}: ${error.message}\n`; + resolve(); + }); + child.once("close", () => resolve()); + }).then(() => { + clearTimeout(killer); + lines.end(); + listening(null); + }); + return { + child, + url, + closed, + tail: () => stderr.trim().split("\n").slice(-20).join("\n"), + stop() { + child.stdin.end(); + signalGroup(child.pid, "SIGTERM"); + killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); + }, + }; +} + export class Opencode implements Adapter { readonly name = "opencode"; @@ -503,22 +600,43 @@ export class Opencode implements Adapter { code === 0 ? resolve() : reject(new Error(`opencode auth login exited ${code}`)), ); }); - const providers = [...new Set((await this.credentials(home)).map((c) => c.provider))]; + const providers = await this.ask(home, async (client, location) => + signedInProviders( + (await client.integration.list({ location })).data, + (await client.provider.list({ location })).data, + ), + ); + if (!providers.length) throw new Error("the sign-in enabled no opencode provider"); writeAccountSettings(home, { ...readAccountSettings(home), providers }); } async status(home: string): Promise { - return signedIn(await this.credentials(home)); + return signedIn( + credentials( + await this.ask(home, async (client, location) => (await client.integration.list({ location })).data), + ), + ); } - /** The account's stored credentials, from opencode's own listing in a private server. */ - async credentials(home: string): Promise { - const { stdout } = await run(this.binary, ["auth", "list", "--standalone", "--format", "json"], { - env: accountEnvironment(this.env, home), - cwd: home, - encoding: "utf8", - }); - return credentials(JSON.parse(stdout)); + /** Ask a private server in the account's home, as opencode's own CLI commands do, once it has loaded. */ + private async ask( + home: string, + question: (client: OpenCodeClient, location: LocationRef) => Promise, + ): Promise { + const password = randomBytes(32).toString("base64url"); + const env = { ...accountEnvironment(this.env, home), OPENCODE_SERVER_PASSWORD: password }; + const server = serve(this.binary, home, env, () => {}); + try { + const url = await server.url; + if (!url) throw new Error(`opencode serve failed: ${server.tail()}`); + const client = connect(url, password); + const location = { directory: home }; + await client.model.default({ location }); + return await question(client, location); + } finally { + server.stop(); + await server.closed; + } } /** Pick the account's model in its own opencode config, which applies to every job it runs. */ @@ -561,71 +679,30 @@ export class Opencode implements Adapter { writeFileSync(shell, shellScript(this.srt, settings, launch.sandbox, launch.env), { mode: 0o700 }); const config = jobConfig({ launch, shell, plugin, instructions }); // The server runs outside the devShell, whose shellHook the job's revision controls, and with a - // HOME commands cannot write, since opencode loads plugins and instructions from it. The password - // and the tools' bearer token stay out of the command line, which other users can read. With - // --stdio it prints its address as JSON, drops the password from its environment, and exits - // when its stdin closes, so it ends with the runner however the runner ends. - const child = spawn(this.binary, ["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"], { - cwd: launch.cwd, - env: { + // HOME commands cannot write, since opencode loads plugins and instructions from it. + const server = serve( + this.binary, + launch.cwd, + { ...accountEnvironment(launch.env, launch.home), TMPDIR: join(dir, "tmp"), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: password, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), }, - stdio: ["pipe", "pipe", "pipe"], - // Its own process group, so stopping it also stops every tool it started. - detached: true, - }); - - let stderr = ""; - let listening: (url: string | null) => void = () => {}; - const url = new Promise((resolve) => (listening = resolve)); - const lines = boundedLines(LINE_LIMIT, (line) => { - try { - const { url } = JSON.parse(line) as { url?: unknown }; - if (typeof url === "string") listening(url); - } catch {} - }); - child.stdout.setEncoding("utf8").on("data", (chunk: string) => { - lines.data(chunk); - launch.output(chunk); - }); - child.stderr.setEncoding("utf8").on("data", (chunk: string) => { - stderr = (stderr + chunk).slice(-64 * 1024); - launch.output(chunk); - }); - const closed = new Promise((resolve) => { - child.once("error", (error) => { - stderr += `could not start ${this.binary}: ${error.message}\n`; - resolve(); - }); - child.once("close", () => resolve()); - }); - void closed.then(() => listening(null)); + launch.output, + ); let stopping = false; - let killer: ReturnType | undefined; let abort: () => Promise = async () => {}; const events = new AbortController(); let watched: Promise = Promise.resolve(); - const tail = () => stderr.trim().split("\n").slice(-20).join("\n"); const failure = (reply: string): Exit => ({ limited: null, failed: true, reply: reply || undefined }); - const terminate = () => { - child.stdin.end(); - signalGroup(child.pid, "SIGTERM"); - killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); - }; const turn = async (): Promise => { - const base = await url; - if (!base) return failure(tail()); - const client = OpenCode.make({ - baseUrl: base, - headers: { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }, - fetch: unbounded, - }); + const url = await server.url; + if (!url) return failure(server.tail()); + const client = connect(url, password); const location = { directory: launch.cwd }; const refused = await refusal(client, location, launch.home); if (refused) return failure(refused); @@ -651,7 +728,7 @@ export class Opencode implements Adapter { const stream = client.event.subscribe({ signal: events.signal })[Symbol.asyncIterator](); // The first event confirms the subscription, so nothing the prompt causes is missed. const connected = await stream.next(); - if (connected.done) return failure(tail() || "opencode's event stream closed"); + if (connected.done) return failure(server.tail() || "opencode's event stream closed"); watched = (async () => { for (let next = await stream.next(); !next.done; next = await stream.next()) { const event: OpenCodeEvent = next.value; @@ -685,14 +762,14 @@ export class Opencode implements Adapter { const done = turn() // The server's own last words explain a lost connection better than the client's error. - .catch((error: unknown) => failure(tail() || (error instanceof Error ? error.message : String(error)))) + .catch((error: unknown) => + failure(server.tail() || (error instanceof Error ? error.message : String(error))), + ) .then(async (exit) => { events.abort(); - terminate(); - await closed; + server.stop(); + await server.closed; await watched; - clearTimeout(killer); - lines.end(); rmSync(dir, { recursive: true, force: true }); return exit; }); @@ -703,8 +780,10 @@ export class Opencode implements Adapter { // Interrupt the turn so opencode records it, then stop the server whether or not it answered. abort() .catch(() => {}) - .finally(terminate); - killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); + .finally(() => server.stop()); + // A server too stuck to answer is stopped anyway. + const stuck = setTimeout(() => server.stop(), STOP_GRACE_MS); + void server.closed.then(() => clearTimeout(stuck)); }, }; } diff --git a/test/runner.test.ts b/test/runner.test.ts index 5bc97f95..ab071921 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -30,6 +30,7 @@ import { PLUGIN, projectConfig, signedIn, + signedInProviders, turnExit, } from "../src/runner/opencode"; import { FLUSH_BYTES, OutputUploader, split } from "../src/runner/output"; @@ -816,7 +817,18 @@ describe("opencode", () => { label: "wafer.ai API key (opencode)", }); expect(signedIn(credentials([]))).toBeNull(); - expect(() => credentials({})).toThrow(/no list/); + expect(() => credentials({})).toThrow(/no integrations/); + // OpenCode Console's providers keep their own IDs; a provider needing no sign-in is no account's. + expect( + signedInProviders( + [{ id: "opencode", connections: [{ type: "credential", id: "c", label: "Console", method: "oauth" }] }], + [ + { id: "acme-gpt", integrationID: "opencode" }, + { id: "opencode", integrationID: "opencode" }, + { id: "ollama" }, + ], + ), + ).toEqual(["acme-gpt", "opencode"]); }); test("keeps the user's provider credentials and opencode settings from the account", () => { @@ -1121,15 +1133,22 @@ describe("account add", () => { const dir = tempDir(); const bin = join(dir, "bin"); mkdirSync(bin); - // opencode's sign-in stores the credential in its own store, which `auth list` reports. - const list = JSON.stringify([ - { id: "opencode-go", connections: [{ type: "credential", id: "c", label: "API key", method: "key" }] }, - ]); + // opencode's sign-in stores the credential in its own store, which its server reports. + writeFileSync(join(bin, "opencode"), `#!${process.execPath}\n${FAKE_OPENCODE}`); + chmodSync(join(bin, "opencode"), 0o755); writeFileSync( - join(bin, "opencode"), - `#!/bin/sh\necho "$*" >> "${join(dir, "calls")}"\ncase "$1 $2" in "auth list") echo '${list}' ;; esac\n`, + join(dir, "signed-in.json"), + JSON.stringify({ + integrations: [ + { + id: "opencode-go", + connections: [{ type: "credential", id: "c", label: "API key", method: "key" }], + }, + { id: "openai", connections: [] }, + ], + providers: [{ id: "opencode-go", integrationID: "opencode-go" }, { id: "openai" }], + }), ); - chmodSync(join(bin, "opencode"), 0o755); const out: string[] = []; const io = { stdout: (text: string) => out.push(text), @@ -1168,10 +1187,17 @@ describe("account add", () => { { action: "provider.use", resource: "opencode-go", effect: "allow" }, ], }); - // In a private server each time, not opencode's background service. - expect(readFileSync(join(dir, "calls"), "utf8")).toBe( - "auth login --standalone\nauth list --standalone --format json\nauth list --standalone --format json\n", - ); + // Each in a private server in the account's home, not opencode's background service. + const calls = readFileSync(join(dir, "calls.log"), "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls.map((c) => c.argv)).toEqual([ + ["auth", "login", "--standalone"], + ["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"], + ["serve", "--stdio", "--hostname=127.0.0.1", "--port=0"], + ]); + expect(new Set(calls.map((c) => c.cwd))).toEqual(new Set([home.accountHome(account!.id)])); }); }); @@ -1392,9 +1418,16 @@ const { pathToFileURL } = require("node:url"); const argv = process.argv.slice(2); const logFile = join(__dirname, "..", "calls.log"); const log = (value) => fs.appendFileSync(logFile, JSON.stringify(value) + "\\n"); -const config = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT); +if (argv[0] === "auth") { + log({ argv, cwd: process.cwd() }); + process.exit(0); +} +// A job's server carries the runner's config; a server answering about the account carries none. +const config = process.env.OPENCODE_CONFIG_CONTENT ? JSON.parse(process.env.OPENCODE_CONFIG_CONTENT) : undefined; +if (!config) log({ argv, cwd: process.cwd() }); +const signedIn = () => JSON.parse(fs.readFileSync(join(__dirname, "..", "signed-in.json"), "utf8")); const auth = "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_SERVER_PASSWORD).toString("base64"); -const kix = config.mcp.servers.kix; +const kix = config?.mcp.servers.kix; const streams = []; const send = (event) => { for (const s of streams) s.write("data: " + JSON.stringify(event) + "\\n\\n"); }; const messages = {}; @@ -1411,7 +1444,7 @@ async function rpc(method, params) { return (await response.json()).result; } // opencode loads a local plugin from its directory and fails it rather than the server. -const plugin = (async () => { +const plugin = config && (async () => { const { package: dir, options } = config.plugins[0]; const { default: definition } = await import(pathToFileURL(join(dir, "server.js")).href); let hooked = false; @@ -1458,6 +1491,8 @@ const server = http.createServer(async (request, response) => { return response.write("data: " + JSON.stringify({ type: "server.connected", data: {} }) + "\\n\\n"); } if (path === "/api/model/default") return json(response, { data: { id: "gpt-5.5", providerID: "openai" } }); + if (path === "/api/integration") return json(response, { location: {}, data: signedIn().integrations }); + if (path === "/api/provider") return json(response, { location: {}, data: signedIn().providers }); // The runner's config, and the account's config directory. // The runner's config, the account's config directory, and any the steps add, such as a machine's. if (path === "/api/config") @@ -2042,7 +2077,9 @@ describe("quest runner", () => { resource: "*", effect: "deny", })), - { action: "external_directory", resource: `${tmp}/*`, effect: "allow" }, + ...[tmp, join(home, "data", "opencode", "tool-output"), join(home, "data", "opencode", "shell")].map( + (dir) => ({ action: "external_directory", resource: `${dir}/*`, effect: "allow" }), + ), ], lsp: false, formatter: false, @@ -2059,7 +2096,7 @@ describe("quest runner", () => { expect(first.config.plugins[0].options).toEqual({ cwd: checkout, writes: [checkout, tmp], - reads: [join(home, "data", "opencode", "tool-output")], + reads: [join(home, "data", "opencode", "tool-output"), join(home, "data", "opencode", "shell")], config: [".opencode", "opencode.json", "opencode.jsonc"].map((name) => join(checkout, name)), }); // The launch's shell, plugin, and settings are gone once it ends. From 6c2f319905785465ef640ab172cf7ecc6fedb35a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 08:15:21 -0800 Subject: [PATCH 06/10] fix(runner): refuse a file tool call whose input is not an object The opencode plugin is the file tools' only boundary, so input it cannot read as an object now fails closed rather than passing with no paths to check. Also drops a stale duplicate comment in the fake server. Co-Authored-By: Claude Opus 5.5 --- src/runner/opencode.ts | 9 ++++++--- test/runner.test.ts | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index f7f863d7..065a6dcb 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -169,7 +169,8 @@ const PLUGIN_ID = "quest-runner"; * existing directory. A dangling symlink is refused, since a write would * follow it. `patch` names its files in the patch's headers. Nothing writes * the checkout's own opencode config. A tool's input may arrive as a JSON - * string, which opencode repairs into its object. The tool opens the path + * string, which opencode repairs into its object; one that is not an object + * either way is refused, since its paths cannot be checked. The tool opens the path * after this check, so a command running alongside it could still swap the * path for a symlink in between: the check narrows that window rather than * closing it. @@ -224,12 +225,14 @@ export default { await ctx.tool.hook("execute.before", ({ tool, input }) => { if (!Object.hasOwn(TOOLS, tool)) return; const args = parsed(input); + if (args === null || typeof args !== "object") + throw new Error(\`the \${tool} tool's input is not an object, so the runner cannot check its paths\`); const paths = tool === "patch" - ? String(args?.patchText ?? "") + ? String(args.patchText ?? "") .split(/\\r\\n|\\r|\\n/) .flatMap((line) => HEADER.exec(line)?.[1]?.trim() || []) - : [args?.path].filter((path) => typeof path === "string" && path); + : [args.path].filter((path) => typeof path === "string" && path); for (const path of paths) { const target = real(resolve(options.cwd, path)); if (!inside(target, TOOLS[tool] ? reads : writes)) diff --git a/test/runner.test.ts b/test/runner.test.ts index ab071921..7a8b574a 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -896,6 +896,9 @@ describe("opencode", () => { ["patch", patch("*** Update File: a.txt", "*** Move to: /etc/x")], ] as const) await expect(call(tool, input), `${tool} ${JSON.stringify(input)}`).rejects.toThrow(/outside the checkout/); + // Input that is not an object, even after opencode's repair, has no paths to check. + for (const input of ["{path: 'auth.json'}", "auth.json", null, 1]) + await expect(call("read", input), JSON.stringify(input)).rejects.toThrow(/not an object/); // A write would follow a symlink to nothing out of the checkout. await expect(call("write", { path: "dangling" })).rejects.toThrow(/symlink to nothing/); // opencode would load its own config from the checkout, outside the sandbox. @@ -1493,7 +1496,6 @@ const server = http.createServer(async (request, response) => { if (path === "/api/model/default") return json(response, { data: { id: "gpt-5.5", providerID: "openai" } }); if (path === "/api/integration") return json(response, { location: {}, data: signedIn().integrations }); if (path === "/api/provider") return json(response, { location: {}, data: signedIn().providers }); - // The runner's config, and the account's config directory. // The runner's config, the account's config directory, and any the steps add, such as a machine's. if (path === "/api/config") return json(response, [ From f15bdb3855d346067c96c898b15fbcfa69cb4385 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 08:35:06 -0800 Subject: [PATCH 07/10] fix(runner): resolve ~ as opencode does and refuse background tool calls opencode's file tools expand a leading ~ to the server's home, so the plugin now resolves paths the same way before checking them; otherwise a write to ~/data/opencode/shell passed as a checkout path. It also refuses shell and subagent calls in the background, whose results arrive only after session.wait returns and the runner stops the server. Co-Authored-By: Claude Opus 5.5 --- src/runner/opencode.ts | 33 ++++++++++++++++++++++++++------- test/runner.test.ts | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index 065a6dcb..edaa7594 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -164,22 +164,30 @@ const PLUGIN_ID = "quest-runner"; * opencode's own saved tool output. opencode's `external_directory` check is * lexical, so a symlink committed to the checkout would otherwise lead them to * any file this user can read: the server, unlike the commands, runs - * unconfined. Each path is resolved as the tool resolves it, then through - * every symlink, and a path that does not exist yet through its nearest - * existing directory. A dangling symlink is refused, since a write would + * unconfined. Each path is resolved as the tool resolves it, from the + * checkout with a leading `~` as the server's home, then through every + * symlink, and a path that does not exist yet through its nearest existing + * directory. A dangling symlink is refused, since a write would * follow it. `patch` names its files in the patch's headers. Nothing writes * the checkout's own opencode config. A tool's input may arrive as a JSON * string, which opencode repairs into its object; one that is not an object - * either way is refused, since its paths cannot be checked. The tool opens the path + * either way is refused, since it cannot be checked. The tool opens the path * after this check, so a command running alongside it could still swap the * path for a symlink in between: the check narrows that window rather than * closing it. + * + * It also refuses `shell` and `subagent` calls in the background. Their + * result reaches the session only after the turn ends, when the runner has + * already stopped the server and reported the turn. */ export const PLUGIN = `import { lstatSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; // Each file tool, and whether it only reads. const TOOLS = { read: true, glob: true, grep: true, write: false, edit: false, patch: false }; +// The tools that can run in the background, past the end of the turn. +const FOREGROUND = ["shell", "subagent"]; const HEADER = /^\\s*\\*\\*\\* (?:Add File|Update File|Delete File|Move to):(.*)$/; function real(path) { @@ -206,6 +214,12 @@ function inside(path, roots) { }); } +// As opencode's tools resolve a path: from the checkout, with a leading ~ as the server's home. +function absolute(cwd, path) { + const home = process.env.OPENCODE_TEST_HOME ?? homedir(); + return resolve(cwd, path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path); +} + function parsed(input) { if (typeof input !== "string") return input; try { @@ -223,10 +237,15 @@ export default { const reads = [...writes, ...options.reads.map(real)]; const config = options.config.map(real); await ctx.tool.hook("execute.before", ({ tool, input }) => { - if (!Object.hasOwn(TOOLS, tool)) return; + const files = Object.hasOwn(TOOLS, tool); + if (!files && !FOREGROUND.includes(tool)) return; const args = parsed(input); if (args === null || typeof args !== "object") - throw new Error(\`the \${tool} tool's input is not an object, so the runner cannot check its paths\`); + throw new Error(\`the \${tool} tool's input is not an object, so the runner cannot check it\`); + // opencode's input repair, which may run after this hook, turns "true" into true. + if (args.background === true || args.background === "true") + throw new Error(\`the runner ends the job when this turn ends, so run the \${tool} in the foreground instead\`); + if (!files) return; const paths = tool === "patch" ? String(args.patchText ?? "") @@ -234,7 +253,7 @@ export default { .flatMap((line) => HEADER.exec(line)?.[1]?.trim() || []) : [args.path].filter((path) => typeof path === "string" && path); for (const path of paths) { - const target = real(resolve(options.cwd, path)); + const target = real(absolute(options.cwd, path)); if (!inside(target, TOOLS[tool] ? reads : writes)) throw new Error(\`\${path} is outside the checkout, where the runner keeps the file tools\`); if (!TOOLS[tool] && inside(target, config)) diff --git a/test/runner.test.ts b/test/runner.test.ts index 7a8b574a..a23bcaac 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -882,6 +882,25 @@ describe("opencode", () => { await expect(call("write", { path: join(output, "tool_1") })).rejects.toThrow(/outside the checkout/); // Commands are the sandbox's to confine. await call("shell", { command: "cat /etc/passwd" }); + // opencode resolves a leading ~ as its home, here the root holding the saved output. + vi.stubEnv("OPENCODE_TEST_HOME", root); + await call("read", { path: "~/tool-output/tool_1" }); + for (const [tool, input] of [ + ["write", { path: "~/tool-output/tool_1" }], + ["read", { path: "~/outside/auth.json" }], + ["read", { path: "~" }], + ["patch", patch("*** Add File: ~/tool-output/x")], + ] as const) + await expect(call(tool, input), `${tool} ${JSON.stringify(input)}`).rejects.toThrow(/outside the checkout/); + vi.unstubAllEnvs(); + // Background work would outlive the turn, and the server with it. + await call("shell", { command: "true", background: false }); + for (const [tool, input] of [ + ["shell", { command: "sleep 60", background: true }], + ["subagent", { description: "x", prompt: "x", background: "true" }], + ["shell", JSON.stringify({ command: "sleep 60", background: true })], + ] as const) + await expect(call(tool, input), `${tool} ${JSON.stringify(input)}`).rejects.toThrow(/in the foreground/); for (const [tool, input] of [ ["read", { path: "auth.json" }], From a7b4d97127fb7c1a7db5f88c79be4de072fbc5a1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 09:37:34 -0800 Subject: [PATCH 08/10] docs(runner): opencode's input repair runs before the plugin's hook Co-Authored-By: Claude Opus 5.5 --- src/runner/opencode.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index edaa7594..5169ee80 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -169,9 +169,11 @@ const PLUGIN_ID = "quest-runner"; * symlink, and a path that does not exist yet through its nearest existing * directory. A dangling symlink is refused, since a write would * follow it. `patch` names its files in the patch's headers. Nothing writes - * the checkout's own opencode config. A tool's input may arrive as a JSON - * string, which opencode repairs into its object; one that is not an object - * either way is refused, since it cannot be checked. The tool opens the path + * the checkout's own opencode config. opencode's input repair runs before + * this hook, turning a JSON-string input into its object and `"true"` into + * true; the plugin repeats both, so a release that reorders or drops repair + * cannot slip a call past it. Input that is not an object either way is + * refused, since it cannot be checked. The tool opens the path * after this check, so a command running alongside it could still swap the * path for a symlink in between: the check narrows that window rather than * closing it. @@ -242,7 +244,6 @@ export default { const args = parsed(input); if (args === null || typeof args !== "object") throw new Error(\`the \${tool} tool's input is not an object, so the runner cannot check it\`); - // opencode's input repair, which may run after this hook, turns "true" into true. if (args.background === true || args.background === "true") throw new Error(\`the runner ends the job when this turn ends, so run the \${tool} in the foreground instead\`); if (!files) return; From 800d51910b6cbd32d36401eda3980c7d50c2dca0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 14:04:13 -0800 Subject: [PATCH 09/10] fix(runner): arm no kill timer once opencode's server has closed A server that failed to start had already cleared its timer when the caller's cleanup stopped it, so stop() armed a referenced ten-second timer nothing cleared, holding the CLI open after the error. Co-Authored-By: Claude Opus 5.5 --- src/runner/opencode.ts | 5 ++++- test/runner.test.ts | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/runner/opencode.ts b/src/runner/opencode.ts index e2d9a9f3..eb0e97e0 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -565,6 +565,7 @@ function serve(binary: string, cwd: string, env: NodeJS.ProcessEnv, output: (chu output(chunk); }); let killer: ReturnType | undefined; + let exited = false; const closed = new Promise((resolve) => { child.once("error", (error) => { stderr += `could not start ${binary}: ${error.message}\n`; @@ -572,6 +573,7 @@ function serve(binary: string, cwd: string, env: NodeJS.ProcessEnv, output: (chu }); child.once("close", () => resolve()); }).then(() => { + exited = true; clearTimeout(killer); lines.end(); listening(null); @@ -584,7 +586,8 @@ function serve(binary: string, cwd: string, env: NodeJS.ProcessEnv, output: (chu stop() { child.stdin.end(); signalGroup(child.pid, "SIGTERM"); - killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); + // Armed after the server closed, nothing would clear it, and it would hold the process open. + if (!exited) killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); }, }; } diff --git a/test/runner.test.ts b/test/runner.test.ts index 023fc740..688de9b2 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -956,6 +956,18 @@ describe("opencode", () => { expect(() => Opencode.setModel(launch.home, "openai/gpt-5.5")).toThrow(/not signed in/); }); + test("leaves no kill timer behind a server that could not start", async () => { + vi.useFakeTimers(); + try { + await expect(new Opencode(join(tempDir(), "opencode"), "/srt").status(tempDir())).rejects.toThrow( + /could not start/, + ); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + test("reads account limits from errors, not brief rate limits", () => { const now = 1_000_000; const error = (type: string, message: string, body?: string) => ({ From 6b40e562e4d009d2b6dc2b452ab2db573ce2dac0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 4 Nov 2026 14:23:44 -0800 Subject: [PATCH 10/10] docs(quest): give the opencode config refusal its 2.x reason The sandbox README explained the per-turn config refusal with opencode 1.19, which this branch removes; the refusal stays because a machine's managed /etc/opencode loads above the runner's config. Co-Authored-By: Claude Opus 5.5 --- quest/m0/kix/sandbox/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/quest/m0/kix/sandbox/README.md b/quest/m0/kix/sandbox/README.md index b6a36e6c..44dea2e1 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -68,11 +68,12 @@ Shipped in [moq-dev/quest#212](https://github.com/moq-dev/quest/pull/212) from source (`nix/sandbox-runtime.nix`), and the runner refuses an opencode account when srt lets commands make Unix sockets ([moq-dev/quest#465](https://github.com/moq-dev/quest/pull/465)). - The runner keeps the agent from creating a checkout's own `opencode.json` - or `.opencode/`, refuses a checkout that holds them, and before each turn - refuses to run if opencode loaded any config but the runner's and the - account's, since opencode 1.19 imported a checkout's plugins even with - project config disabled. + Besides disabling project config, the runner keeps the agent from creating + a checkout's own `opencode.json` or `.opencode/` and refuses a checkout + that holds them. Before each turn it refuses to run if opencode loaded any + config but the runner's and the account's, since a machine's managed + `/etc/opencode` loads above the runner's and could replace the sandboxed + `shell`. `test/live/runner-sandbox.test.ts` checks that boundary with the pinned opencode and srt and a scripted model. It runs in `just runner-sandbox` and a nightly GitHub-hosted workflow (`.github/workflows/sandbox.yml`),