diff --git a/.changeset/warm-toon-acp.md b/.changeset/warm-toon-acp.md new file mode 100644 index 000000000000..52cbddc6b2bb --- /dev/null +++ b/.changeset/warm-toon-acp.md @@ -0,0 +1,5 @@ +--- +"opencode": minor +--- + +Add typed JSON/TOON RPC session reads, opt-in TOON-RPC ACP framing, and a native framed RPC sidecar in every Redcode platform package. diff --git a/.github/workflows/red-publish.yml b/.github/workflows/red-publish.yml index 5e436c5e0571..8dfbcfe47741 100644 --- a/.github/workflows/red-publish.yml +++ b/.github/workflows/red-publish.yml @@ -20,8 +20,104 @@ concurrency: cancel-in-progress: false jobs: + sidecar: + if: github.repository == 'reddb-io/redcode' + name: sidecar (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + host: ubuntu-latest + target: x86_64-linux-gnu.2.29 + file: redcode-rpc-sidecar-linux-x64 + identity: "ELF 64-bit LSB executable, x86-64" + linkage: dynamically linked + - name: linux-arm64 + host: ubuntu-latest + target: aarch64-linux-gnu.2.29 + file: redcode-rpc-sidecar-linux-arm64 + identity: "ELF 64-bit LSB executable, ARM aarch64" + linkage: dynamically linked + - name: linux-x64-musl + host: ubuntu-latest + target: x86_64-linux-musl + file: redcode-rpc-sidecar-linux-x64-musl + identity: "ELF 64-bit LSB executable, x86-64" + linkage: statically linked + - name: linux-arm64-musl + host: ubuntu-latest + target: aarch64-linux-musl + file: redcode-rpc-sidecar-linux-arm64-musl + identity: "ELF 64-bit LSB executable, ARM aarch64" + linkage: statically linked + - name: windows-x64 + host: ubuntu-latest + target: x86_64-windows-gnu + file: redcode-rpc-sidecar-windows-x64.exe + identity: "PE32+ executable (console) x86-64" + linkage: "" + - name: windows-arm64 + host: ubuntu-latest + target: aarch64-windows-gnu + file: redcode-rpc-sidecar-windows-arm64.exe + identity: "PE32+ executable (console) Aarch64" + linkage: "" + - name: darwin-x64 + host: macos-15-intel + target: "" + deployment: "11.0" + developer: /Applications/Xcode_16.4.app/Contents/Developer + file: redcode-rpc-sidecar-darwin-x64 + identity: "Mach-O 64-bit executable x86_64" + linkage: "" + - name: darwin-arm64 + host: macos-15 + target: "" + deployment: "11.0" + developer: /Applications/Xcode_16.4.app/Contents/Developer + file: redcode-rpc-sidecar-darwin-arm64 + identity: "Mach-O 64-bit executable arm64" + linkage: "" + runs-on: ${{ matrix.host }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: ./.github/actions/setup-bun + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.11.1 + - name: Install pinned Zig toolchain + if: runner.os == 'Linux' + run: | + set -euo pipefail + curl --fail --location --output /tmp/zig.tar.xz https://ziglang.org/builds/zig-x86_64-linux-0.17.0-dev.1857+3c46da14d.tar.xz + echo "0825e4a55baf4a6647c3ce3077a7236c9ffec5b17e1ca0102a930fe33d631bc6 /tmp/zig.tar.xz" | sha256sum --check - + tar -xf /tmp/zig.tar.xz -C /tmp + echo "/tmp/zig-x86_64-linux-0.17.0-dev.1857+3c46da14d" >> "$GITHUB_PATH" + - name: Build RPC sidecar + working-directory: packages/rpc-sidecar + env: + DEVELOPER_DIR: ${{ matrix.developer }} + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.deployment }} + REDCODE_RPC_SIDECAR_OUTPUT: dist/release/${{ matrix.file }} + REDCODE_RPC_SIDECAR_TARGET: ${{ matrix.target }} + run: bun run build + - name: Verify RPC sidecar target + run: | + set -euo pipefail + metadata="$(file "packages/rpc-sidecar/dist/release/${{ matrix.file }}")" + grep -F "${{ matrix.identity }}" <<<"$metadata" + if [ -n "${{ matrix.linkage }}" ]; then grep -F "${{ matrix.linkage }}" <<<"$metadata"; fi + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: rpc-sidecar-${{ matrix.name }} + path: packages/rpc-sidecar/dist/release/${{ matrix.file }} + if-no-files-found: error + retention-days: 1 + publish: if: github.repository == 'reddb-io/redcode' + needs: sidecar runs-on: ubuntu-latest environment: name: red-release @@ -71,11 +167,17 @@ jobs: exit 1 fi echo "resuming existing draft $TAG" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: rpc-sidecar-* + path: packages/rpc-sidecar/dist/release + merge-multiple: true - name: Build and upload native binaries env: GH_REPO: reddb-io/redcode GH_TOKEN: ${{ github.token }} OPENCODE_RELEASE: "1" + REDCODE_RPC_SIDECAR_DIR: ${{ github.workspace }}/packages/rpc-sidecar/dist/release VERSION: ${{ steps.release.outputs.version }} run: OPENCODE_VERSION="$VERSION" ./packages/opencode/script/build.ts - name: Normalize native npm metadata @@ -118,6 +220,7 @@ jobs: for attempt in 1 2 3 4 5 6 7 8 9 10; do if npm install --prefix "$root" --no-audit --no-fund "@reddb-io/redcode@${VERSION}"; then "$root/node_modules/.bin/redcode" --version | grep -Fx "$VERSION" + REDCODE_RPC_URL=http://127.0.0.1:1/rpc "$root/node_modules/.bin/redcode-rpc-sidecar" text += chunk); process.stdin.on("end", () => { const pkg = JSON.parse(text); const expected = require("./package.json").version; if (pkg.name !== "@reddb-io/redcode" || pkg.version !== expected || pkg.bin.redcode === undefined) process.exit(1) })' + node -e 'let text=""; process.stdin.on("data", (chunk) => text += chunk); process.stdin.on("end", () => { const pkg = JSON.parse(text); const expected = require("./package.json").version; if (pkg.name !== "@reddb-io/redcode" || pkg.version !== expected || pkg.bin.redcode === undefined || pkg.bin["redcode-rpc-sidecar"] === undefined) process.exit(1) })' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69de1d93b0d..c3cd2300399c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,6 +74,11 @@ jobs: working-directory: packages/client run: bun run check:generated + - name: Build native RPC sidecar + if: runner.os == 'Linux' + working-directory: packages/rpc-sidecar + run: bun run test:native + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' working-directory: packages/opencode diff --git a/README.md b/README.md index 1c7124e1e1db..95b1f7c34417 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ larger surface than Redcode targets. Telling the two apart is the fastest way to | Piece | What it is | Where | | --- | --- | --- | -| `@reddb-io/redcode` | One native CLI binary, `redcode`. The whole product. | [Install](#install) | +| `@reddb-io/redcode` | The native `redcode` CLI and its `redcode-rpc-sidecar` companion. | [Install](#install) | | The TUI | Sessions, diffs, permissions, and the Worker fleet | [Use](#use) | | The server, ACP agent, MCP client | The same binary, other entry points | [Use](#use) | @@ -168,9 +168,10 @@ npm install -g @reddb-io/redcode redcode ``` -Bun, pnpm, and Yarn work too. The install resolves one native binary for your platform — Linux +Bun, pnpm, and Yarn work too. The install resolves one native package for your platform — Linux (glibc and musl, x64 and arm64), macOS (x64 and arm64), and Windows (x64 and arm64), with non-AVX2 -variants where the architecture needs them. +variants where the architecture needs them. Each package contains `redcode` and the matching +`redcode-rpc-sidecar` companion. There is no beta channel, no container image, no desktop build, no package-manager tap, and no hosted deployment. See [What Ships And What Doesn't](#what-ships-and-what-doesnt) for why that is @@ -185,7 +186,7 @@ Running `redcode` with no arguments opens the TUI directly in a new session. | `redcode` | Terminal UI — sessions, diffs, permissions, and the Workers fleet | | `redcode run` | Non-interactive prompt; `--format json` emits structured records | | `redcode serve` | Headless HTTP server exposing the Protocol API | -| `redcode acp` | Agent Client Protocol over stdio, for editors that speak ACP | +| `redcode acp` | Agent Client Protocol over stdio; `--experimental-toon` selects TOON-RPC framing | | `redcode mcp` | Manage the MCP servers Redcode connects to — it is an MCP client, not a server | | `redcode attach` | Attach to a running server | | `redcode web` | Start the server and open the local web interface | @@ -198,6 +199,16 @@ Running `redcode` with no arguments opens the TUI directly in a new session. `redcode --help` lists everything, including `upgrade`, `uninstall`, `generate`, and `console`. +`redcode serve` prints both its base URL and the exact `POST /rpc` endpoint. That endpoint accepts +JSON-RPC 2.0 (`application/json`) and TOON-RPC 1.0 (`application/toon`) for the same typed read-only +methods: `health.get`, `session.list`, and `session.active`. `session.list` accepts the same filters, +ordering, limit, and cursor semantics as `GET /api/session`. + +The sibling `redcode-rpc-sidecar` bridges bounded `Content-Length` frames on stdin/stdout to that +HTTP endpoint. Set `REDCODE_RPC_URL` to the printed URL. It reuses +`OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD`, or accepts a complete +`REDCODE_AUTHORIZATION` header. + ## Architecture ``` @@ -224,6 +235,7 @@ does not import the CLI that hosts it. | `packages/client` | Generated clients — zero-Effect root, `/effect` variant | | `packages/tui` | Terminal UI (OpenTUI + Solid) | | `packages/opencode` | The CLI that becomes the `redcode` binary | +| `packages/rpc-sidecar` | Static native companion that bridges framed JSON/TOON RPC to `/rpc` | | `packages/plugin` | Public plugin API | | `packages/sdk/js` | The SDK the TUI, CLI, and ACP agent all talk through | diff --git a/bun.lock b/bun.lock index 6ed276f2e77d..ae5241bf8db5 100644 --- a/bun.lock +++ b/bun.lock @@ -566,7 +566,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "0.8.3", + "version": "0.12.0", "bin": { "redcode": "./bin/opencode", }, @@ -623,7 +623,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", - "@reddb-io/toon": "0.26.2", + "@reddb-io/toon": "0.29.6", "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", @@ -738,6 +738,16 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/rpc-sidecar": { + "name": "@opencode-ai/rpc-sidecar", + "version": "0.12.0", + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "scriptc": "0.0.35", + }, + }, "packages/schema": { "name": "@opencode-ai/schema", "version": "1.18.18", @@ -797,6 +807,9 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@reddb-io/multi-rpc": "0.29.6", + "@reddb-io/toon": "0.29.6", + "@reddb-io/toon-rpc": "0.29.6", "drizzle-orm": "catalog:", "effect": "catalog:", }, @@ -1077,6 +1090,7 @@ "tree-sitter-bash", ], "patchedDependencies": { + "@scriptc/runtime@0.0.35": "patches/@scriptc%2Fruntime@0.0.35.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", @@ -2043,6 +2057,8 @@ "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], + "@opencode-ai/rpc-sidecar": ["@opencode-ai/rpc-sidecar@workspace:packages/rpc-sidecar"], + "@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -2485,8 +2501,12 @@ "@radix-ui/rect": ["@radix-ui/rect@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ=="], + "@reddb-io/multi-rpc": ["@reddb-io/multi-rpc@0.29.6", "", { "dependencies": { "@reddb-io/toon": "0.29.6", "@reddb-io/toon-rpc": "0.29.6" } }, "sha512-OljioOTUEf6LlNXA0MbhwNO8V9q0W96GCh3y1u36ZAAfZxAhKDXgwu6Ato8hhO0hp5WRHlkW649sOWrIm9VKYg=="], + "@reddb-io/toon": ["@reddb-io/toon@0.26.2", "", {}, "sha512-XXcBkLcwVVhy2A+BQvv+Ds1KD8YqQ+Bu0/ETbJT2TnnaIRcEIHNT98DHUV1qSlijpF8aGH1VgLqEv3Q7TOxZsA=="], + "@reddb-io/toon-rpc": ["@reddb-io/toon-rpc@0.29.6", "", { "peerDependencies": { "@reddb-io/toon": ">=0.28" } }, "sha512-tvAEr3VkCjwI0wHHGD/G6r2+DGDhqhy+XgHTe0kACnUDTXHKebXyDvVTfaRHlUBxzcnBvZGzGXxMFe2rawXNAQ=="], + "@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.8.1", "", {}, "sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig=="], "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], @@ -2545,6 +2565,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + "@scriptc/compiler": ["@scriptc/compiler@0.0.35", "", { "dependencies": { "@scriptc/runtime": "0.0.35", "typescript": "7.0.2", "typescript5": "npm:typescript@5.9.3" } }, "sha512-OLTmyGpFPJAAVn0RfRBoXuCjpaTfpBC1wt6XdO/SzqMab/DaRjCIJY9vv5b1VspP6zLZP2WEYWZPgP8vHKAJPg=="], + + "@scriptc/runtime": ["@scriptc/runtime@0.0.35", "", {}, "sha512-kjtKfneGr9YGxx631jOPJ1XVh0X6h5LM+rwQmhF6loI9oA+zW7rlYmoF+VkTXLdkre+bu9LptIIgdxKPdAquPA=="], + "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], "@sentry-internal/browser-utils": ["@sentry-internal/browser-utils@10.36.0", "", { "dependencies": { "@sentry/core": "10.36.0" } }, "sha512-WILVR8HQBWOxbqLRuTxjzRCMIACGsDTo6jXvzA8rz6ezElElLmIrn3CFAswrESLqEEUa4CQHl5bLgSVJCRNweA=="], @@ -3049,6 +3073,46 @@ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], @@ -3317,6 +3381,8 @@ "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], "builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="], @@ -5125,6 +5191,8 @@ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "scriptc": ["scriptc@0.0.35", "", { "dependencies": { "@scriptc/compiler": "0.0.35" }, "bin": { "scriptc": "dist/bootstrap.js" } }, "sha512-46Td/02HJZNLuosdYDiUSQ+auKwGmQ03+odD6Pg+J7D+eAV7sOYQvjZu9M+3ePyYfL3hFQa6wWiGexV/Bd0r+w=="], + "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], @@ -5483,6 +5551,8 @@ "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + "typescript5": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], @@ -5557,6 +5627,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "utf-8-validate": ["utf-8-validate@5.0.10", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ=="], + "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -6181,6 +6253,8 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@opencode-ai/server/@reddb-io/toon": ["@reddb-io/toon@0.29.6", "", { "bin": { "toon": "bin/toon.mjs" } }, "sha512-1zM8zRsiK8TTLB19RgPGXHeDMReX9o23C2K+KIjMacfghYhAjVDj6J6OXuJA/8DieKMTSKbewAmA2hkJykygHg=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -6209,8 +6283,14 @@ "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@reddb-io/multi-rpc/@reddb-io/toon": ["@reddb-io/toon@0.29.6", "", { "bin": { "toon": "bin/toon.mjs" } }, "sha512-1zM8zRsiK8TTLB19RgPGXHeDMReX9o23C2K+KIjMacfghYhAjVDj6J6OXuJA/8DieKMTSKbewAmA2hkJykygHg=="], + + "@reddb-io/toon-rpc/@reddb-io/toon": ["@reddb-io/toon@0.29.6", "", { "bin": { "toon": "bin/toon.mjs" } }, "sha512-1zM8zRsiK8TTLB19RgPGXHeDMReX9o23C2K+KIjMacfghYhAjVDj6J6OXuJA/8DieKMTSKbewAmA2hkJykygHg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "@scriptc/compiler/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], @@ -6561,6 +6641,8 @@ "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], + "opencode/@reddb-io/toon": ["@reddb-io/toon@0.29.6", "", { "bin": { "toon": "bin/toon.mjs" } }, "sha512-1zM8zRsiK8TTLB19RgPGXHeDMReX9o23C2K+KIjMacfghYhAjVDj6J6OXuJA/8DieKMTSKbewAmA2hkJykygHg=="], + "opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], diff --git a/bunfig.toml b/bunfig.toml index 04eac521d5b8..e244c3267bf4 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@deepseek-ai/cordis", "@deepseek-ai/cosmokit", "@reddb-io/toon", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@deepseek-ai/cordis", "@deepseek-ai/cosmokit", "@reddb-io/toon", "@reddb-io/toon-rpc", "@reddb-io/multi-rpc", "scriptc", "@scriptc/compiler", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 8527ba368f28..200453c4bb28 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "@types/node": "catalog:" }, "patchedDependencies": { + "@scriptc/runtime@0.0.35": "patches/@scriptc%2Fruntime@0.0.35.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index c6f4884a75b8..94b6b3c87814 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -263,7 +263,7 @@ export type SessionsListOutput = { }> } }> - readonly cursor: { readonly previous?: string | null; readonly next?: string | null } + readonly cursor: { readonly previous?: string; readonly next?: string } } export type SessionsCreateInput = { diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index 1f26403ca70b..62aa8d603214 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -43,10 +43,12 @@ function run(target) { }) } -const envPath = process.env.REDCODE_BIN_PATH || process.env.OPENCODE_BIN_PATH - const scriptPath = fs.realpathSync(__filename) const scriptDir = path.dirname(scriptPath) +const sidecar = path.basename(scriptPath).startsWith("redcode-rpc-sidecar") +const envPath = sidecar + ? process.env.REDCODE_RPC_SIDECAR_PATH + : process.env.REDCODE_BIN_PATH || process.env.OPENCODE_BIN_PATH // const cached = path.join(scriptDir, ".redcode") @@ -71,7 +73,13 @@ if (!arch) { arch = os.arch() } const base = "@reddb-io/redcode-" + platform + "-" + arch -const binary = platform === "windows" ? "redcode.exe" : "redcode" +const binary = sidecar + ? platform === "windows" + ? "redcode-rpc-sidecar.exe" + : "redcode-rpc-sidecar" + : platform === "windows" + ? "redcode.exe" + : "redcode" function supportsAvx2() { if (arch !== "x64") return false @@ -186,10 +194,10 @@ function findBinary(startDir) { } } -const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) +const resolved = envPath || (!sidecar && fs.existsSync(cached) ? cached : findBinary(scriptDir)) if (!resolved) { console.error( - "It seems that your package manager failed to install the right version of the Redcode CLI for your platform. You can try manually installing " + + `It seems that your package manager failed to install the right version of ${sidecar ? "the Redcode RPC sidecar" : "the Redcode CLI"} for your platform. You can try manually installing ` + names.map((n) => `\"${n}\"`).join(" or ") + " package", ) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 18bbef72ccfc..64228bf06ca1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -98,7 +98,7 @@ "@opencode-ai/server": "workspace:*", "@opencode-ai/tui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", - "@reddb-io/toon": "0.26.2", + "@reddb-io/toon": "0.29.6", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index dfb22029f59b..9ba91a7e40f6 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -26,6 +26,7 @@ const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") const skipUpload = process.argv.includes("--skip-upload") +const sidecarDir = process.env.REDCODE_RPC_SIDECAR_DIR const createEmbeddedWebUIBundle = async () => { console.log(`Building Web UI to embed in the binary`) @@ -219,6 +220,19 @@ for (const item of targets) { } await $`rm -rf ./dist/${name}/bin/tui` + if (sidecarDir) { + const sidecarKey = ["redcode-rpc-sidecar", item.os === "win32" ? "windows" : item.os, item.arch, item.abi] + .filter(Boolean) + .join("-") + const extension = item.os === "win32" ? ".exe" : "" + const source = path.resolve(sidecarDir, `${sidecarKey}${extension}`) + if (!(await Bun.file(source).exists())) throw new Error(`missing staged RPC sidecar: ${source}`) + const destination = `dist/${name}/bin/redcode-rpc-sidecar${extension}` + await Bun.write(destination, Bun.file(source)) + if (item.os !== "win32") await $`chmod 755 ${destination}` + } else if (Script.release) { + throw new Error("REDCODE_RPC_SIDECAR_DIR is required for release builds") + } await Bun.file(`dist/${name}/package.json`).write( JSON.stringify( { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index daa94f41237a..a01da5670f6a 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -44,6 +44,7 @@ const meta = `./dist/${product}-package` await rm(meta, { recursive: true, force: true }) await $`mkdir -p ${path.join(meta, "bin")}` await $`cp ./bin/opencode ${path.join(meta, "bin", product)}` +await $`cp ./bin/opencode ${path.join(meta, "bin", "redcode-rpc-sidecar")}` await Bun.file(path.join(meta, "LICENSE")).write(await Bun.file("../../LICENSE").text()) await Bun.file(path.join(meta, "NOTICE")).write(await Bun.file("../../NOTICE").text()) // The registry page is written here rather than copied from the repository README, @@ -60,7 +61,8 @@ npm install -g @reddb-io/redcode redcode \`\`\` -One native binary for Linux (glibc and musl), macOS, and Windows on x64 and arm64. +One native package for Linux (glibc and musl), macOS, and Windows on x64 and arm64. It includes the +\`redcode-rpc-sidecar\` companion for framed JSON/TOON RPC integrations. ## Use @@ -88,7 +90,7 @@ await Bun.file(path.join(meta, "package.json")).write( description: "RedDB's AI coding agent for the terminal", license: "MIT", repository: { type: "git", url: "https://github.com/reddb-io/redcode" }, - bin: { [product]: `./bin/${product}` }, + bin: { [product]: `./bin/${product}`, "redcode-rpc-sidecar": "./bin/redcode-rpc-sidecar" }, os: ["darwin", "linux", "win32"], cpu: ["arm64", "x64"], optionalDependencies: Object.fromEntries( diff --git a/packages/opencode/src/acp/toon-stream.ts b/packages/opencode/src/acp/toon-stream.ts new file mode 100644 index 000000000000..5cf99d8b5f16 --- /dev/null +++ b/packages/opencode/src/acp/toon-stream.ts @@ -0,0 +1,86 @@ +import { decode, encode } from "@reddb-io/toon" +import type { AnyMessage } from "@agentclientprotocol/sdk" + +export const AcpToonMaxFrameBytes = 1024 * 1024 + +export function toonStream(output: WritableStream, input: ReadableStream) { + const encoder = new TextEncoder() + const writer = output.getWriter() + const writable = new WritableStream({ + async write(message) { + const { jsonrpc: _jsonrpc, toonrpc: _toonrpc, ...rest } = message as unknown as Record + const frame = encoder.encode(`${encode({ toonrpc: "1.0", ...rest })}\n\n`) + if (frame.byteLength > AcpToonMaxFrameBytes) throw new Error("ACP TOON frame exceeds 1 MiB") + await writer.write(frame) + }, + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }) + + const reader = input.getReader() + const decoder = new TextDecoder("utf-8", { fatal: true }) + let buffer = "" + let resolveClosed: () => void + let rejectClosed: (error: unknown) => void + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve + rejectClosed = reject + }) + + const readable = new ReadableStream({ + start(controller) { + void pump(controller) + }, + cancel: (reason) => reader.cancel(reason), + }) + + async function pump(controller: ReadableStreamDefaultController) { + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) break + buffer += decoder.decode(chunk.value, { stream: true }) + drain(controller) + if (encoder.encode(buffer).byteLength > AcpToonMaxFrameBytes) throw new Error("ACP TOON frame exceeds 1 MiB") + } + buffer += decoder.decode() + drain(controller) + if (buffer.trim().length > 0) throw new Error("Incomplete ACP TOON frame") + controller.close() + resolveClosed() + } catch (error) { + await reader.cancel(error).catch(() => undefined) + rejectClosed(error) + controller.error(error) + } finally { + reader.releaseLock() + } + } + + function drain(controller: ReadableStreamDefaultController) { + while (true) { + const start = buffer.search(/[^\r\n]/) + if (start < 0) { + buffer = "" + return + } + buffer = buffer.slice(start) + const match = /\r?\n\r?\n/.exec(buffer) + if (!match) return + const frame = buffer.slice(0, match.index) + buffer = buffer.slice(match.index + match[0].length) + if (encoder.encode(frame).byteLength > AcpToonMaxFrameBytes) throw new Error("ACP TOON frame exceeds 1 MiB") + if (frame.trimStart().startsWith("{")) throw new Error("JSON frames are not accepted in ACP TOON mode") + + const value = decode(frame) + if (value === null || typeof value !== "object" || Array.isArray(value)) + throw new Error('Invalid ACP TOON frame: expected toonrpc "1.0"') + const message = value as Record + if (message.toonrpc !== "1.0") throw new Error('Invalid ACP TOON frame: expected toonrpc "1.0"') + const { toonrpc: _toonrpc, jsonrpc: _jsonrpc, ...rest } = message + controller.enqueue({ jsonrpc: "2.0", ...rest } as AnyMessage) + } + } + + return { writable, readable, closed } +} diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 38dea676cc03..19ce33f414ee 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -5,16 +5,24 @@ import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { ACPProfile } from "@/acp/profile" +import { toonStream } from "@/acp/toon-stream" +import { Readable, Writable } from "node:stream" export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { - return withNetworkOptions(yargs).option("cwd", { - describe: "working directory", - type: "string", - default: process.cwd(), - }) + return withNetworkOptions(yargs) + .option("cwd", { + describe: "working directory", + type: "string", + default: process.cwd(), + }) + .option("experimental-toon", { + describe: "use TOON-RPC framing for ACP stdio", + type: "boolean", + default: false, + }) }, handler: Effect.fn("Cli.acp")(function* (args) { const { Server } = yield* Effect.promise(() => import("@/server/server")) @@ -29,45 +37,32 @@ export const AcpCommand = effectCmd({ headers: ServerAuth.headers(), }) - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) - }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) - - const stream = ndJsonStream(input, output) + const input = Writable.toWeb(process.stdout) as WritableStream + const output = Readable.toWeb(process.stdin) as unknown as ReadableStream + const transport = args.experimentalToon + ? toonStream(input, output) + : { + ...ndJsonStream(input, output), + closed: new Promise((resolve, reject) => { + process.stdin.once("end", resolve) + process.stdin.once("close", resolve) + process.stdin.once("error", reject) + }), + } + const stream = transport const agent = ACP.init({ sdk }) - new AgentSideConnection((conn) => { + const connection = new AgentSideConnection((conn) => { ACPProfile.mark("cli.acp.connection.create") return agent.create(conn) }, stream) yield* Effect.logInfo("setup connection") process.stdin.resume() - yield* Effect.promise( - () => - new Promise((resolve, reject) => { - process.stdin.on("end", () => resolve()) - process.stdin.on("error", reject) - }), - ).pipe(Effect.ensuring(Effect.promise(() => server.stop(true)))) + const transportFailure = transport.closed.then(() => new Promise(() => undefined)) + const outputFailure = new Promise((_, reject) => process.stdout.once("error", reject)) + yield* Effect.promise(() => Promise.race([connection.closed, transportFailure, outputFailure])).pipe( + Effect.ensuring(Effect.promise(() => server.stop(true))), + ) }), }) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 12264ab74568..16859fcfead2 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" +import { RpcPath } from "@opencode-ai/protocol/rpc" export const ServeCommand = effectCmd({ command: "serve", @@ -18,6 +19,7 @@ export const ServeCommand = effectCmd({ const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`Redcode server listening on http://${server.hostname}:${server.port}`) + console.log(`Redcode RPC endpoint: http://${server.hostname}:${server.port}${RpcPath}`) yield* Effect.never }), diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index dc6f876af428..3a32b40e0314 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -72,6 +72,8 @@ import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { Api } from "@opencode-ai/server/api" +import { RpcApi } from "@opencode-ai/protocol/rpc" +import { RpcHandler } from "@opencode-ai/server/rpc" import { PublicApi } from "./public" import { authorizationLayer, @@ -181,6 +183,10 @@ const serverRoutes = HttpApiBuilder.layer(Api).pipe( Layer.provide(PluginPtyEnvironment.layer), Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]), ) +const rpcRoutes = HttpApiBuilder.layer(RpcApi).pipe( + Layer.provide(RpcHandler), + Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]), +) // `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so // processes that never serve it (CLI, scripts) don't pay at module load. @@ -281,6 +287,7 @@ export function createRoutes( ptyConnectApiRoutes, instanceRoutes, serverRoutes, + rpcRoutes, docRoute, uiRoute, ).pipe( diff --git a/packages/opencode/test/acp/toon-stream.test.ts b/packages/opencode/test/acp/toon-stream.test.ts new file mode 100644 index 000000000000..76c7d8a48ee1 --- /dev/null +++ b/packages/opencode/test/acp/toon-stream.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { decode, encode } from "@reddb-io/toon" +import { AcpToonMaxFrameBytes, toonStream } from "../../src/acp/toon-stream" + +describe("ACP TOON stream", () => { + test("decodes TOON requests and encodes TOON responses", async () => { + const request = encode({ toonrpc: "1.0", method: "initialize", params: {}, id: 1 }) + "\n\n" + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(request)) + controller.close() + }, + }) + const output = new TransformStream() + const stream = toonStream(output.writable, input) + + const incoming = await stream.readable.getReader().read() + expect(incoming.value).toEqual({ jsonrpc: "2.0", method: "initialize", params: {}, id: 1 }) + await stream.closed + + const read = output.readable.getReader().read() + const writer = stream.writable.getWriter() + await writer.write({ jsonrpc: "2.0", result: { ok: true }, id: 1 }) + const frame = await read + expect(decode(new TextDecoder().decode(frame.value))).toEqual({ + toonrpc: "1.0", + result: { ok: true }, + id: 1, + }) + }) + + test("rejects JSON frames and cancels the underlying input", async () => { + let cancelled = false + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0","method":"initialize","id":1}\n\n')) + }, + cancel() { + cancelled = true + }, + }) + const stream = toonStream(new WritableStream(), input) + const closed = expect(stream.closed).rejects.toThrow("JSON frames are not accepted") + + await expect(stream.readable.getReader().read()).rejects.toThrow("JSON frames are not accepted") + await closed + expect(cancelled).toBe(true) + }) + + test("rejects oversized frames and cancels the underlying input", async () => { + let cancelled = false + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(AcpToonMaxFrameBytes + 1).fill(97)) + }, + cancel() { + cancelled = true + }, + }) + const stream = toonStream(new WritableStream(), input) + const closed = expect(stream.closed).rejects.toThrow("exceeds 1 MiB") + + await expect(stream.readable.getReader().read()).rejects.toThrow("exceeds 1 MiB") + await closed + expect(cancelled).toBe(true) + }) +}) diff --git a/packages/opencode/test/cli/acp/helpers.ts b/packages/opencode/test/cli/acp/helpers.ts index 8ef7d0abf102..ad429efdfe4a 100644 --- a/packages/opencode/test/cli/acp/helpers.ts +++ b/packages/opencode/test/cli/acp/helpers.ts @@ -11,9 +11,15 @@ import { type AcpClient, } from "./acp-test-client" -export function createAcpClient(input: Pick, env?: Record) { +export function createAcpClient( + input: Pick, + env?: Record, + options?: { experimentalToon?: boolean }, +) { return Effect.gen(function* () { - return createJsonRpcAcpClient(yield* input.opencode.acp(env ? { env } : undefined)) + return createJsonRpcAcpClient( + yield* input.opencode.acp({ ...(env ? { env } : {}), experimentalToon: options?.experimentalToon }), + ) }) } diff --git a/packages/opencode/test/cli/acp/initialize-auth.test.ts b/packages/opencode/test/cli/acp/initialize-auth.test.ts index f9382a055eac..a7d200b8f1d5 100644 --- a/packages/opencode/test/cli/acp/initialize-auth.test.ts +++ b/packages/opencode/test/cli/acp/initialize-auth.test.ts @@ -58,6 +58,30 @@ describe("redcode acp initialize/auth subprocess", () => { 60_000, ) + cliIt.live( + "initialize responds over opt-in TOON-RPC", + ({ opencode }) => + Effect.gen(function* () { + const initialized = yield* initialize( + yield* createAcpClient({ opencode }, undefined, { experimentalToon: true }), + ) + expect(initialized.protocolVersion).toBe(1) + expect(initialized.agentInfo?.name).toBe("Redcode") + }), + 60_000, + ) + + cliIt.live( + "exits on malformed TOON-RPC input", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* opencode.acp({ experimentalToon: true }) + yield* acp.sendRaw('{"jsonrpc":"2.0","method":"initialize","id":1}\n\n') + expect(yield* Effect.promise(() => acp.exited)).toBe(1) + }), + 60_000, + ) + cliIt.live( "initialize without terminal-auth metadata keeps auth command implicit", ({ opencode }) => diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 47a76aac1a58..fee2647f1857 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -6,19 +6,20 @@ exports[`opencode CLI help-text snapshots every documented command emits stable start ACP (Agent Client Protocol) server Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --port port to listen on [number] [default: 0] - --hostname hostname to listen on [string] [default: "127.0.0.1"] - --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --port port to listen on [number] [default: 0] + --hostname hostname to listen on [string] [default: "127.0.0.1"] + --mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false] - --mdns-domain custom domain name for mDNS service (default: opencode.local) + --mdns-domain custom domain name for mDNS service (default: opencode.local) [string] [default: "opencode.local"] - --cors additional domains to allow for CORS [array] [default: []] - --cwd working directory [string] [default: ""]" + --cors additional domains to allow for CORS [array] [default: []] + --cwd working directory [string] [default: ""] + --experimental-toon use TOON-RPC framing for ACP stdio [boolean] [default: false]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp --help 1`] = ` diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 66d38968d7cb..120b06c09eb5 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -22,6 +22,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppProcess } from "@opencode-ai/core/process" +import { decode, encode } from "@reddb-io/toon" import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" @@ -131,18 +132,20 @@ export type ServeHandle = { readonly exited: Promise } -// `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is +// `opencode acp` speaks newline-delimited JSON-RPC by default and opt-in TOON-RPC. It is // long-lived and exits cleanly when stdin is closed. The handle exposes the // duplex stream as send/receive rather than raw pipes so tests don't have to // reimplement framing on every call site. export type AcpOpts = SpawnOpts & { readonly cwd?: string readonly extraArgs?: string[] + readonly experimentalToon?: boolean } export type AcpHandle = { // Writes a single JSON-RPC message to the child's stdin as one ndjson line. readonly send: (msg: object) => Effect.Effect + readonly sendRaw: (frame: string) => Effect.Effect // Resolves with the next parsed JSON-RPC line from the child's stdout. // Lines are buffered in a queue so multiple receives in a row won't drop // anything. Pair with `Effect.timeout` if a test wants a deadline. @@ -395,6 +398,7 @@ export function withCliFixture( const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) { const argv = ["acp"] if (opts?.cwd) argv.push("--cwd", opts.cwd) + if (opts?.experimentalToon) argv.push("--experimental-toon") if (opts?.extraArgs) argv.push(...opts.extraArgs) // Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits @@ -432,23 +436,18 @@ export function withCliFixture( const stderrChunks: string[] = [] yield* forkStderrDrain(proc.stderr, stderrChunks) - // Each ndjson line becomes one queue entry. JSON.parse failures are - // surfaced as the raw string so a malformed protocol message doesn't - // silently wedge the test in `receive`. const responses = yield* Queue.unbounded() + const responseState = { buffer: "" } yield* Effect.forkScoped( fromBunStream("stdout", () => proc.stdout).pipe( Stream.decodeText(), - Stream.splitLines, - Stream.runForEach((line) => { - if (line.length === 0) return Effect.void - let parsed: unknown - try { - parsed = JSON.parse(line) - } catch { - parsed = { _rawLine: line } - } - return Queue.offer(responses, parsed) + Stream.runForEach((chunk) => { + responseState.buffer += chunk + return Effect.forEach( + drainAcpFrames(responseState, opts?.experimentalToon === true), + (frame) => Queue.offer(responses, decodeAcpFrame(frame, opts?.experimentalToon === true)), + { discard: true }, + ) }), Effect.ignore({ log: true }), ), @@ -461,7 +460,16 @@ export function withCliFixture( // and corrupt the ndjson framing. send: (msg: object) => Effect.promise(async () => { - const ret = proc.stdin.write(JSON.stringify(msg) + "\n") + const ret = proc.stdin.write( + opts?.experimentalToon + ? `${encode({ toonrpc: "1.0", ...stripJsonRpc(msg) })}\n\n` + : `${JSON.stringify(msg)}\n`, + ) + if (typeof ret !== "number") await ret + }), + sendRaw: (frame: string) => + Effect.promise(async () => { + const ret = proc.stdin.write(frame) if (typeof ret !== "number") await ret }), receive: Queue.take(responses), @@ -487,6 +495,45 @@ export function withCliFixture( ) } +function drainAcpFrames(state: { buffer: string }, toon: boolean) { + const frames: string[] = [] + const delimiter = toon ? /\r?\n\r?\n/ : /\r?\n/ + for (;;) { + const match = delimiter.exec(state.buffer) + if (!match) return frames + frames.push(state.buffer.slice(0, match.index)) + state.buffer = state.buffer.slice(match.index + match[0].length) + } +} + +function decodeAcpFrame(frame: string, toon: boolean) { + if (!toon) { + try { + return JSON.parse(frame) as unknown + } catch { + return { _rawFrame: frame } + } + } + const input: unknown = (() => { + try { + return decode(frame) + } catch { + return undefined + } + })() + if (!input || typeof input !== "object" || Array.isArray(input) || !("toonrpc" in input) || input.toonrpc !== "1.0") { + return { _rawFrame: frame } + } + return { + jsonrpc: "2.0", + ...Object.fromEntries(Object.entries(input).filter(([key]) => key !== "toonrpc")), + } +} + +function stripJsonRpc(input: object) { + return Object.fromEntries(Object.entries(input).filter(([key]) => key !== "jsonrpc")) +} + function parseJsonEvents(stdout: string): Array> { return stdout .split("\n") diff --git a/packages/opencode/test/server/httpapi-rpc.test.ts b/packages/opencode/test/server/httpapi-rpc.test.ts new file mode 100644 index 000000000000..2f0c9bbe4a97 --- /dev/null +++ b/packages/opencode/test/server/httpapi-rpc.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { decode, encode } from "@reddb-io/toon" +import { Context } from "effect" +import { RpcMaxBodyBytes } from "@opencode-ai/protocol/rpc" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +const context = Context.empty() as Context.Context + +function http(directory: string, path: string, init?: RequestInit) { + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { + ...init?.headers, + "x-opencode-directory": directory, + }, + }), + context, + ) +} + +function request(directory: string, body: string, contentType: string) { + return http(directory, "/rpc", { + method: "POST", + headers: { "content-type": contentType }, + body, + }) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("JSON and TOON RPC", () => { + test("answers JSON-RPC health requests", async () => { + await using dir = await tmpdir() + const response = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "health.get", params: {}, id: 1 }), + "application/json", + ) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("application/json") + expect(await response.json()).toEqual({ jsonrpc: "2.0", result: { healthy: true }, id: 1 }) + }) + + test("answers TOON-RPC health requests in TOON", async () => { + await using dir = await tmpdir() + const response = await request( + dir.path, + encode({ toonrpc: "1.0", method: "health.get", params: {}, id: 2 }), + "application/toon", + ) + + expect(response.status).toBe(200) + expect(response.headers.get("content-type")).toContain("application/toon") + expect(decode(await response.text())).toEqual({ toonrpc: "1.0", result: { healthy: true }, id: 2 }) + }) + + test("rejects non-empty params for no-parameter methods", async () => { + await using dir = await tmpdir() + const response = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "health.get", params: { ignored: true }, id: 3 }), + "application/json", + ) + + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32602, message: "Invalid params" }, + id: 3, + }) + }) + + test("matches REST session reads and rejects invalid cursors", async () => { + await using dir = await tmpdir() + const created = await http(dir.path, "/api/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ location: { directory: dir.path } }), + }) + expect(created.status).toBe(200) + + const restList = await http(dir.path, `/api/session?directory=${encodeURIComponent(dir.path)}`) + const rpcList = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "session.list", params: { directory: dir.path }, id: 4 }), + "application/json", + ) + expect((await rpcList.json()).result).toEqual(await restList.json()) + + const restActive = await http(dir.path, "/api/session/active") + const rpcActive = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "session.active", params: {}, id: 5 }), + "application/json", + ) + expect((await rpcActive.json()).result).toEqual(await restActive.json()) + + const invalid = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "session.list", params: { cursor: "invalid" }, id: 6 }), + "application/json", + ) + expect(await invalid.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32602, message: "Invalid params" }, + id: 6, + }) + }) + + test("does not answer notifications", async () => { + await using dir = await tmpdir() + const response = await request( + dir.path, + JSON.stringify({ jsonrpc: "2.0", method: "health.get", params: {} }), + "application/json", + ) + expect(response.status).toBe(204) + expect(await response.text()).toBe("") + }) + + test("rejects batches", async () => { + await using dir = await tmpdir() + const response = await request( + dir.path, + JSON.stringify([{ jsonrpc: "2.0", method: "health.get", id: 4 }]), + "application/json", + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32600, message: "Invalid Request: batches are not supported" }, + id: null, + }) + }) + + test("rejects unsupported content types", async () => { + await using dir = await tmpdir() + const response = await request(dir.path, "{}", "text/plain") + expect(response.status).toBe(415) + }) + + test("rejects request bodies larger than 1 MiB", async () => { + await using dir = await tmpdir() + const response = await request(dir.path, " ".repeat(RpcMaxBodyBytes + 1), "application/json") + expect(response.status).toBe(413) + }) +}) diff --git a/packages/opencode/test/server/rpc-sidecar-integration.test.ts b/packages/opencode/test/server/rpc-sidecar-integration.test.ts new file mode 100644 index 000000000000..2b2a3b53ffed --- /dev/null +++ b/packages/opencode/test/server/rpc-sidecar-integration.test.ts @@ -0,0 +1,41 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import path from "node:path" +import { cliIt } from "../lib/cli-process" + +const sidecar = path.resolve(import.meta.dir, "../../../rpc-sidecar/src/cli.ts") + +cliIt.live( + "RPC sidecar authenticates to the real server", + ({ opencode }) => + Effect.gen(function* () { + const server = yield* opencode.serve({ env: { OPENCODE_SERVER_PASSWORD: "secret" } }) + const body = JSON.stringify({ jsonrpc: "2.0", method: "health.get", params: {}, id: 1 }) + const proc = Bun.spawn(["bun", sidecar], { + env: { + ...process.env, + OPENCODE_SERVER_PASSWORD: "secret", + REDCODE_AUTHORIZATION: "", + REDCODE_RPC_URL: `${server.url}/rpc`, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + proc.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`) + proc.stdin.end() + + const [exitCode, stdout, stderr] = yield* Effect.promise(() => + Promise.all([proc.exited, new Response(proc.stdout).bytes(), new Response(proc.stderr).text()]), + ) + expect(exitCode, stderr).toBe(0) + const boundary = Buffer.from(stdout).indexOf("\r\n\r\n") + expect(boundary).toBeGreaterThan(0) + expect(JSON.parse(new TextDecoder().decode(stdout.slice(boundary + 4)))).toEqual({ + jsonrpc: "2.0", + result: { healthy: true }, + id: 1, + }) + }), + 60_000, +) diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 18618164f04e..d1f1b4cb030c 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -1,9 +1,11 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +export const HealthResponse = Schema.Struct({ healthy: Schema.Literal(true) }) + export const HealthGroup = HttpApiGroup.make("server.health").add( HttpApiEndpoint.get("health.get", "/api/health", { - success: Schema.Struct({ healthy: Schema.Literal(true) }), + success: HealthResponse, }).annotateMerge( OpenApi.annotations({ identifier: "v2.health.get", diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 72804ddee761..ca21f1e61089 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -3,7 +3,7 @@ import { SessionInput } from "@opencode-ai/schema/session-input" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" -import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" +import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, optional, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" @@ -80,10 +80,35 @@ export const SessionsCursor = Schema.String.pipe( ) export type SessionsCursor = typeof SessionsCursor.Type -const SessionActive = Schema.Struct({ +export const DefaultSessionsLimit = 50 + +export const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) +export const SessionListInput = Schema.Struct({ + workspace: Workspace.ID.pipe(Schema.optional), + limit: PositiveInt.pipe(Schema.optional), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])), + search: Schema.optional(Schema.String), + directory: AbsolutePath.pipe(Schema.optional), + project: Project.ID.pipe(Schema.optional), + subpath: RelativePath.pipe(Schema.optional), + cursor: SessionsCursor.pipe(Schema.optional), +}).annotate({ identifier: "SessionListInput" }) + +export const SessionsResponse = Schema.Struct({ + data: Schema.Array(Session.Info), + cursor: Schema.Struct({ + previous: optional(SessionsCursor), + next: optional(SessionsCursor), + }), +}).annotate({ identifier: "SessionsResponse" }) + +export const ActiveSessionsResponse = Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }).annotate({ + identifier: "ActiveSessionsResponse", +}) + const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) export const SessionHistoryQuery = Schema.Struct({ @@ -108,13 +133,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.list", "/api/session", { query: SessionsQuery, - success: Schema.Struct({ - data: Schema.Array(Session.Info), - cursor: Schema.Struct({ - previous: SessionsCursor.pipe(Schema.optional), - next: SessionsCursor.pipe(Schema.optional), - }), - }).annotate({ identifier: "SessionsResponse" }), + success: SessionsResponse, error: [InvalidCursorError, InvalidRequestError], }).annotateMerge( OpenApi.annotations({ @@ -144,7 +163,7 @@ export const makeSessionGroup = (sessionLo ) .add( HttpApiEndpoint.get("session.active", "/api/session/active", { - success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }), + success: ActiveSessionsResponse, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.active", diff --git a/packages/protocol/src/rpc.ts b/packages/protocol/src/rpc.ts new file mode 100644 index 000000000000..bc104b7b53af --- /dev/null +++ b/packages/protocol/src/rpc.ts @@ -0,0 +1,27 @@ +import { Schema } from "effect" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { ActiveSessionsResponse, SessionListInput, SessionsResponse } from "./groups/session" +import { HealthResponse } from "./groups/health" +import { Authorization } from "./middleware/authorization" + +export const RpcPath = "/rpc" +export const RpcMaxBodyBytes = 1024 * 1024 +export const RpcContentTypes = ["application/json", "application/toon"] as const +export const RpcSupportsBatch = false +export const RpcNoParams = Schema.Union([Schema.Tuple([]), Schema.Struct({})]) + +export const RpcMethods = { + healthGet: { name: "health.get", params: RpcNoParams, result: HealthResponse }, + sessionList: { name: "session.list", params: SessionListInput, result: SessionsResponse }, + sessionActive: { name: "session.active", params: RpcNoParams, result: ActiveSessionsResponse }, +} as const + +export const RpcGroup = HttpApiGroup.make("rpc") + .add( + HttpApiEndpoint.post("handle", RpcPath, { + success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })), + }), + ) + .middleware(Authorization) + +export const RpcApi = HttpApi.make("rpc").add(RpcGroup) diff --git a/packages/rpc-sidecar/README.md b/packages/rpc-sidecar/README.md new file mode 100644 index 000000000000..f0273349660d --- /dev/null +++ b/packages/rpc-sidecar/README.md @@ -0,0 +1,7 @@ +# Redcode RPC sidecar + +`redcode-rpc-sidecar` bridges `Content-Length` framed stdin/stdout messages to a Redcode `POST /rpc` endpoint. + +Set `REDCODE_RPC_URL` to the exact endpoint URL. Authentication uses `REDCODE_AUTHORIZATION` when present, otherwise HTTP Basic credentials from `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD`. + +Frames use `Content-Length: \r\n\r\n`. Headers are limited to 8 KiB and bodies to 1 MiB. Requests are processed sequentially, redirects are not followed, and responses use the same framing. diff --git a/packages/rpc-sidecar/package.json b/packages/rpc-sidecar/package.json new file mode 100644 index 000000000000..b2a6f178d12b --- /dev/null +++ b/packages/rpc-sidecar/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/rpc-sidecar", + "version": "0.12.0", + "private": true, + "type": "module", + "scripts": { + "build": "bun script/build.ts", + "test": "bun test", + "test:native": "bun script/test-native.ts", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + "scriptc": "0.0.35" + } +} diff --git a/packages/rpc-sidecar/script/build.ts b/packages/rpc-sidecar/script/build.ts new file mode 100644 index 000000000000..4ea25e56218b --- /dev/null +++ b/packages/rpc-sidecar/script/build.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import path from "node:path" + +const extension = process.platform === "win32" ? ".exe" : "" +const output = path.resolve( + import.meta.dir, + "..", + process.env.REDCODE_RPC_SIDECAR_OUTPUT ?? `dist/redcode-rpc-sidecar${extension}`, +) +if (process.env.REDCODE_RPC_SIDECAR_TARGET) { + process.env.SCRIPTC_TARGET = process.env.REDCODE_RPC_SIDECAR_TARGET + process.env.SCRIPTC_CC = "zigcc" +} +await $`mkdir -p ${path.dirname(output)}` +await $`node ${path.resolve(import.meta.dir, "../node_modules/scriptc/dist/bootstrap.js")} build ${path.resolve(import.meta.dir, "../src/cli.ts")} --out ${output} --no-keep-c` +if (process.platform !== "win32" && !output.endsWith(".exe")) await $`chmod 755 ${output}` + +console.log(output) diff --git a/packages/rpc-sidecar/script/test-native.ts b/packages/rpc-sidecar/script/test-native.ts new file mode 100644 index 000000000000..3bc2dfb8fd36 --- /dev/null +++ b/packages/rpc-sidecar/script/test-native.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env bun + +import path from "node:path" + +const root = path.resolve(import.meta.dir, "..") +const extension = process.platform === "win32" ? ".exe" : "" +const output = path.join(root, `dist/redcode-rpc-sidecar${extension}`) +const build = Bun.spawn(["bun", "run", "build"], { cwd: root, stdout: "inherit", stderr: "inherit" }) +if ((await build.exited) !== 0) process.exit(1) + +const test = Bun.spawn(["bun", "test", "test/sidecar.test.ts"], { + cwd: root, + env: { ...process.env, REDCODE_RPC_SIDECAR_COMMAND: output }, + stdout: "inherit", + stderr: "inherit", +}) +process.exit(await test.exited) diff --git a/packages/rpc-sidecar/src/cli.ts b/packages/rpc-sidecar/src/cli.ts new file mode 100644 index 000000000000..04d658b0cb21 --- /dev/null +++ b/packages/rpc-sidecar/src/cli.ts @@ -0,0 +1,6 @@ +import { main } from "./main" + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +}) diff --git a/packages/rpc-sidecar/src/main.ts b/packages/rpc-sidecar/src/main.ts new file mode 100644 index 000000000000..2079d1992edc --- /dev/null +++ b/packages/rpc-sidecar/src/main.ts @@ -0,0 +1,152 @@ +export const MaxHeaderBytes = 8 * 1024 +export const MaxBodyBytes = 1024 * 1024 + +export async function main() { + process.on("SIGINT", () => process.exit(130)) + process.on("SIGTERM", () => process.exit(143)) + + const url = rpcUrl(process.env.REDCODE_RPC_URL) + const parser = frameParser() + for await (const chunk of process.stdin) { + const bodies = parser.push(chunk) + for (const body of bodies) { + const response = await post(url, body) + if (response.byteLength > 0) await write(response) + } + } + parser.finish() +} + +export function rpcUrl(input: string | undefined) { + if (!input) throw new Error("REDCODE_RPC_URL is required") + const authority = /^https?:\/\/([^/?#]*)/i.exec(input)?.[1] + if (authority?.includes("@")) throw new Error("REDCODE_RPC_URL must not contain credentials") + if (input.includes("#")) throw new Error("REDCODE_RPC_URL must point exactly to /rpc") + const url = new URL(input) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("REDCODE_RPC_URL must use HTTP or HTTPS") + if (url.pathname !== "/rpc" || url.search) throw new Error("REDCODE_RPC_URL must point exactly to /rpc") + return url +} + +async function post(url: URL, body: Uint8Array) { + const headers: Record = { + "content-type": dialect(body), + accept: dialect(body), + } + const authorization = "REDCODE_AUTHORIZATION" in process.env ? process.env.REDCODE_AUTHORIZATION : undefined + const password = "OPENCODE_SERVER_PASSWORD" in process.env ? process.env.OPENCODE_SERVER_PASSWORD : undefined + if (authorization) headers.authorization = authorization + if (!authorization && password) { + const username = + ("OPENCODE_SERVER_USERNAME" in process.env ? process.env.OPENCODE_SERVER_USERNAME : undefined) ?? "opencode" + headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + } + + const response = await fetch(url, { + method: "POST", + headers, + body: new Uint8Array(body), + redirect: "manual", + }) + if (response.status >= 300 && response.status < 400) + throw new Error(`RPC endpoint attempted redirect (${response.status})`) + if (!response.ok) throw new Error(`RPC endpoint returned HTTP ${response.status}`) + + const headerLength = response.headers.get("content-length") + const length = headerLength === null ? NaN : parseInt(headerLength, 10) + if (Number.isFinite(length) && length > MaxBodyBytes) throw new Error("RPC response exceeds 1 MiB") + if (!response.body) return new Uint8Array() + + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const chunk = await reader.read() + if (chunk.done) break + size += chunk.value.byteLength + if (size > MaxBodyBytes) { + await reader.cancel() + throw new Error("RPC response exceeds 1 MiB") + } + chunks.push(chunk.value) + } + return join(chunks, size) +} + +function frameParser() { + let buffer = new Uint8Array() + let length: number | undefined + + return { + push(chunk: Uint8Array) { + const bodies: Uint8Array[] = [] + buffer = join([buffer, chunk], buffer.byteLength + chunk.byteLength) + while (true) { + if (length === undefined) { + const boundary = headerBoundary(buffer) + if (boundary < 0) { + if (buffer.byteLength > MaxHeaderBytes) throw new Error("RPC frame header exceeds 8 KiB") + break + } + if (boundary > MaxHeaderBytes) throw new Error("RPC frame header exceeds 8 KiB") + length = contentLength(new TextDecoder().decode(buffer.slice(0, boundary))) + buffer = buffer.slice(boundary + 4) + } + if (buffer.byteLength < length) break + bodies.push(buffer.slice(0, length)) + buffer = buffer.slice(length) + length = undefined + } + return bodies + }, + finish() { + if (length !== undefined || buffer.byteLength > 0) throw new Error("Incomplete RPC frame") + }, + } +} + +function contentLength(header: string) { + const values = header + .split("\r\n") + .map((line) => line.match(/^content-length\s*:\s*(\d+)\s*$/i)?.[1]) + .flatMap((value) => (value === undefined ? [] : [value])) + if (values.length !== 1) throw new Error("RPC frame requires one Content-Length header") + const length = parseInt(values[0], 10) + if (!Number.isSafeInteger(length) || length < 0) throw new Error("Invalid RPC frame Content-Length") + if (length > MaxBodyBytes) throw new Error("RPC frame body exceeds 1 MiB") + return length +} + +function headerBoundary(buffer: Uint8Array) { + for (let index = 0; index <= buffer.byteLength - 4; index++) { + if (buffer[index] === 13 && buffer[index + 1] === 10 && buffer[index + 2] === 13 && buffer[index + 3] === 10) + return index + } + return -1 +} + +function dialect(body: Uint8Array) { + const first = new TextDecoder().decode(body).trimStart()[0] + return first === "{" || first === "[" ? "application/json" : "application/toon" +} + +function join(chunks: Uint8Array[], size: number) { + const output = new Uint8Array(size) + let offset = 0 + chunks.forEach((chunk) => { + output.set(chunk, offset) + offset += chunk.byteLength + }) + return output +} + +function write(body: Uint8Array) { + const header = new TextEncoder().encode(`Content-Length: ${body.byteLength}\r\n\r\n`) + const frame = join([header, body], header.byteLength + body.byteLength) + return new Promise((resolve, reject) => { + process.stdout.write(frame, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} diff --git a/packages/rpc-sidecar/test/sidecar.test.ts b/packages/rpc-sidecar/test/sidecar.test.ts new file mode 100644 index 000000000000..00b42c360714 --- /dev/null +++ b/packages/rpc-sidecar/test/sidecar.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import { MaxBodyBytes, MaxHeaderBytes } from "../src/main" +import path from "node:path" + +const root = path.resolve(import.meta.dir, "..") +const command = process.env.REDCODE_RPC_SIDECAR_COMMAND + ? [process.env.REDCODE_RPC_SIDECAR_COMMAND] + : ["bun", path.join(root, "src/cli.ts")] + +function frame(body: string) { + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}` +} + +function frames(value: Uint8Array) { + const output: string[] = [] + let offset = 0 + while (offset < value.byteLength) { + const boundary = Buffer.from(value).indexOf("\r\n\r\n", offset) + if (boundary < 0) throw new Error("missing frame boundary") + const header = new TextDecoder().decode(value.slice(offset, boundary)) + const length = parseInt(header.match(/^Content-Length: (\d+)$/i)?.[1] ?? "", 10) + const start = boundary + 4 + output.push(new TextDecoder().decode(value.slice(start, start + length))) + offset = start + length + } + return output +} + +async function execute(url: string, input: string, env: Record = {}) { + const proc = Bun.spawn(command, { + cwd: root, + env: { ...process.env, ...env, REDCODE_RPC_URL: url }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }) + const write = proc.stdin.write(input) + if (typeof write !== "number") await write + proc.stdin.end() + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).bytes(), + new Response(proc.stderr).text(), + ]) + return { exitCode, stdout, stderr } +} + +function server(handler: (request: Request) => Response | Promise) { + const instance = Bun.serve({ port: 0, fetch: handler }) + return { + url: new URL("/rpc", instance.url).href, + [Symbol.dispose]: () => instance.stop(true), + } +} + +describe(`RPC sidecar (${command[0].endsWith("cli.ts") ? "source" : "native"})`, () => { + test("forwards JSON with explicit authorization", async () => { + let received: { contentType: string | null; authorization: string | null; body: string } | undefined + using endpoint = server(async (request) => { + received = { + contentType: request.headers.get("content-type"), + authorization: request.headers.get("authorization"), + body: await request.text(), + } + return Response.json({ jsonrpc: "2.0", result: { healthy: true }, id: 1 }) + }) + const body = JSON.stringify({ jsonrpc: "2.0", method: "health.get", id: 1 }) + const result = await execute(endpoint.url, frame(body), { REDCODE_AUTHORIZATION: "Bearer test" }) + + expect(result.exitCode).toBe(0) + expect(received).toEqual({ contentType: "application/json", authorization: "Bearer test", body }) + expect(JSON.parse(frames(result.stdout)[0])).toEqual({ jsonrpc: "2.0", result: { healthy: true }, id: 1 }) + }) + + test("forwards TOON with basic authorization", async () => { + const received: { contentType: string | null; authorization: string | null } = { + contentType: null, + authorization: null, + } + using endpoint = server((request) => { + received.contentType = request.headers.get("content-type") + received.authorization = request.headers.get("authorization") + return new Response('toonrpc: "1.0"\nresult: true\nid: 2', { + headers: { "content-type": "application/toon" }, + }) + }) + const result = await execute(endpoint.url, frame('toonrpc: "1.0"\nmethod: health.get\nid: 2'), { + REDCODE_AUTHORIZATION: "", + OPENCODE_SERVER_USERNAME: "alice", + OPENCODE_SERVER_PASSWORD: "secret", + }) + + expect(result.exitCode).toBe(0) + expect(received.contentType).toBe("application/toon") + expect(received.authorization).toBe(`Basic ${Buffer.from("alice:secret").toString("base64")}`) + expect(frames(result.stdout)).toEqual(['toonrpc: "1.0"\nresult: true\nid: 2']) + }) + + test("processes frames sequentially and preserves order", async () => { + let active = 0 + let maximum = 0 + using endpoint = server(async (request) => { + active++ + maximum = Math.max(maximum, active) + const requestBody = (await request.json()) as { id: number } + await Bun.sleep(requestBody.id === 1 ? 20 : 0) + active-- + return Response.json({ jsonrpc: "2.0", result: requestBody.id, id: requestBody.id }) + }) + const first = JSON.stringify({ jsonrpc: "2.0", method: "health.get", id: 1 }) + const second = JSON.stringify({ jsonrpc: "2.0", method: "health.get", id: 2 }) + const result = await execute(endpoint.url, frame(first) + frame(second)) + + expect(result.exitCode).toBe(0) + expect(maximum).toBe(1) + expect(frames(result.stdout).map((body) => JSON.parse(body).id)).toEqual([1, 2]) + }) + + test("does not emit a frame for notifications", async () => { + using endpoint = server(() => new Response(null, { status: 204 })) + const body = JSON.stringify({ jsonrpc: "2.0", method: "health.get" }) + const result = await execute(endpoint.url, frame(body)) + + expect(result.exitCode).toBe(0) + expect(result.stdout.byteLength).toBe(0) + }) + + test("rejects oversized headers and bodies", async () => { + using endpoint = server(() => new Response("unexpected")) + const header = await execute(endpoint.url, `X-Test: ${"a".repeat(MaxHeaderBytes)}\r\n`) + const body = await execute(endpoint.url, `Content-Length: ${MaxBodyBytes + 1}\r\n\r\n`) + + expect(header.exitCode).toBe(1) + expect(header.stderr).toContain("header exceeds 8 KiB") + expect(body.exitCode).toBe(1) + expect(body.stderr).toContain("body exceeds 1 MiB") + }) + + test("does not follow redirects", async () => { + let redirected = false + using endpoint = server((request) => { + if (new URL(request.url).pathname === "/redirected") { + redirected = true + return new Response("unexpected") + } + return new Response(null, { status: 302, headers: { location: "/redirected" } }) + }) + const body = JSON.stringify({ jsonrpc: "2.0", method: "health.get", id: 1 }) + const result = await execute(endpoint.url, frame(body)) + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("attempted redirect") + expect(redirected).toBe(false) + }) + + test("requires an exact credential-free RPC URL", async () => { + const result = await execute("http://user:pass@127.0.0.1/rpc?x=1", "") + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("must not contain credentials") + }) + + test("exits 143 on SIGTERM", async () => { + const received = Promise.withResolvers() + using endpoint = server(() => { + received.resolve() + return new Promise(() => undefined) + }) + const proc = Bun.spawn(command, { + cwd: root, + env: { ...process.env, REDCODE_RPC_URL: endpoint.url }, + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + const body = JSON.stringify({ jsonrpc: "2.0", method: "health.get", id: 1 }) + proc.stdin.write(frame(body)) + await received.promise + proc.kill("SIGTERM") + expect(await proc.exited).toBe(143) + }) +}) diff --git a/packages/rpc-sidecar/tsconfig.json b/packages/rpc-sidecar/tsconfig.json new file mode 100644 index 000000000000..391504782061 --- /dev/null +++ b/packages/rpc-sidecar/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + }, + "include": ["src", "script", "test"] +} diff --git a/packages/server/package.json b/packages/server/package.json index 83eca9036b2a..d473cebb9bad 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -14,6 +14,9 @@ "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", + "@reddb-io/multi-rpc": "0.29.6", + "@reddb-io/toon": "0.29.6", + "@reddb-io/toon-rpc": "0.29.6", "drizzle-orm": "catalog:", "effect": "catalog:" }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 485cd69f8c6d..c65d89c972a5 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,20 +1,18 @@ import { SessionV2 } from "@opencode-ai/core/session" -import { DateTime, Effect, Stream } from "effect" +import { Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" -import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, InvalidRequestError, - InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, SessionNotFoundError, UnknownError, } from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" +import { activeSessions, listSessions } from "../session-read" -const DefaultSessionsLimit = 50 const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => @@ -24,46 +22,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return handlers .handle( "session.list", - Effect.fn(function* (ctx) { - const query = - ctx.query.cursor !== undefined - ? yield* SessionsCursor.parse(ctx.query.cursor).pipe( - Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), - ) - : ctx.query - const sessions = yield* session.list({ - ...query, - workspaceID: query.workspace, - limit: ctx.query.limit ?? DefaultSessionsLimit, - }) - const first = sessions[0] - const last = sessions.at(-1) - return { - data: sessions, - cursor: { - previous: first - ? SessionsCursor.make({ - ...query, - anchor: { - id: first.id, - time: DateTime.toEpochMillis(first.time.created), - direction: "previous", - }, - }) - : undefined, - next: last - ? SessionsCursor.make({ - ...query, - anchor: { - id: last.id, - time: DateTime.toEpochMillis(last.time.created), - direction: "next", - }, - }) - : undefined, - }, - } - }), + Effect.fn((ctx) => listSessions(session, ctx.query)), ) .handle( "session.create", @@ -80,13 +39,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ) .handle( "session.active", - Effect.fn(function* () { - return { - data: Object.fromEntries( - Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]), - ), - } - }), + Effect.fn(() => activeSessions(session)), ) .handle( "session.get", diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index cc1b1ae6a55d..8e5c1b44ab66 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -22,6 +22,8 @@ import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" import { layer as locationLayer } from "./location" import { sessionLocationLayer } from "./middleware/session-location" +import { RpcApi } from "@opencode-ai/protocol/rpc" +import { RpcHandler } from "./rpc" const applicationServices = LayerNode.group([ Database.node, @@ -51,8 +53,10 @@ export function createEmbeddedRoutes() { function makeRoutes(auth: Layer.Layer) { const serviceLayer = AppNodeBuilder.build(applicationServices, [[SessionExecution.node, SessionExecutionLocal.node]]) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers), + return Layer.mergeAll( + HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(Layer.provide(handlers)), + HttpApiBuilder.layer(RpcApi).pipe(Layer.provide(RpcHandler)), + ).pipe( Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(authorizationLayer), diff --git a/packages/server/src/rpc.ts b/packages/server/src/rpc.ts new file mode 100644 index 000000000000..2d2c77cf85cf --- /dev/null +++ b/packages/server/src/rpc.ts @@ -0,0 +1,100 @@ +import { SessionV2 } from "@opencode-ai/core/session" +import { MultiRpc, Server, contentTypeFor, detectProtocol, encodeMessage } from "@reddb-io/multi-rpc" +import { decode } from "@reddb-io/toon" +import { RpcError } from "@reddb-io/toon-rpc" +import { RpcApi, RpcMaxBodyBytes, RpcMethods } from "@opencode-ai/protocol/rpc" +import { Effect, FileSystem, Schema } from "effect" +import { HttpIncomingMessage, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { activeSessions, listSessions } from "./session-read" + +export const RpcHandler = HttpApiBuilder.group(RpcApi, "rpc", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const server = new Server() + + server.register(RpcMethods.healthGet.name, async (params) => { + requireNoParams(params) + return { healthy: true } + }) + server.register(RpcMethods.sessionList.name, async (params) => { + const input = await Effect.runPromise( + Schema.decodeUnknownEffect(RpcMethods.sessionList.params)(params).pipe( + Effect.mapError(() => new RpcError(-32602, "Invalid params")), + ), + ) + const result = await Effect.runPromise( + listSessions(session, input).pipe( + Effect.catchTag("InvalidCursorError", () => Effect.fail(new RpcError(-32602, "Invalid params"))), + ), + ) + return Effect.runPromise(Schema.encodeEffect(RpcMethods.sessionList.result)(result)) + }) + server.register(RpcMethods.sessionActive.name, async (params) => { + requireNoParams(params) + return Effect.runPromise(activeSessions(session)) + }) + + const multi = new MultiRpc(server) + return handlers.handleRaw( + "handle", + Effect.fn("Rpc.handle")(function* (ctx) { + const contentType = ctx.request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() + if (contentType !== "application/json" && contentType !== "application/toon") + return HttpServerResponse.text("Unsupported Media Type", { status: 415 }) + + const contentLength = Number(ctx.request.headers["content-length"]) + if (Number.isFinite(contentLength) && contentLength > RpcMaxBodyBytes) + return HttpServerResponse.text("Payload Too Large", { status: 413 }) + + const body = yield* ctx.request.arrayBuffer.pipe( + Effect.provideService(HttpIncomingMessage.MaxBodySize, FileSystem.Size(RpcMaxBodyBytes)), + Effect.map((value) => new Uint8Array(value)), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (!body || body.byteLength > RpcMaxBodyBytes) + return HttpServerResponse.text("Payload Too Large", { status: 413 }) + + const protocol = detectProtocol(body, contentType) + if (isBatch(body, protocol)) { + return HttpServerResponse.text( + encodeMessage( + { + error: { code: -32600, message: "Invalid Request: batches are not supported" }, + id: null, + }, + protocol, + ), + { status: 400, contentType: contentTypeFor(protocol) }, + ) + } + + const response = yield* Effect.promise(() => multi.handleWithProtocol(body, contentType)) + return HttpServerResponse.uint8Array(response.body, { + status: response.body.byteLength === 0 ? 204 : 200, + contentType: contentTypeFor(response.protocol), + }) + }), + ) + }), +) + +function requireNoParams(params: unknown) { + if (Array.isArray(params) && params.length === 0) return + if (params !== null && typeof params === "object" && !Array.isArray(params) && Object.keys(params).length === 0) + return + throw new RpcError(-32602, "Invalid params") +} + +function isBatch(body: Uint8Array, protocol: "jsonrpc" | "toonrpc") { + const parsed = Effect.runSync( + Effect.try({ + try: () => { + const text = new TextDecoder().decode(body) + return protocol === "jsonrpc" ? JSON.parse(text) : decode(text) + }, + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))), + ) + return Array.isArray(parsed) +} diff --git a/packages/server/src/session-read.ts b/packages/server/src/session-read.ts new file mode 100644 index 000000000000..00bb750e14d3 --- /dev/null +++ b/packages/server/src/session-read.ts @@ -0,0 +1,55 @@ +import { SessionV2 } from "@opencode-ai/core/session" +import { DefaultSessionsLimit, SessionsCursor, SessionListInput } from "@opencode-ai/protocol/groups/session" +import { InvalidCursorError } from "@opencode-ai/protocol/errors" +import { DateTime, Effect } from "effect" + +export function listSessions(session: SessionV2.Interface, input: typeof SessionListInput.Type) { + return Effect.gen(function* () { + const query = + input.cursor !== undefined + ? yield* SessionsCursor.parse(input.cursor).pipe( + Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })), + ) + : input + const sessions = yield* session.list({ + ...query, + workspaceID: query.workspace, + limit: input.limit ?? DefaultSessionsLimit, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + data: sessions, + cursor: { + previous: first + ? SessionsCursor.make({ + ...query, + anchor: { + id: first.id, + time: DateTime.toEpochMillis(first.time.created), + direction: "previous", + }, + }) + : undefined, + next: last + ? SessionsCursor.make({ + ...query, + anchor: { + id: last.id, + time: DateTime.toEpochMillis(last.time.created), + direction: "next", + }, + }) + : undefined, + }, + } + }) +} + +export function activeSessions(session: SessionV2.Interface) { + return session.active.pipe( + Effect.map((active) => ({ + data: Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])), + })), + ) +} diff --git a/patches/@scriptc%2Fruntime@0.0.35.patch b/patches/@scriptc%2Fruntime@0.0.35.patch new file mode 100644 index 000000000000..4030150428df --- /dev/null +++ b/patches/@scriptc%2Fruntime@0.0.35.patch @@ -0,0 +1,68 @@ +diff --git a/src/scr_runtime.h b/src/scr_runtime.h +index 95f9cb8..344897f 100644 +--- a/src/scr_runtime.h ++++ b/src/scr_runtime.h +@@ -26,5 +26,5 @@ + struct tm *gmtime_r(const time_t *t, struct tm *out); + char *strcasestr(const char *hay, const char *needle); +-#elif defined(SCR_MUSL) ++#elif defined(SCR_MUSL) || defined(__linux__) + void arc4random_buf(void *buf, size_t n); + #endif +diff --git a/src/scr_lib.c b/src/scr_lib.c +index b2348db..1016f43 100644 +--- a/src/scr_lib.c ++++ b/src/scr_lib.c +@@ -89,3 +89,52 @@ + #elif defined(__linux__) + #include + #endif ++ ++#if defined(__linux__) && !defined(SCR_MUSL) ++#include ++#if defined(__GLIBC__) && !__GLIBC_PREREQ(2, 36) ++#include ++#include ++#include ++ ++static void scr_urandom(unsigned char *p, size_t n) { ++ int fd; ++ do { ++ fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); ++ } while (fd < 0 && errno == EINTR); ++ if (fd < 0) scr_trap("scriptc: open /dev/urandom failed\n"); ++ while (n > 0) { ++ ssize_t got = read(fd, p, n); ++ if (got > 0) { ++ p += (size_t)got; ++ n -= (size_t)got; ++ continue; ++ } ++ if (got < 0 && errno == EINTR) continue; ++ close(fd); ++ scr_trap("scriptc: read /dev/urandom failed\n"); ++ } ++ close(fd); ++} ++ ++/* glibc exposed arc4random_buf in 2.36. Older supported targets use the ++ * same infallible contract as scriptc's musl shim. */ ++void arc4random_buf(void *buf, size_t n) { ++ unsigned char *p = buf; ++ while (n > 0) { ++ ssize_t got = getrandom(p, n, 0); ++ if (got > 0) { ++ p += (size_t)got; ++ n -= (size_t)got; ++ continue; ++ } ++ if (got < 0 && errno == EINTR) continue; ++ if (got < 0 && errno == ENOSYS) { ++ scr_urandom(p, n); ++ return; ++ } ++ scr_trap("scriptc: getrandom failed\n"); ++ } ++} ++#endif ++#endif diff --git a/script/test-redcode-release-contract.ts b/script/test-redcode-release-contract.ts index 28ae8498de08..359927bad133 100755 --- a/script/test-redcode-release-contract.ts +++ b/script/test-redcode-release-contract.ts @@ -73,6 +73,29 @@ for (const required of [ "Existing draft Redcode tag to reconcile", "released tags are immutable", "https://github.com/reddb-io/redcode", + "REDCODE_RPC_SIDECAR_DIR", + "redcode-rpc-sidecar-linux-x64", + "redcode-rpc-sidecar-linux-arm64", + "redcode-rpc-sidecar-linux-x64-musl", + "redcode-rpc-sidecar-linux-arm64-musl", + "redcode-rpc-sidecar-windows-x64.exe", + "redcode-rpc-sidecar-windows-arm64.exe", + "redcode-rpc-sidecar-darwin-x64", + "redcode-rpc-sidecar-darwin-arm64", + "x86_64-linux-gnu.2.29", + "aarch64-linux-gnu.2.29", + "x86_64-linux-musl", + "aarch64-linux-musl", + "x86_64-windows-gnu", + "aarch64-windows-gnu", + "macos-15-intel", + "macos-15", + 'deployment: "11.0"', + "/Applications/Xcode_16.4.app/Contents/Developer", + "node-version: 24.11.1", + "Verify RPC sidecar target", + "zig-x86_64-linux-0.17.0-dev.1857+3c46da14d.tar.xz", + "0825e4a55baf4a6647c3ce3077a7236c9ffec5b17e1ca0102a930fe33d631bc6", ]) { if (!publish.includes(required)) throw new Error(`red-publish.yml is missing ${required}`) } @@ -81,6 +104,34 @@ const build = await Bun.file(path.join(root, "packages", "opencode", "script", " for (const required of ["SHA256SUMS", 'new Bun.CryptoHasher("sha256")', "./dist/SHA256SUMS"]) { if (!build.includes(required)) throw new Error(`build.ts is missing release integrity contract ${required}`) } +for (const required of [ + "REDCODE_RPC_SIDECAR_DIR", + "missing staged RPC sidecar", + 'throw new Error("REDCODE_RPC_SIDECAR_DIR is required for release builds")', + "redcode-rpc-sidecar", +]) { + if (!build.includes(required)) throw new Error(`build.ts is missing RPC sidecar contract ${required}`) +} + +const sidecarBuild = await Bun.file(path.join(root, "packages", "rpc-sidecar", "script", "build.ts")).text() +for (const required of ["REDCODE_RPC_SIDECAR_TARGET", "SCRIPTC_TARGET", 'SCRIPTC_CC = "zigcc"']) { + if (!sidecarBuild.includes(required)) + throw new Error(`RPC sidecar build is missing cross-target contract ${required}`) +} +if (publish.includes("@ziglang/cli")) throw new Error("red-publish.yml must not use the mutable @ziglang/cli download") + +const publishScript = await Bun.file(path.join(root, "packages", "opencode", "script", "publish.ts")).text() +for (const required of [ + '"redcode-rpc-sidecar": "./bin/redcode-rpc-sidecar"', + 'cp ./bin/opencode ${path.join(meta, "bin", "redcode-rpc-sidecar")}', +]) { + if (!publishScript.includes(required)) throw new Error(`publish.ts is missing RPC sidecar contract ${required}`) +} + +const launcher = await Bun.file(path.join(root, "packages", "opencode", "bin", "opencode")).text() +for (const required of ["REDCODE_RPC_SIDECAR_PATH", 'startsWith("redcode-rpc-sidecar")']) { + if (!launcher.includes(required)) throw new Error(`npm launcher is missing RPC sidecar contract ${required}`) +} for (const banned of ["beta", "docker", "desktop", "sst", "vscode"]) { if (publish.toLowerCase().includes(banned)) throw new Error(`red-publish.yml must not mention ${banned}`) }