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 0cc2b91f..a260915e 100644 --- a/README.md +++ b/README.md @@ -1250,10 +1250,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/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 da6e6163..3ec098da 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; sandboxRuntime = pkgs.callPackage ./nix/sandbox-runtime.nix { }; quest = pkgs.callPackage ./nix/package.nix { }; questPinned = pkgs.callPackage "${quest-pinned}/nix/package.nix" { }; @@ -75,8 +70,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 = { @@ -125,9 +120,9 @@ fi 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 + 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 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/quest/m0/README.md b/quest/m0/README.md index ffaa3efc..ae59277d 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -105,4 +105,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 e6fecdef..44dea2e1 100644 --- a/quest/m0/kix/sandbox/README.md +++ b/quest/m0/kix/sandbox/README.md @@ -68,9 +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)). - 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. + 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`), diff --git a/quest/m0/kix/sandbox/opencode-data.md b/quest/m0/kix/sandbox/opencode-data.md index fda20b52..4cbddef8 100644 --- a/quest/m0/kix/sandbox/opencode-data.md +++ b/quest/m0/kix/sandbox/opencode-data.md @@ -78,7 +78,3 @@ Implementation guidance: - Extend `test/live/runner-sandbox.test.ts` with a second job, in the same repository, that tries to read the first job's saved tool output and shell capture. - -## Required - -- [opencode 2.x](/quest/m0/opencode-sdk.md) - this builds on its client and job plugin 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 fabd7ae8..fd66a2a1 100644 --- a/src/runner/opencode.ts +++ b/src/runner/opencode.ts @@ -1,35 +1,36 @@ // 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. +// 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 { 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 { - 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 { LIMIT } from "../cli/limit"; @@ -81,29 +82,65 @@ 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"]; @@ -119,26 +156,41 @@ 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 * 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 - * 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. + * 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. 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. + * + * 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, lsp: true, write: false, edit: false, apply_patch: false }; +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) { @@ -165,42 +217,62 @@ 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 { + 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 }) => { + 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 it\`); + 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 ?? "") + .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(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)) + 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`. + * 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`. */ export function shellScript(srt: string, settings: string, sandbox: Sandbox, env: NodeJS.ProcessEnv): string { const kept = Object.entries(srtEnvironment(env, sandbox.tmp)).map(([name, value]) => quote(`${name}=${value}`)); @@ -214,86 +286,113 @@ 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")]; } /** * 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, 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. */ 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"), + ...[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, 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)], + reads: savedOutput(launch.home), config: PROJECT_CONFIG.map((name) => join(launch.cwd, name)), }, - ], + }, ], }; } 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, 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 credentials stored among opencode's integrations, without the credentials themselves. */ +export function credentials(list: unknown): Credential[] { + 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 }) => + connection?.type === "credential" && typeof connection.method === "string" + ? [{ provider: integration.id as string, type: connection.method }] + : [], + ) + : [], + ); +} + +/** + * 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. */ @@ -316,56 +415,177 @@ export function signedIn(found: Credential[]): SignedIn | null { }; } -/** - * 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 || 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; + +/** 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; + let exited = false; + const closed = new Promise((resolve) => { + child.once("error", (error) => { + stderr += `could not start ${binary}: ${error.message}\n`; + resolve(); + }); + child.once("close", () => resolve()); + }).then(() => { + exited = true; + clearTimeout(killer); + lines.end(); + listening(null); + }); return { - limited, - failed: !info || error !== undefined, - reply: text || (typeof message === "string" ? message : error?.name), + child, + url, + closed, + tail: () => stderr.trim().split("\n").slice(-20).join("\n"), + stop() { + child.stdin.end(); + signalGroup(child.pid, "SIGTERM"); + // 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); + }, }; } @@ -379,11 +599,17 @@ 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), + // Not the caller's directory, whose own opencode config the server would load. + cwd: home, stdio: "inherit", }); child.once("error", reject); @@ -391,17 +617,50 @@ export class Opencode implements Adapter { code === 0 ? resolve() : reject(new Error(`opencode auth login exited ${code}`)), ); }); + 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(credentials(home)); + return signedIn( + credentials( + await this.ask(home, async (client, location) => (await client.integration.list({ location })).data), + ), + ); + } + + /** 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. */ 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 { @@ -410,82 +669,61 @@ 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"], { - cwd: launch.cwd, - env: { - ...environment(launch.env), - ...accountDirectories(launch.home), - HOME: launch.home, + 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: ["ignore", "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) => { - const found = /^opencode server listening on (http:\/\/\S+)/.exec(line); - if (found) listening(found[1]!); - }); - 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 turn = async (): Promise => { - const base = await url; - if (!base) return failure(tail()); - const client = createOpencodeClient({ - baseUrl: base, - directory: launch.cwd, - headers: { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }, - }); - let session: string | undefined; + 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); + let session: string; if (launch.resume) { try { session = readFileSync(join(sessions, launch.session), "utf8").trim(); @@ -493,66 +731,62 @@ 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(server.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)))) + .catch((error: unknown) => + failure(server.tail() || (error instanceof Error ? error.message : String(error))), + ) .then(async (exit) => { - signalGroup(child.pid, "SIGTERM"); - killer ??= setTimeout(() => signalGroup(child.pid, "SIGKILL"), STOP_GRACE_MS); - await closed; + events.abort(); + server.stop(); + await server.closed; await watched; - clearTimeout(killer); - lines.end(); rmSync(dir, { recursive: true, force: true }); return exit; }); @@ -560,11 +794,13 @@ 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")); - 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/live/runner-sandbox.test.ts b/test/live/runner-sandbox.test.ts index 729e054a..d46b0b24 100644 --- a/test/live/runner-sandbox.test.ts +++ b/test/live/runner-sandbox.test.ts @@ -37,6 +37,8 @@ import { closeSandbox, openSandbox, quote, type Sandbox, unixSocketRefusal } fro /** 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"; @@ -67,18 +69,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), }, { @@ -172,12 +176,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")), }, ]; @@ -188,7 +192,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))`); @@ -214,6 +218,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", "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", () => { @@ -229,7 +242,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"); @@ -299,6 +312,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 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( @@ -359,7 +376,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, @@ -377,20 +394,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", @@ -401,6 +419,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 b91d75de..2d6f3979 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -24,13 +24,14 @@ import { type Account, RunnerHome } from "../src/runner/home"; import { serveTools } from "../src/runner/mcp"; import { accountDirectories, + accountLimit, credentials, environment, Opencode, PLUGIN, projectConfig, - retryLimit, signedIn, + signedInProviders, turnExit, } from "../src/runner/opencode"; import { FLUSH_BYTES, OutputUploader, split } from "../src/runner/output"; @@ -811,26 +812,36 @@ 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 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", () => { @@ -858,46 +869,78 @@ 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" }); + // 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" }], + ["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/); + // 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", { 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", () => { @@ -918,44 +961,76 @@ 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("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 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( + accountLimit( + error( + "provider.rate-limit", + "Too many", + JSON.stringify({ error: { type: "usage_limit_reached", resets_at: 1_900_000 } }), + ), + now, + ), + ).toEqual({ until: 1_900_000_000 }); + 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( - api("Too many", JSON.stringify({ error: { type: "usage_limit_reached", resets_at: 1_900_000 } })), - [], + assistant([ + { type: "reasoning", text: "hm" }, + { type: "text", text: "All done" }, + ]), + undefined, null, 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({ + ), + ).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: "Bad request", + reply: "No model", }); - expect( - turnExit({ role: "assistant" } as never, [{ type: "text", text: "All done" } as never], null, now), - ).toEqual({ limited: null, failed: false, reply: "All done" }); }); }); @@ -1105,12 +1180,22 @@ 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 its server reports. + writeFileSync(join(bin, "opencode"), `#!${process.execPath}\n${FAKE_OPENCODE}`); + chmodSync(join(bin, "opencode"), 0o755); 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`, + 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), @@ -1141,7 +1226,25 @@ 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" }, + ], + }); + // 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)])); }); }); @@ -1381,23 +1484,33 @@ 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); +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.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) { @@ -1408,17 +1521,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 = config && (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, }); @@ -1429,35 +1550,58 @@ 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" } }); + 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, 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 }, + ]); + 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. */ @@ -1547,10 +1691,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[] = []; @@ -1938,6 +2091,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"', @@ -1958,7 +2112,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); @@ -1994,35 +2148,46 @@ 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", + })), + ...[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, - 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")], + 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. 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, { @@ -2063,20 +2228,37 @@ 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(); + }); + + 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(); });