mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0574107e18 | |||
| 6f0fa2d6c6 | |||
| 363905dcdd | |||
| d71bc0055a | |||
| 4f235fc59b | |||
| 1d772f4896 | |||
| 113a117cef | |||
| c103ba6056 | |||
| 38f03c9d8b | |||
| e5ac4ca560 | |||
| 5f0487fa87 | |||
| 00557f9078 | |||
| 5ac1089c5c | |||
| d1843f49ac | |||
| aac66d384e | |||
| 83108c1b5d | |||
| e7000161f7 | |||
| 66200eee34 | |||
| 699477755f |
@@ -308,7 +308,6 @@ jobs:
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform_flag: --win --arm64
|
||||
bun_install_flags: --os=win32 --cpu=arm64
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
@@ -389,7 +388,6 @@ jobs:
|
||||
working-directory: packages/desktop
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
|
||||
|
||||
@@ -174,19 +174,17 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.98",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
@@ -378,7 +376,6 @@
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"diff": "catalog:",
|
||||
@@ -433,28 +430,10 @@
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk-next": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -472,6 +451,22 @@
|
||||
"vite": "catalog:",
|
||||
"zod-openapi": "5.4.6",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
},
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
@@ -551,7 +546,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-beta.101",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.98",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -588,7 +583,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -631,7 +626,6 @@
|
||||
"name": "@opencode-ai/schema",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -654,7 +648,6 @@
|
||||
"packages/sdk-next": {
|
||||
"name": "@opencode-ai/sdk-next",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -668,8 +661,6 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
},
|
||||
"packages/server": {
|
||||
@@ -679,7 +670,6 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -977,7 +967,6 @@
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"cross-spawn": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
@@ -997,6 +986,25 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/voice": {
|
||||
"name": "@opencode-ai/voice",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"opentui-spinner": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.4",
|
||||
@@ -1046,23 +1054,23 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"web-tree-sitter",
|
||||
"esbuild",
|
||||
"electron",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1070,14 +1078,13 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||
"@effect/platform-node": "4.0.0-beta.98",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1097,7 +1104,6 @@
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
@@ -1114,7 +1120,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1558,13 +1564,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.98", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.98" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITfK8xhcl+9GXOvPwzADWkOQ+dgUGZrJNefT3r2+uLFmzjyKRLtHzhLOl6lZaLSsf5io13+nmt8adfMRQPq+oA=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2090,6 +2096,8 @@
|
||||
|
||||
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
|
||||
|
||||
"@opencode-ai/voice": ["@opencode-ai/voice@workspace:packages/voice"],
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
|
||||
@@ -3842,7 +3850,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
|
||||
"effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
||||
+6
-8
@@ -36,9 +36,9 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||
"@effect/platform-node": "4.0.0-beta.98",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -50,7 +50,6 @@
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
@@ -69,7 +68,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -152,8 +151,7 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
@@ -167,7 +165,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -425,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||
item: Tool.Content,
|
||||
item: ToolContent,
|
||||
) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
@@ -436,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
||||
// with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -371,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
|
||||
item: Content,
|
||||
item: ToolContent,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
@@ -392,7 +392,7 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
})
|
||||
|
||||
@@ -496,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -335,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolFileContent,
|
||||
type TextPart,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||
})
|
||||
|
||||
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
@@ -153,4 +152,8 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
||||
* treated as a defect and fails the stream.
|
||||
*/
|
||||
export class ToolFailure extends Tool.Error {}
|
||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
|
||||
import { isRecord } from "../utils/record"
|
||||
@@ -40,6 +40,8 @@ export const MediaPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
export { ToolContent, ToolFileContent, ToolTextContent }
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
@@ -61,7 +63,7 @@ export const ToolResultValue = Object.assign(
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(Tool.Content),
|
||||
value: Schema.Array(ToolContent),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||
{
|
||||
@@ -77,16 +79,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
||||
|
||||
export interface ToolOutput {
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
}
|
||||
|
||||
export const ToolOutput = Object.assign(
|
||||
Schema.Struct({
|
||||
structured: Schema.Unknown,
|
||||
content: Schema.Array(Tool.Content),
|
||||
content: Schema.Array(ToolContent),
|
||||
}).annotate({ identifier: "LLM.ToolOutput" }),
|
||||
{
|
||||
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
|
||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
|
||||
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
||||
switch (result.type) {
|
||||
case "json":
|
||||
|
||||
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
|
||||
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.map((value) => result(call, value)),
|
||||
Effect.catchTag("Tool.Error", (failure) =>
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type {
|
||||
ToolCallPart,
|
||||
ToolContent,
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema"
|
||||
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
|
||||
|
||||
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
||||
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
|
||||
) => ReadonlyArray<Tool.Content>
|
||||
) => ReadonlyArray<ToolContent>
|
||||
|
||||
/**
|
||||
* A type-safe LLM tool. Each tool bundles its own description, parameter
|
||||
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyExecutableTool
|
||||
export function make(config: {
|
||||
@@ -159,7 +159,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: undefined
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyTool
|
||||
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
}
|
||||
|
||||
const project = (
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
|
||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||
parameters: unknown,
|
||||
callID: ToolCallPart["id"],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Content } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
GenerationOptions,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
ToolChoice,
|
||||
ToolContent,
|
||||
ToolOutput,
|
||||
toDefinitions,
|
||||
} from "../src"
|
||||
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
|
||||
|
||||
it.effect("models canonical tool files with URIs", () =>
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(Content)
|
||||
const decode = Schema.decodeUnknownSync(ToolContent)
|
||||
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
|
||||
@@ -88,8 +88,12 @@ function SessionTabSlot(props: {
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
|
||||
class="relative flex min-w-7 flex-shrink"
|
||||
classList={{
|
||||
hidden: !session() && !missingSession() && !persisted()?.title,
|
||||
"w-72 max-w-72": props.active(),
|
||||
"w-56 max-w-56": !props.active(),
|
||||
}}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -144,7 +148,11 @@ function DraftTabSlot(props: {
|
||||
data-titlebar-tab-slot
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
class="relative flex min-w-7 flex-shrink"
|
||||
classList={{
|
||||
"w-72 max-w-72": props.active(),
|
||||
"w-56 max-w-56": !props.active(),
|
||||
}}
|
||||
>
|
||||
<DraftTabItem
|
||||
ref={(el) => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -171,7 +171,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -32,7 +32,7 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -63,7 +63,7 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
||||
@@ -4,14 +4,14 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
@@ -50,7 +50,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: SessionDiff[]
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("adaptServerEvent", () => {
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.v2.asked",
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event, PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -18,15 +18,7 @@ const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type PermissionEvent = {
|
||||
id: string
|
||||
type: "permission.v2.asked"
|
||||
properties: PermissionRequest
|
||||
current?: OpenCodeEvent
|
||||
}
|
||||
export type ServerEvent = (Event | PermissionEvent) & {
|
||||
current?: OpenCodeEvent
|
||||
}
|
||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
@@ -37,7 +29,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
if (event.type === "permission.v2.asked") {
|
||||
return {
|
||||
id: event.id,
|
||||
type: "permission.v2.asked",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: event.data.id,
|
||||
sessionID: event.data.sessionID,
|
||||
@@ -51,16 +43,16 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
}
|
||||
} as ServerEvent
|
||||
}
|
||||
if (event.type === "permission.v2.replied")
|
||||
return { id: event.id, type: "permission.v2.replied", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.asked")
|
||||
return { id: event.id, type: "question.v2.asked", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.replied")
|
||||
return { id: event.id, type: "question.v2.replied", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.rejected")
|
||||
return { id: event.id, type: "question.v2.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
@@ -140,7 +140,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, SessionDiff[]>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
@@ -773,7 +773,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -909,8 +909,13 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.properties.file.startsWith(".git/")) return
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
@@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = SessionDiff | VcsFileDiff
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -23,8 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -60,7 +59,7 @@ import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (SessionDiff | VcsFileDiff)[]
|
||||
diffs: () => (FileDiffInfo | VcsFileDiff)[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = SessionDiff | VcsFileDiff
|
||||
export type RenderDiff = FileDiffInfo | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = SessionDiff | VcsFileDiff
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { diffs, message } from "./diffs"
|
||||
|
||||
@@ -9,7 +9,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies SnapshotFileDiff
|
||||
} satisfies FileDiffInfo
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export type SessionDiff = SnapshotFileDiff & { file: string; patch: string }
|
||||
type Diff = FileDiffInfo
|
||||
|
||||
function diff(value: unknown): value is SessionDiff {
|
||||
function diff(value: unknown): value is Diff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
if (!("file" in value) || typeof value.file !== "string") return false
|
||||
if (!("patch" in value) || typeof value.patch !== "string") return false
|
||||
@@ -17,7 +17,7 @@ function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function diffs(value: unknown): SessionDiff[] {
|
||||
export function diffs(value: unknown): Diff[] {
|
||||
if (Array.isArray(value) && value.every(diff)) return value
|
||||
if (Array.isArray(value)) return value.filter(diff)
|
||||
if (diff(value)) return [value]
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function streamTurn(input: {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Result } from "effect"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
|
||||
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readDisplayText } from "@opencode-ai/tui/mini/tool"
|
||||
export type ToolInput = Record<string, unknown>
|
||||
export type ToolContent = ReadonlyArray<
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null }
|
||||
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
|
||||
>
|
||||
|
||||
export function toToolKind(toolName: string): ToolKind {
|
||||
@@ -44,7 +44,6 @@ export function toLocations(toolName: string, input: ToolInput, cwd?: string): T
|
||||
return workdir ? [{ path: workdir }] : []
|
||||
}
|
||||
case "read":
|
||||
return locationFrom(input.path)
|
||||
case "edit":
|
||||
case "write":
|
||||
case "patch":
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
} from "@opencode-ai/plugin/v2/effect"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -24,5 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool: { Error: Tool.Error },
|
||||
Tool,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin"
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
JsonValue,
|
||||
LLMToolContent,
|
||||
LocationRef,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
ToolContent,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { EOL } from "node:os"
|
||||
@@ -56,7 +56,7 @@ type ToolState = StartedPart & {
|
||||
provider?: unknown
|
||||
providerState?: SessionMessageAssistantTool["providerState"]
|
||||
metadata: Record<string, JsonValue>
|
||||
content: ToolContent[]
|
||||
content: LLMToolContent[]
|
||||
}
|
||||
|
||||
type V2Event = EventSubscribeOutput
|
||||
@@ -177,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,13 +13,11 @@ type Options = {
|
||||
readonly command?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const startupDirectory = process.cwd()
|
||||
|
||||
function command(password: string, options: Options) {
|
||||
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
|
||||
if (!executable) throw new Error("Failed to resolve standalone server command")
|
||||
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
|
||||
cwd: startupDirectory,
|
||||
cwd: process.cwd(),
|
||||
// Explicit entry wins over anything inherited, so a user-exported
|
||||
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
|
||||
env: { OPENCODE_PASSWORD: password },
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import path from "node:path"
|
||||
|
||||
const entrypoint = process.argv[1] ? path.resolve(process.argv[1]) : undefined
|
||||
|
||||
export function selfCommand() {
|
||||
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
|
||||
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
|
||||
if (!entrypoint) throw new Error("Failed to resolve CLI entrypoint")
|
||||
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), entrypoint]
|
||||
return [process.execPath, entrypoint]
|
||||
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
|
||||
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
|
||||
return [process.execPath, process.argv[1]]
|
||||
}
|
||||
|
||||
function nodeFlags() {
|
||||
|
||||
@@ -244,7 +244,7 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_tools",
|
||||
assistantMessageID: "msg_tools",
|
||||
callID: "call_fail",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
input: { filePath: "/workspace/missing.ts" },
|
||||
executed: false,
|
||||
}),
|
||||
)
|
||||
@@ -638,7 +638,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "error",
|
||||
input: { path: "/workspace/missing.ts" },
|
||||
input: { filePath: "/workspace/missing.ts" },
|
||||
metadata: { bytes: 0 },
|
||||
content: [{ type: "text", text: "partial" }],
|
||||
error: { type: "tool.error", message: "failed hard" },
|
||||
|
||||
@@ -49,7 +49,7 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
metadata: { filePath: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
|
||||
}),
|
||||
)
|
||||
@@ -96,7 +96,7 @@ describe("acp permission behavior", () => {
|
||||
title: "/workspace/file.ts",
|
||||
kind: "read",
|
||||
locations: [{ path: "/workspace/file.ts" }],
|
||||
rawInput: { path: "/workspace/file.ts" },
|
||||
rawInput: { filePath: "/workspace/file.ts" },
|
||||
},
|
||||
})
|
||||
expect(permissionReplies(fixture)).toEqual([
|
||||
@@ -502,7 +502,7 @@ function permissionAsked(
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
return ephemeralEvent("permission.v2.asked", {
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("acp tools", () => {
|
||||
})
|
||||
|
||||
test("extracts file locations from tool input", () => {
|
||||
expect(toLocations("read", { path: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
|
||||
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
|
||||
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||
@@ -43,7 +43,7 @@ describe("acp tools", () => {
|
||||
])
|
||||
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
|
||||
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
||||
})
|
||||
|
||||
test("builds completed content with text and image attachments", () => {
|
||||
@@ -205,13 +205,12 @@ describe("acp tools", () => {
|
||||
runningToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "read",
|
||||
state: { input: { path: "/tmp/a" } },
|
||||
state: { input: { filePath: "/tmp/a" } },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
}),
|
||||
).toMatchObject({
|
||||
toolCallId: "call",
|
||||
status: "in_progress",
|
||||
locations: [{ path: "/tmp/a" }],
|
||||
content: [{ type: "content", content: { type: "text", text: "done" } }],
|
||||
})
|
||||
})
|
||||
@@ -274,7 +273,7 @@ describe("acp tools", () => {
|
||||
errorToolUpdate({
|
||||
toolCallId: "call",
|
||||
toolName: "read",
|
||||
input: { path: "/tmp/a" },
|
||||
input: { filePath: "/tmp/a" },
|
||||
content: [{ type: "text", text: "partial output" }],
|
||||
metadata: { path: "/tmp/a" },
|
||||
error: "failed",
|
||||
@@ -285,7 +284,7 @@ describe("acp tools", () => {
|
||||
kind: "read",
|
||||
title: "read",
|
||||
locations: [{ path: "/tmp/a" }],
|
||||
rawInput: { path: "/tmp/a" },
|
||||
rawInput: { filePath: "/tmp/a" },
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "partial output" } },
|
||||
{ type: "content", content: { type: "text", text: "failed" } },
|
||||
|
||||
@@ -3,14 +3,12 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../../src/services/standalone"
|
||||
|
||||
process.chdir(path.join(import.meta.dir, "../../../.."))
|
||||
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Standalone.start({
|
||||
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
|
||||
})
|
||||
const endpoint = yield* Standalone.start()
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
)
|
||||
|
||||
@@ -75,11 +75,20 @@ export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promiseToolModule = `export {}`
|
||||
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const make = sdk.Tool.make`
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Error = sdk.Tool.Error
|
||||
`
|
||||
export const Tool = sdk.Tool
|
||||
export const Failure = sdk.Tool.Failure
|
||||
export const RegistrationError = sdk.Tool.RegistrationError
|
||||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const validateNamespace = sdk.Tool.validateNamespace
|
||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
@@ -91,17 +100,17 @@ const __filename = import.meta.filename
|
||||
const __dirname = import.meta.dirname
|
||||
const require = __cjs_mod__.createRequire(import.meta.url)
|
||||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
|
||||
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
|
||||
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
|
||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
})}
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-promise-plugin": promisePluginModule,
|
||||
"opencode:plugin-promise-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
|
||||
@@ -1,62 +1,17 @@
|
||||
# @opencode-ai/client
|
||||
|
||||
Promise and Effect clients derived from OpenCode's authoritative Effect `HttpApi`, plus handwritten Node transports.
|
||||
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
|
||||
- `@opencode-ai/client/node`: Promise client plus Node-hosted browser attachments.
|
||||
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
|
||||
|
||||
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
|
||||
|
||||
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
|
||||
|
||||
The Promise root remains structural and has no Core, Effect, Schema, Protocol, or WebSocket runtime dependency. `/node` adds Effect, Schema, Protocol, and `ws`, but never Core or Server. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce these import graphs.
|
||||
|
||||
## Node browser attachments
|
||||
|
||||
The Node entrypoint owns the control connection, Session lease, authenticated proxy, and network tunnels. Chromium hosts provide a small platform port once with `BrowserDriver.chromium`; the SDK owns command semantics, CDP input, snapshots, generations, cancellation, and limits.
|
||||
|
||||
```ts
|
||||
import { BrowserDriver, OpenCode } from "@opencode-ai/client/node"
|
||||
|
||||
const driver = BrowserDriver.chromium(async ({ proxy, signal }) => {
|
||||
const view = await createChromiumView({ proxy, signal })
|
||||
return {
|
||||
resource: view,
|
||||
state: () => view.state(),
|
||||
subscribe: (listener) => view.subscribe((state, mainDocumentChanged) => listener({ state, mainDocumentChanged })),
|
||||
navigate: (url) => view.navigate(url),
|
||||
back: () => view.back(),
|
||||
forward: () => view.forward(),
|
||||
reload: () => view.reload(),
|
||||
stop: () => view.stop(),
|
||||
send: (method, params) => view.sendCDP(method, params),
|
||||
viewport: () => view.viewport(),
|
||||
screenshot: ({ maxDimension }) => view.capturePNG({ maxDimension }),
|
||||
dispose: () => view.close(),
|
||||
}
|
||||
})
|
||||
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "https://opencode.example",
|
||||
headers: { authorization: `Basic ${credentials}` },
|
||||
})
|
||||
const attachment = await client.browser.attach({ sessionID, driver })
|
||||
|
||||
await attachment.resource.navigate("example.com")
|
||||
const view = attachment.resource.resource
|
||||
await attachment.close()
|
||||
```
|
||||
|
||||
`attach` resolves only after the server acknowledges the exact Session lease. Each attachment has its own proxy and driver resource; `close()` and `Symbol.asyncDispose` are idempotent. A single Node client multiplexes up to 16 distinct Sessions over one lazily opened control WebSocket.
|
||||
|
||||
Driver factories should return after configuring their resource rather than await a proxied navigation: tunnel dialing is deliberately held behind the first lease acknowledgement, which is published after the driver supplies its initial state.
|
||||
|
||||
Port state events set `mainDocumentChanged` only when the main document changes; this advances the public generation and invalidates element refs. `send` must dispatch CDP calls in invocation order. `screenshot` returns PNG bytes and dimensions, proportionally scaled to `maxDimension` without upscaling. The returned controller serializes local navigation with remote commands; `stop` immediately interrupts active work, and controller disposal is idempotent. An aborted or timed-out operation that reached the platform disposes the port so late native completion cannot cross the queue fence.
|
||||
|
||||
`BrowserDriver` descriptors are structural factory functions, so adapters remain compatible across duplicate client package instances. The Node entrypoint also re-exports canonical `Browser` contracts. `BrowserDriver.define` remains the advanced escape hatch for non-Chromium semantics; throw `BrowserDriverError` for typed command failures there. Structurally equivalent errors are accepted only when their `code` is a valid `Browser.ErrorCode`.
|
||||
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
|
||||
|
||||
Effect consumers construct canonical decoded inputs:
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/promise/index.ts",
|
||||
"./node": "./src/node/index.ts",
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./service": "./src/promise/service.ts",
|
||||
@@ -29,17 +28,15 @@
|
||||
"build": "bun run script/build-package.ts",
|
||||
"generate": "bun run script/build.ts",
|
||||
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
|
||||
"test": "bun test --timeout 5000 && bun run test:node-package",
|
||||
"test:node-package": "bun test ./test/node/package-smoke.ts --timeout 60000",
|
||||
"typecheck": "tsgo --noEmit && tsgo -p test/types/tsconfig.json --noEmit && tsgo -p test/types/tsconfig.nodenext.json --noEmit"
|
||||
"test": "bun test --timeout 5000",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"ws": "8.21.0"
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101"
|
||||
"effect": "4.0.0-beta.98"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
@@ -51,7 +48,6 @@
|
||||
"@opencode-ai/httpapi-codegen": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:"
|
||||
}
|
||||
|
||||
@@ -7,4 +7,3 @@ process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
await $`bun build src/node/index.ts --outfile dist/node/index.js --target=node --format=esm --packages=external`
|
||||
|
||||
@@ -6,86 +6,11 @@ import {
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
||||
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
|
||||
const effectTypeReferences = [
|
||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||
...namespaceTypes("FileDiff", "@opencode-ai/schema/file-diff", FileDiff),
|
||||
...namespaceTypes("FileSystem", "@opencode-ai/schema/filesystem", FileSystem),
|
||||
...namespaceTypes("Form", "@opencode-ai/schema/form", Form),
|
||||
...namespaceTypes("InstructionEntry", "@opencode-ai/schema/instruction-entry", InstructionEntry),
|
||||
...namespaceTypes("Integration", "@opencode-ai/schema/integration", Integration),
|
||||
...namespaceTypes("Location", "@opencode-ai/schema/location", Location),
|
||||
...namespaceTypes("Mcp", "@opencode-ai/schema/mcp", Mcp),
|
||||
...namespaceTypes("Model", "@opencode-ai/schema/model", Model),
|
||||
...namespaceTypes("Permission", "@opencode-ai/schema/permission", Permission),
|
||||
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
|
||||
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
|
||||
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
|
||||
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
|
||||
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
|
||||
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
|
||||
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
|
||||
...namespaceTypes("PtyTicket", "@opencode-ai/schema/pty-ticket", PtyTicket),
|
||||
...namespaceTypes("Question", "@opencode-ai/schema/question", Question),
|
||||
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
|
||||
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
|
||||
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
|
||||
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
|
||||
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
|
||||
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
|
||||
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
|
||||
...namespaceTypes("WebSearch", "@opencode-ai/schema/websearch", WebSearch),
|
||||
...namespaceTypes("Workspace", "@opencode-ai/schema/workspace", Workspace),
|
||||
typeReference("Prompt", "@opencode-ai/schema/prompt", Prompt),
|
||||
typeReference("PromptMention", "@opencode-ai/schema/prompt", PromptMention),
|
||||
typeReference("FileAttachment", "@opencode-ai/schema/prompt", FileAttachment),
|
||||
typeReference("AgentAttachment", "@opencode-ai/schema/prompt", AgentAttachment),
|
||||
typeReference("AbsolutePath", "@opencode-ai/schema/schema", AbsolutePath),
|
||||
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
|
||||
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
|
||||
]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
@@ -97,40 +22,14 @@ await Effect.runPromise(
|
||||
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectImported(effectContract, {
|
||||
module: "../../contract",
|
||||
api: "ClientApi",
|
||||
shapeModule: "../api/api.js",
|
||||
}),
|
||||
emitEffectImported(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, {
|
||||
typeReferences: effectTypeReferences,
|
||||
outputTypes: {
|
||||
"event.subscribe": {
|
||||
name: "OpenCodeEvent",
|
||||
import: 'import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"',
|
||||
},
|
||||
},
|
||||
}),
|
||||
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 3, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
function namespaceTypes(namespace: string, module: string, values: object) {
|
||||
return Object.entries(values).flatMap(([name, schema]) =>
|
||||
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
|
||||
)
|
||||
}
|
||||
|
||||
function typeReference(name: string, module: string, schema: Schema.Top) {
|
||||
return {
|
||||
schema,
|
||||
name,
|
||||
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,2 @@
|
||||
export { ClientError } from "./client-error.js"
|
||||
export * as OpenCode from "./client.js"
|
||||
export { ClientError } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,639 +0,0 @@
|
||||
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
|
||||
import { BROWSER_CONTROL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import WebSocket from "ws"
|
||||
import type { ClientOptions } from "../../promise/generated/client.js"
|
||||
import {
|
||||
browserDriverFactory,
|
||||
type BrowserDriver,
|
||||
type BrowserDriverContext,
|
||||
type BrowserDriverInstance,
|
||||
type BrowserProxy,
|
||||
} from "./driver.js"
|
||||
import { createBrowserProxy } from "./proxy.js"
|
||||
import { openBrowserTunnel, type BrowserTunnelEndpoint } from "./tunnel.js"
|
||||
|
||||
export interface BrowserAttachOptions<Resource> {
|
||||
readonly sessionID: string
|
||||
readonly driver: BrowserDriver<Resource>
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface BrowserAttachment<Resource> extends AsyncDisposable {
|
||||
readonly resource: Resource
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface BrowserClient {
|
||||
readonly attach: <Resource>(options: BrowserAttachOptions<Resource>) => Promise<BrowserAttachment<Resource>>
|
||||
}
|
||||
|
||||
type ProxyServer = Awaited<ReturnType<typeof createBrowserProxy>>
|
||||
|
||||
type AttachmentRecord = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly leaseID: Browser.LeaseID
|
||||
readonly abort: AbortController
|
||||
readonly setup: Promise<void>
|
||||
readonly finishSetup: () => void
|
||||
readonly externalSignal?: AbortSignal
|
||||
externalAbort?: () => void
|
||||
active: boolean
|
||||
closed: boolean
|
||||
state?: Browser.State
|
||||
execute?: BrowserDriverInstance<unknown>["execute"]
|
||||
dispose?: BrowserDriverInstance<unknown>["dispose"]
|
||||
unsubscribe?: () => void
|
||||
proxy?: ProxyServer
|
||||
closing?: Promise<void>
|
||||
}
|
||||
|
||||
type ActiveAttachment = AttachmentRecord & {
|
||||
readonly active: true
|
||||
readonly state: Browser.State
|
||||
readonly execute: BrowserDriverInstance<unknown>["execute"]
|
||||
}
|
||||
|
||||
type Waiter = {
|
||||
readonly key: string
|
||||
readonly signal: AbortSignal
|
||||
readonly resolve: () => void
|
||||
readonly reject: (error: Error) => void
|
||||
readonly abort: () => void
|
||||
}
|
||||
|
||||
/** Creates the Node-only browser host attached to one immutable server endpoint. */
|
||||
export function createBrowserClient(options: ClientOptions): BrowserClient {
|
||||
const control = new BrowserClientControl(endpoint(options))
|
||||
return { attach: (input) => control.attach(input) }
|
||||
}
|
||||
|
||||
class BrowserClientControl {
|
||||
private readonly records = new Map<Session.ID, AttachmentRecord>()
|
||||
private readonly requests = new Map<
|
||||
BrowserControl.RequestID,
|
||||
{ readonly leaseID: Browser.LeaseID; readonly abort: AbortController }
|
||||
>()
|
||||
private readonly waiters = new Set<Waiter>()
|
||||
private socket?: WebSocket
|
||||
private retry?: ReturnType<typeof setTimeout>
|
||||
private retryAttempt = 0
|
||||
private revision = 0
|
||||
private ready = false
|
||||
private opening?: Promise<void>
|
||||
private openingAbort?: AbortController
|
||||
private synced?: string
|
||||
private syncing?: { readonly revision: number; readonly snapshot: string; readonly leases: Set<string> }
|
||||
private syncedLeases = new Set<string>()
|
||||
private inbound = Promise.resolve()
|
||||
private failing = false
|
||||
|
||||
constructor(private readonly server: BrowserTunnelEndpoint) {}
|
||||
|
||||
async attach<Resource>(input: BrowserAttachOptions<Resource>): Promise<BrowserAttachment<Resource>> {
|
||||
if (!Schema.is(Session.ID)(input.sessionID)) throw new TypeError("Browser attachment requires a valid Session ID")
|
||||
if (input.signal?.aborted) throw abortError(input.signal, "Browser attachment was aborted")
|
||||
const sessionID = Session.ID.make(input.sessionID)
|
||||
if (this.records.has(sessionID)) throw new Error(`Browser is already attached to Session ${sessionID}`)
|
||||
if (this.records.size >= 16) throw new Error("A Node client cannot attach more than 16 browser Sessions")
|
||||
|
||||
let finishSetup!: () => void
|
||||
const setup = new Promise<void>((resolve) => {
|
||||
finishSetup = resolve
|
||||
})
|
||||
const record: AttachmentRecord = {
|
||||
sessionID,
|
||||
leaseID: Browser.LeaseID.create(),
|
||||
abort: new AbortController(),
|
||||
setup,
|
||||
finishSetup,
|
||||
externalSignal: input.signal,
|
||||
active: false,
|
||||
closed: false,
|
||||
}
|
||||
this.records.set(sessionID, record)
|
||||
record.externalAbort = () => {
|
||||
void this.close(record, abortError(input.signal, "Browser attachment was aborted")).catch(() => undefined)
|
||||
}
|
||||
input.signal?.addEventListener("abort", record.externalAbort, { once: true })
|
||||
|
||||
try {
|
||||
record.proxy = await createBrowserProxy({
|
||||
connect: async (target, signal) => {
|
||||
await this.waitForAttachment(record, signal)
|
||||
const tunnelSignal = AbortSignal.any([signal, record.abort.signal])
|
||||
if (tunnelSignal.aborted) throw abortError(tunnelSignal, "Browser tunnel was aborted")
|
||||
return openBrowserTunnel({
|
||||
endpoint: this.server,
|
||||
sessionID: record.sessionID,
|
||||
leaseID: record.leaseID,
|
||||
target,
|
||||
signal: tunnelSignal,
|
||||
})
|
||||
},
|
||||
})
|
||||
this.requireOpen(record)
|
||||
|
||||
const instance = await createDriver(
|
||||
input.driver,
|
||||
{ proxy: exposedProxy(record.proxy), signal: record.abort.signal },
|
||||
record.abort.signal,
|
||||
)
|
||||
if (instance !== null && typeof instance === "object" && typeof instance.dispose === "function") {
|
||||
record.dispose = () => instance.dispose()
|
||||
}
|
||||
if (
|
||||
instance === null ||
|
||||
typeof instance !== "object" ||
|
||||
typeof instance.state !== "function" ||
|
||||
typeof instance.subscribe !== "function" ||
|
||||
typeof instance.execute !== "function" ||
|
||||
typeof instance.dispose !== "function"
|
||||
) {
|
||||
throw new TypeError("Browser driver factory returned an invalid driver instance")
|
||||
}
|
||||
record.execute = (command, options) => instance.execute(command, options)
|
||||
|
||||
let receivedState = false
|
||||
record.unsubscribe = instance.subscribe((state) => {
|
||||
if (record.closed) return
|
||||
const next = contractState(state)
|
||||
if (!next) {
|
||||
void this.close(record, new TypeError("Browser driver published an invalid state")).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
receivedState = true
|
||||
record.state = next
|
||||
if (record.active) this.changed()
|
||||
})
|
||||
if (typeof record.unsubscribe !== "function") {
|
||||
throw new TypeError("Browser driver subscribe must return an unsubscribe function")
|
||||
}
|
||||
const initial = contractState(instance.state())
|
||||
if (!initial) throw new TypeError("Browser driver returned an invalid initial state")
|
||||
if (!receivedState) record.state = initial
|
||||
this.requireOpen(record)
|
||||
|
||||
record.active = true
|
||||
this.changed()
|
||||
record.finishSetup()
|
||||
await this.waitForAttachment(record, record.abort.signal)
|
||||
this.requireOpen(record)
|
||||
|
||||
const close = () => this.close(record)
|
||||
return Object.freeze({
|
||||
resource: instance.resource,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close,
|
||||
})
|
||||
} catch (error) {
|
||||
record.finishSetup()
|
||||
await this.close(record).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private close(record: AttachmentRecord, reason = new Error("Browser attachment was closed")) {
|
||||
if (!record.closed) {
|
||||
record.closed = true
|
||||
record.active = false
|
||||
if (this.records.get(record.sessionID) === record) this.records.delete(record.sessionID)
|
||||
record.abort.abort(reason)
|
||||
this.syncedLeases.delete(attachmentKey(record.sessionID, record.leaseID))
|
||||
this.rejectAttachmentWaiters(record, reason)
|
||||
this.abortRequests(record.leaseID)
|
||||
this.changed()
|
||||
}
|
||||
if (record.closing) return record.closing
|
||||
record.closing = record.setup.then(async () => {
|
||||
if (record.externalAbort) record.externalSignal?.removeEventListener("abort", record.externalAbort)
|
||||
const unsubscribe = await cleanup(() => record.unsubscribe?.())
|
||||
const dispose = await cleanup(() => record.dispose?.())
|
||||
const proxy = await cleanup(() => record.proxy?.close())
|
||||
const failure = [unsubscribe, dispose, proxy].find(
|
||||
(result): result is { readonly ok: false; readonly error: unknown } => !result.ok,
|
||||
)
|
||||
if (failure) throw failure.error
|
||||
})
|
||||
return record.closing
|
||||
}
|
||||
|
||||
private requireOpen(record: AttachmentRecord) {
|
||||
if (!record.closed && !record.abort.signal.aborted) return
|
||||
throw abortError(record.abort.signal, "Browser attachment was closed")
|
||||
}
|
||||
|
||||
private changed() {
|
||||
if (this.failing) return
|
||||
if (this.attachments().length === 0) {
|
||||
this.stopControl()
|
||||
return
|
||||
}
|
||||
this.connect()
|
||||
this.publish()
|
||||
}
|
||||
|
||||
private attachments() {
|
||||
return [...this.records.values()].filter(
|
||||
(record): record is ActiveAttachment =>
|
||||
record.active && record.state !== undefined && record.execute !== undefined && !record.closed,
|
||||
)
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.socket || this.retry || this.opening || this.attachments().length === 0) return
|
||||
const abort = new AbortController()
|
||||
this.openingAbort = abort
|
||||
this.opening = this.open(abort.signal)
|
||||
.catch((error) => this.failControl(error instanceof Error ? error : new Error(String(error))))
|
||||
.finally(() => {
|
||||
if (this.openingAbort !== abort) return
|
||||
this.openingAbort = undefined
|
||||
this.opening = undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async open(lifetime: AbortSignal) {
|
||||
if (process.versions.bun) {
|
||||
// TODO: Remove the HTTP auth probe once Bun exposes ws upgrade responses.
|
||||
// https://github.com/oven-sh/bun/issues/5951
|
||||
const signal = AbortSignal.any([lifetime, AbortSignal.timeout(10_000)])
|
||||
const response = await (this.server.fetch ?? globalThis.fetch)(new URL("/api/health", this.server.url), {
|
||||
headers: this.server.authorization ? { Authorization: this.server.authorization } : undefined,
|
||||
signal,
|
||||
}).catch(() => undefined)
|
||||
if (lifetime.aborted) return
|
||||
if (!response) {
|
||||
this.scheduleReconnect()
|
||||
return
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.failControl(new Error(`Browser control connection was rejected with HTTP ${response.status}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.socket || this.retry || this.attachments().length === 0) return
|
||||
const socket = new WebSocket(controlURL(this.server), BROWSER_CONTROL_PROTOCOL, {
|
||||
...(this.server.authorization ? { headers: { Authorization: this.server.authorization } } : {}),
|
||||
handshakeTimeout: 10_000,
|
||||
maxPayload: BrowserControlProtocol.MaxMessageBytes,
|
||||
perMessageDeflate: false,
|
||||
followRedirects: false,
|
||||
})
|
||||
this.socket = socket
|
||||
socket.on("message", (data, binary) => {
|
||||
if (socket !== this.socket) return
|
||||
this.inbound = this.inbound.then(
|
||||
() => this.receive(socket, data, binary),
|
||||
() => this.receive(socket, data, binary),
|
||||
)
|
||||
})
|
||||
socket.on("error", (error) => {
|
||||
if (socket !== this.socket) return
|
||||
const status = /^Unexpected server response: (401|403|426)$/.exec(error.message)?.[1]
|
||||
if (status) this.failControl(new Error(`Browser control connection was rejected with HTTP ${status}`))
|
||||
})
|
||||
if (!process.versions.bun) {
|
||||
socket.on("unexpected-response", (_request, response) => {
|
||||
response.resume()
|
||||
if (socket !== this.socket) return
|
||||
if (response.statusCode === 401 || response.statusCode === 403 || response.statusCode === 426) {
|
||||
this.failControl(new Error(`Browser control connection was rejected with HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
socket.terminate()
|
||||
})
|
||||
}
|
||||
socket.on("close", (code, reason) => {
|
||||
if (socket !== this.socket) return
|
||||
if (code === 1002 || code === 1007 || code === 1009) {
|
||||
this.failControl(controlCloseError(code, reason))
|
||||
return
|
||||
}
|
||||
this.socket = undefined
|
||||
this.resetConnection()
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
private async receive(socket: WebSocket, data: WebSocket.RawData, binary: boolean) {
|
||||
if (binary) return this.protocolError(socket)
|
||||
const decoded = await Effect.runPromise(BrowserControlProtocol.decodeFromServer(rawData(data))).catch(
|
||||
() => undefined,
|
||||
)
|
||||
if (!decoded || socket !== this.socket) return this.protocolError(socket)
|
||||
if (decoded.type === "browser.control.ready") {
|
||||
if (this.ready) return this.protocolError(socket)
|
||||
this.ready = true
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
if (!this.ready) return this.protocolError(socket)
|
||||
if (decoded.type === "browser.control.synced") {
|
||||
if (!this.syncing || this.syncing.revision !== decoded.revision) return this.protocolError(socket)
|
||||
this.synced = this.syncing.snapshot
|
||||
this.syncedLeases = this.syncing.leases
|
||||
this.syncing = undefined
|
||||
this.retryAttempt = 0
|
||||
this.resolveWaiters()
|
||||
this.publish()
|
||||
return
|
||||
}
|
||||
if (decoded.type === "browser.control.cancel") {
|
||||
const request = this.requests.get(decoded.requestID)
|
||||
if (!request) return
|
||||
if (request.leaseID !== decoded.leaseID) return this.protocolError(socket)
|
||||
this.requests.delete(decoded.requestID)
|
||||
request.abort.abort(new Error("Browser command was cancelled"))
|
||||
return
|
||||
}
|
||||
void this.request(socket, decoded)
|
||||
}
|
||||
|
||||
private publish() {
|
||||
const socket = this.socket
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !this.ready || this.syncing) return
|
||||
const attachments = this.attachments().map((record) => ({
|
||||
sessionID: record.sessionID,
|
||||
leaseID: record.leaseID,
|
||||
state: record.state,
|
||||
}))
|
||||
const snapshot = JSON.stringify(attachments)
|
||||
if (snapshot === this.synced) return
|
||||
const revision = this.revision++
|
||||
this.syncing = {
|
||||
revision,
|
||||
snapshot,
|
||||
leases: new Set(attachments.map((attachment) => attachmentKey(attachment.sessionID, attachment.leaseID))),
|
||||
}
|
||||
this.send(socket, { type: "browser.control.sync", revision, attachments })
|
||||
}
|
||||
|
||||
private async request(socket: WebSocket, message: BrowserControl.Request) {
|
||||
if (this.requests.has(message.requestID)) return this.protocolError(socket)
|
||||
const record = this.records.get(message.sessionID)
|
||||
if (!record?.active || record.closed || record.leaseID !== message.leaseID || !record.execute) {
|
||||
this.send(socket, {
|
||||
type: "browser.control.response",
|
||||
requestID: message.requestID,
|
||||
leaseID: message.leaseID,
|
||||
outcome: { type: "failure", code: "not_attached", message: "The browser attachment is no longer available." },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const abort = new AbortController()
|
||||
this.requests.set(message.requestID, { leaseID: message.leaseID, abort })
|
||||
const outcome = await Promise.resolve()
|
||||
.then(() => record.execute?.(message.command, { signal: abort.signal }))
|
||||
.then(
|
||||
(result): Browser.Outcome => {
|
||||
if (!Schema.is(Browser.Result)(result) || result.type !== message.command.type) {
|
||||
return { type: "failure", code: "protocol", message: "Browser driver returned an invalid command result." }
|
||||
}
|
||||
return { type: "success", result }
|
||||
},
|
||||
(error): Browser.Outcome => driverFailure(error),
|
||||
)
|
||||
if (this.requests.get(message.requestID)?.abort !== abort) return
|
||||
this.requests.delete(message.requestID)
|
||||
if (socket !== this.socket) return
|
||||
this.send(socket, {
|
||||
type: "browser.control.response",
|
||||
requestID: message.requestID,
|
||||
leaseID: message.leaseID,
|
||||
outcome,
|
||||
})
|
||||
if (outcome.type === "success") {
|
||||
const state = contractState(outcome.result.state)
|
||||
if (state) {
|
||||
record.state = state
|
||||
this.changed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private send(socket: WebSocket, message: BrowserControl.FromDesktop) {
|
||||
if (socket !== this.socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(BrowserControlProtocol.encodeFromDesktop(message), (error) => {
|
||||
if (error && socket === this.socket) socket.terminate()
|
||||
})
|
||||
}
|
||||
|
||||
private protocolError(socket: WebSocket) {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.close(1002, "Invalid browser control message")
|
||||
else socket.terminate()
|
||||
}
|
||||
|
||||
private waitForAttachment(record: AttachmentRecord, signal: AbortSignal) {
|
||||
const key = attachmentKey(record.sessionID, record.leaseID)
|
||||
if (record.closed || this.records.get(record.sessionID) !== record) {
|
||||
return Promise.reject(new Error("Browser attachment is no longer available"))
|
||||
}
|
||||
if (record.active && this.syncedLeases.has(key)) return Promise.resolve()
|
||||
if (signal.aborted) return Promise.reject(abortError(signal, "Browser attachment wait was aborted"))
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter: Waiter = {
|
||||
key,
|
||||
signal,
|
||||
resolve,
|
||||
reject,
|
||||
abort: () => {
|
||||
this.waiters.delete(waiter)
|
||||
reject(abortError(signal, "Browser attachment wait was aborted"))
|
||||
},
|
||||
}
|
||||
signal.addEventListener("abort", waiter.abort, { once: true })
|
||||
this.waiters.add(waiter)
|
||||
})
|
||||
}
|
||||
|
||||
private resolveWaiters() {
|
||||
for (const waiter of this.waiters) {
|
||||
if (!this.syncedLeases.has(waiter.key)) continue
|
||||
const record = [...this.records.values()].find(
|
||||
(item) => !item.closed && item.active && attachmentKey(item.sessionID, item.leaseID) === waiter.key,
|
||||
)
|
||||
if (!record) continue
|
||||
this.waiters.delete(waiter)
|
||||
waiter.signal.removeEventListener("abort", waiter.abort)
|
||||
waiter.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAttachmentWaiters(record: AttachmentRecord, error: Error) {
|
||||
const key = attachmentKey(record.sessionID, record.leaseID)
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.key !== key) continue
|
||||
this.waiters.delete(waiter)
|
||||
waiter.signal.removeEventListener("abort", waiter.abort)
|
||||
waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
private abortRequests(leaseID?: Browser.LeaseID) {
|
||||
for (const [requestID, request] of this.requests) {
|
||||
if (leaseID && request.leaseID !== leaseID) continue
|
||||
this.requests.delete(requestID)
|
||||
request.abort.abort(new Error("Browser command was aborted"))
|
||||
}
|
||||
}
|
||||
|
||||
private stopControl() {
|
||||
this.openingAbort?.abort()
|
||||
this.openingAbort = undefined
|
||||
this.opening = undefined
|
||||
if (this.retry) clearTimeout(this.retry)
|
||||
this.retry = undefined
|
||||
const socket = this.socket
|
||||
this.socket = undefined
|
||||
this.resetConnection()
|
||||
socket?.terminate()
|
||||
}
|
||||
|
||||
private failControl(error: Error) {
|
||||
this.failing = true
|
||||
this.stopControl()
|
||||
this.retryAttempt = 0
|
||||
const records = [...this.records.values()]
|
||||
records.forEach((record) => {
|
||||
void this.close(record, error).catch(() => undefined)
|
||||
})
|
||||
this.failing = false
|
||||
}
|
||||
|
||||
private resetConnection() {
|
||||
this.ready = false
|
||||
this.synced = undefined
|
||||
this.syncedLeases.clear()
|
||||
this.syncing = undefined
|
||||
this.revision = 0
|
||||
this.abortRequests()
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.retry || this.attachments().length === 0) return
|
||||
const delay = Math.min(5_000, 100 * 2 ** this.retryAttempt++)
|
||||
this.retry = setTimeout(() => {
|
||||
this.retry = undefined
|
||||
this.connect()
|
||||
}, delay)
|
||||
this.retry.unref()
|
||||
}
|
||||
}
|
||||
|
||||
function createDriver<Resource>(driver: BrowserDriver<Resource>, context: BrowserDriverContext, signal: AbortSignal) {
|
||||
const creating = Promise.resolve().then(() => browserDriverFactory(driver)(context))
|
||||
return new Promise<BrowserDriverInstance<Resource>>((resolve, reject) => {
|
||||
let settled = false
|
||||
const abort = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener("abort", abort)
|
||||
reject(abortError(signal, "Browser driver creation was aborted"))
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
creating.then(
|
||||
(instance) => {
|
||||
if (settled) {
|
||||
void disposeLateDriver(instance)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
signal.removeEventListener("abort", abort)
|
||||
resolve(instance)
|
||||
},
|
||||
(error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener("abort", abort)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
if (signal.aborted) abort()
|
||||
})
|
||||
}
|
||||
|
||||
async function disposeLateDriver(instance: unknown) {
|
||||
if (instance === null || typeof instance !== "object" || !("dispose" in instance)) return
|
||||
const dispose = instance.dispose
|
||||
if (typeof dispose !== "function") return
|
||||
await Promise.resolve()
|
||||
.then(() => dispose.call(instance))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function cleanup(task: () => unknown) {
|
||||
return Promise.resolve()
|
||||
.then(task)
|
||||
.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: unknown) => ({ ok: false as const, error }),
|
||||
)
|
||||
}
|
||||
|
||||
function exposedProxy(proxy: ProxyServer): BrowserProxy {
|
||||
return Object.freeze({
|
||||
url: proxy.url,
|
||||
host: proxy.host,
|
||||
port: proxy.port,
|
||||
credentials: Object.freeze({ ...proxy.credentials }),
|
||||
certificateFingerprint: proxy.certificateFingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
function contractState(state: Browser.State) {
|
||||
if (!Schema.is(Browser.State)(state)) return undefined
|
||||
return Object.freeze({ ...state })
|
||||
}
|
||||
|
||||
function driverFailure(error: unknown): Browser.Failure {
|
||||
return {
|
||||
type: "failure",
|
||||
code:
|
||||
error !== null && typeof error === "object" && "code" in error && Schema.is(Browser.ErrorCode)(error.code)
|
||||
? error.code
|
||||
: "internal",
|
||||
message: (error instanceof Error ? error.message : String(error)).slice(0, 1_024),
|
||||
}
|
||||
}
|
||||
|
||||
function endpoint(options: ClientOptions): BrowserTunnelEndpoint {
|
||||
const url = new URL(options.baseUrl)
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
|
||||
throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
|
||||
}
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? undefined
|
||||
return Object.freeze({ url: url.href, ...(authorization ? { authorization } : {}), fetch: options.fetch })
|
||||
}
|
||||
|
||||
function controlURL(endpoint: BrowserTunnelEndpoint) {
|
||||
const url = new URL(endpoint.url)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.pathname = "/api/browser/control"
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return url
|
||||
}
|
||||
|
||||
function attachmentKey(sessionID: string, leaseID: Browser.LeaseID) {
|
||||
return `${sessionID}\0${leaseID}`
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal | undefined, message: string) {
|
||||
return signal?.reason instanceof Error ? signal.reason : new Error(message)
|
||||
}
|
||||
|
||||
function controlCloseError(code: number, reason: Buffer) {
|
||||
const detail = reason.toString("utf8").slice(0, 256)
|
||||
return new Error(`Browser control connection closed with fatal code ${code}${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.RawData) {
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Browser } from "@opencode-ai/schema/browser"
|
||||
import { chromiumDriver, type ChromiumDriver, type ChromiumPort } from "./chromium.js"
|
||||
|
||||
/** Connection details for the attachment's private authenticated proxy. */
|
||||
export interface BrowserProxy {
|
||||
readonly url: string
|
||||
readonly host: string
|
||||
readonly port: number
|
||||
readonly credentials: {
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
}
|
||||
readonly certificateFingerprint: string
|
||||
}
|
||||
|
||||
export interface BrowserDriverContext {
|
||||
readonly proxy: BrowserProxy
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
export interface BrowserDriverInstance<Resource> {
|
||||
readonly resource: Resource
|
||||
readonly state: () => Browser.State
|
||||
readonly subscribe: (listener: (state: Browser.State) => void) => () => void
|
||||
readonly execute: (command: Browser.Command, options: { readonly signal: AbortSignal }) => Promise<Browser.Result>
|
||||
readonly dispose: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export type BrowserDriverFactory<Resource> = (
|
||||
context: BrowserDriverContext,
|
||||
) => Promise<BrowserDriverInstance<Resource>> | BrowserDriverInstance<Resource>
|
||||
|
||||
/** Error returned to the server when a browser adapter cannot execute a command. */
|
||||
export class BrowserDriverError extends Error {
|
||||
override readonly name = "BrowserDriverError"
|
||||
|
||||
constructor(
|
||||
readonly code: Browser.ErrorCode,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural adapter descriptor, represented by its factory function. */
|
||||
export type BrowserDriver<Resource> = BrowserDriverFactory<Resource>
|
||||
|
||||
export const BrowserDriver = {
|
||||
define<Resource>(create: BrowserDriverFactory<Resource>): BrowserDriver<Resource> {
|
||||
if (typeof create !== "function") throw new TypeError("Browser driver factory must be a function")
|
||||
return Object.freeze(create)
|
||||
},
|
||||
chromium<Resource>(
|
||||
create: (context: BrowserDriverContext) => PromiseLike<ChromiumPort<Resource>> | ChromiumPort<Resource>,
|
||||
): ChromiumDriver<Resource> {
|
||||
if (typeof create !== "function") throw new TypeError("Chromium port factory must be a function")
|
||||
return Object.freeze(chromiumDriver(create))
|
||||
},
|
||||
}
|
||||
|
||||
export function browserDriverFactory<Resource>(driver: BrowserDriver<Resource>) {
|
||||
if (typeof driver !== "function") throw new TypeError("Browser driver must be a factory function")
|
||||
return driver
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { createHash, createSign, generateKeyPairSync, randomBytes } from "node:crypto"
|
||||
|
||||
// Certificate construction follows VS Code's MIT-licensed tunnel proxy implementation.
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
|
||||
/** Creates the ephemeral certificate pinned by the browser proxy's host. */
|
||||
export function createBrowserProxyCertificate(hostname: string) {
|
||||
const keys = generateKeyPairSync("ec", {
|
||||
namedCurve: "prime256v1",
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
||||
})
|
||||
const certificate = createCertificate(keys.privateKey, keys.publicKey, hostname)
|
||||
const fingerprint = `sha256/${createHash("sha256").update(pemToDer(certificate)).digest("base64")}`
|
||||
return { key: keys.privateKey, certificate, fingerprint }
|
||||
}
|
||||
|
||||
function createCertificate(privateKey: string, publicKey: string, hostname: string) {
|
||||
const serial = randomBytes(8)
|
||||
serial[0] &= 0x7f
|
||||
if (serial.every((byte) => byte === 0)) serial[serial.length - 1] = 1
|
||||
const now = new Date(Date.now() - 60_000)
|
||||
const expires = new Date(now)
|
||||
expires.setUTCFullYear(expires.getUTCFullYear() + 1)
|
||||
const name = sequence([set([sequence([oid(Buffer.from([0x55, 0x04, 0x03])), utf8("OpenCode Browser Proxy")])])])
|
||||
const signatureAlgorithm = sequence([oid(Buffer.from([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02]))])
|
||||
const extensions = Buffer.concat([
|
||||
Buffer.from([0xa3]),
|
||||
lengthPrefix(
|
||||
sequence([
|
||||
sequence([
|
||||
oid(Buffer.from([0x55, 0x1d, 0x11])),
|
||||
octetString(
|
||||
sequence([tagged(0x82, Buffer.from(hostname, "ascii")), tagged(0x87, Buffer.from([127, 0, 0, 1]))]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
])
|
||||
const body = sequence([
|
||||
Buffer.from([0xa0, 0x03, 0x02, 0x01, 0x02]),
|
||||
integer(serial),
|
||||
signatureAlgorithm,
|
||||
name,
|
||||
sequence([time(now), time(expires)]),
|
||||
name,
|
||||
pemToDer(publicKey),
|
||||
extensions,
|
||||
])
|
||||
const signer = createSign("SHA256")
|
||||
signer.update(body)
|
||||
const signature = signer.sign(privateKey)
|
||||
const certificate = sequence([
|
||||
body,
|
||||
signatureAlgorithm,
|
||||
Buffer.concat([Buffer.from([0x03]), length(signature.length + 1), Buffer.from([0]), signature]),
|
||||
])
|
||||
const encoded = certificate
|
||||
.toString("base64")
|
||||
.match(/.{1,64}/g)
|
||||
?.join("\n")
|
||||
if (!encoded) throw new Error("Failed to encode browser proxy certificate")
|
||||
return `-----BEGIN CERTIFICATE-----\n${encoded}\n-----END CERTIFICATE-----\n`
|
||||
}
|
||||
|
||||
function pemToDer(pem: string) {
|
||||
return Buffer.from(pem.replace(/-----[A-Z ]+-----/g, "").replaceAll(/\s/g, ""), "base64")
|
||||
}
|
||||
|
||||
function length(value: number) {
|
||||
if (value < 0x80) return Buffer.from([value])
|
||||
if (value < 0x100) return Buffer.from([0x81, value])
|
||||
if (value < 0x10000) return Buffer.from([0x82, value >> 8, value & 0xff])
|
||||
if (value < 0x1000000) return Buffer.from([0x83, value >> 16, (value >> 8) & 0xff, value & 0xff])
|
||||
throw new RangeError(`ASN.1 value is too large: ${value}`)
|
||||
}
|
||||
|
||||
function lengthPrefix(value: Buffer) {
|
||||
return Buffer.concat([length(value.length), value])
|
||||
}
|
||||
|
||||
function tagged(tag: number, value: Buffer) {
|
||||
return Buffer.concat([Buffer.from([tag]), lengthPrefix(value)])
|
||||
}
|
||||
|
||||
function sequence(items: ReadonlyArray<Buffer>) {
|
||||
return tagged(0x30, Buffer.concat(items))
|
||||
}
|
||||
|
||||
function set(items: ReadonlyArray<Buffer>) {
|
||||
return tagged(0x31, Buffer.concat(items))
|
||||
}
|
||||
|
||||
function integer(value: Buffer) {
|
||||
const first = value.findIndex(
|
||||
(byte, index) => byte !== 0 || index === value.length - 1 || (value[index + 1] & 0x80) !== 0,
|
||||
)
|
||||
const canonical = value.subarray(first < 0 ? value.length - 1 : first)
|
||||
return tagged(0x02, canonical[0] & 0x80 ? Buffer.concat([Buffer.from([0]), canonical]) : canonical)
|
||||
}
|
||||
|
||||
function oid(value: Buffer) {
|
||||
return tagged(0x06, value)
|
||||
}
|
||||
|
||||
function utf8(value: string) {
|
||||
return tagged(0x0c, Buffer.from(value, "utf8"))
|
||||
}
|
||||
|
||||
function octetString(value: Buffer) {
|
||||
return tagged(0x04, value)
|
||||
}
|
||||
|
||||
function time(value: Date) {
|
||||
const encoded = value.toISOString().replaceAll(/[-:T]/g, "")
|
||||
if (value.getUTCFullYear() < 2050) return tagged(0x17, Buffer.from(encoded.slice(2, 14) + "Z", "ascii"))
|
||||
return tagged(0x18, Buffer.from(encoded.slice(0, 14) + "Z", "ascii"))
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { once } from "node:events"
|
||||
import { Agent, request, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http"
|
||||
import { createServer } from "node:https"
|
||||
import { Duplex } from "node:stream"
|
||||
import { createBrowserProxyCertificate } from "./proxy-certificate.js"
|
||||
|
||||
export type BrowserProxyConnector = (target: BrowserTunnel.Target, signal: AbortSignal) => Promise<Duplex>
|
||||
|
||||
/** Starts an authenticated TLS forward proxy whose target sockets can only come from the tunnel connector. */
|
||||
export async function createBrowserProxy(input: { readonly connect: BrowserProxyConnector }) {
|
||||
const candidate = `opencode-${randomBytes(16).toString("hex")}.localhost`
|
||||
const certificate = createBrowserProxyCertificate(candidate)
|
||||
const username = randomBytes(16).toString("hex")
|
||||
const password = randomBytes(32).toString("hex")
|
||||
const expectedAuthorization = Buffer.from(`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`)
|
||||
const clients = new Set<Duplex>()
|
||||
const tunnels = new Set<Duplex>()
|
||||
const pending = new Set<AbortController>()
|
||||
const limiter = connectionLimiter(6, 64)
|
||||
let closed = false
|
||||
|
||||
const connect = async (target: BrowserTunnel.Target, signal?: AbortSignal) => {
|
||||
if (closed) throw new Error("Browser proxy is closed")
|
||||
const abort = new AbortController()
|
||||
const cancel = () => abort.abort(signal?.reason)
|
||||
const done = () => {
|
||||
pending.delete(abort)
|
||||
signal?.removeEventListener("abort", cancel)
|
||||
}
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
if (signal?.aborted) cancel()
|
||||
pending.add(abort)
|
||||
return limiter
|
||||
.run(() => input.connect(target, abort.signal), abort.signal)
|
||||
.then(
|
||||
(tunnel) => {
|
||||
done()
|
||||
if (closed || abort.signal.aborted) {
|
||||
tunnel.destroy()
|
||||
throw abort.signal.reason ?? new Error("Browser proxy is closed")
|
||||
}
|
||||
tunnels.add(tunnel)
|
||||
tunnel.once("close", () => tunnels.delete(tunnel))
|
||||
tunnel.on("error", () => tunnel.destroy())
|
||||
return tunnel
|
||||
},
|
||||
(error) => {
|
||||
done()
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const authorized = (header: string | undefined) => {
|
||||
if (!header) return false
|
||||
const actual = Buffer.from(header)
|
||||
return actual.length === expectedAuthorization.length && timingSafeEqual(actual, expectedAuthorization)
|
||||
}
|
||||
|
||||
const server = createServer(
|
||||
{ key: certificate.key, cert: certificate.certificate, allowHalfOpen: true, maxHeaderSize: 64 * 1_024 },
|
||||
(incoming, response) => {
|
||||
void forward(incoming, response, connect, authorized).catch(() => response.destroy())
|
||||
},
|
||||
)
|
||||
server.requestTimeout = 30_000
|
||||
server.headersTimeout = 10_000
|
||||
server.keepAliveTimeout = 5_000
|
||||
server.maxRequestsPerSocket = 1_000
|
||||
server.on("connection", (socket) => {
|
||||
clients.add(socket)
|
||||
socket.once("close", () => clients.delete(socket))
|
||||
})
|
||||
server.on("connect", (incoming, socket, head) => {
|
||||
clients.add(socket)
|
||||
socket.once("close", () => clients.delete(socket))
|
||||
let established = false
|
||||
void (async () => {
|
||||
if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
|
||||
socket.end(
|
||||
'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="OpenCode Browser Proxy"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
|
||||
)
|
||||
return
|
||||
}
|
||||
const parsed = authority(incoming.url ?? "", 443)
|
||||
if (!parsed) {
|
||||
socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
return
|
||||
}
|
||||
const abort = new AbortController()
|
||||
let tunnel: Duplex | undefined
|
||||
const buffered: Uint8Array[] = head.byteLength ? [head] : []
|
||||
let bufferedBytes = head.byteLength
|
||||
const cancel = () => {
|
||||
abort.abort(new Error("Browser proxy client closed during tunnel setup"))
|
||||
tunnel?.destroy()
|
||||
}
|
||||
const onReadable = () => {
|
||||
while (true) {
|
||||
const data: unknown = socket.read()
|
||||
if (data === null) break
|
||||
if (!(data instanceof Uint8Array)) {
|
||||
cancel()
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
bufferedBytes += data.byteLength
|
||||
if (bufferedBytes > 256 * 1_024) {
|
||||
cancel()
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
buffered.push(data)
|
||||
}
|
||||
if (socket.readableEnded) cancel()
|
||||
}
|
||||
incoming.once("aborted", cancel)
|
||||
incoming.once("error", cancel)
|
||||
socket.once("close", cancel)
|
||||
socket.once("end", cancel)
|
||||
socket.once("error", cancel)
|
||||
socket.on("readable", onReadable)
|
||||
socket.pause()
|
||||
tunnel = await connect(target(parsed.host, parsed.port), abort.signal)
|
||||
onReadable()
|
||||
if (socket.destroyed || socket.readableEnded || abort.signal.aborted) {
|
||||
socket.off("readable", onReadable)
|
||||
tunnel.destroy()
|
||||
return
|
||||
}
|
||||
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
established = true
|
||||
for (const data of buffered) {
|
||||
if (!tunnel.write(data)) await once(tunnel, "drain", { signal: abort.signal })
|
||||
}
|
||||
socket.off("readable", onReadable)
|
||||
bridge(socket, tunnel)
|
||||
incoming.off("aborted", cancel)
|
||||
incoming.off("error", cancel)
|
||||
socket.off("close", cancel)
|
||||
socket.off("end", cancel)
|
||||
socket.off("error", cancel)
|
||||
socket.resume()
|
||||
})().catch(() => {
|
||||
if (socket.destroyed) return
|
||||
if (established) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||
})
|
||||
})
|
||||
server.on("error", () => undefined)
|
||||
server.on("clientError", (_error, socket) => {
|
||||
if (!socket.destroyed) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off("listening", onListening)
|
||||
reject(error)
|
||||
}
|
||||
const onListening = () => {
|
||||
server.off("error", onError)
|
||||
resolve()
|
||||
}
|
||||
server.once("error", onError)
|
||||
server.once("listening", onListening)
|
||||
server.listen(0, "127.0.0.1")
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close()
|
||||
throw new Error("Browser proxy did not bind a TCP address")
|
||||
}
|
||||
const host = candidate
|
||||
let closing: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
url: `https://${host}:${address.port}`,
|
||||
host,
|
||||
port: address.port,
|
||||
credentials: { username, password },
|
||||
certificateFingerprint: certificate.fingerprint,
|
||||
close() {
|
||||
if (closing) return closing
|
||||
closed = true
|
||||
pending.forEach((abort) => abort.abort())
|
||||
pending.clear()
|
||||
limiter.close()
|
||||
tunnels.forEach((tunnel) => tunnel.destroy())
|
||||
tunnels.clear()
|
||||
clients.forEach((client) => client.destroy())
|
||||
clients.clear()
|
||||
closing = new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
return closing
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function forward(
|
||||
incoming: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
connect: (target: BrowserTunnel.Target, signal?: AbortSignal) => Promise<Duplex>,
|
||||
authorized: (header: string | undefined) => boolean,
|
||||
) {
|
||||
if (!authorized(singleHeader(incoming.headers["proxy-authorization"]))) {
|
||||
response.writeHead(407, { "Proxy-Authenticate": 'Basic realm="OpenCode Browser Proxy"' })
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const url = (() => {
|
||||
try {
|
||||
return new URL(incoming.url ?? "")
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!url || url.protocol !== "http:" || !url.hostname || url.username || url.password) {
|
||||
response.writeHead(400)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
const port = url.port ? Number(url.port) : 80
|
||||
const abort = new AbortController()
|
||||
let tunnel: Duplex | undefined
|
||||
let agent: Agent | undefined
|
||||
const cancel = () => {
|
||||
abort.abort(new Error("Browser proxy downstream closed"))
|
||||
tunnel?.destroy()
|
||||
}
|
||||
incoming.once("aborted", cancel)
|
||||
incoming.once("error", cancel)
|
||||
incoming.socket.once("close", cancel)
|
||||
incoming.socket.once("end", cancel)
|
||||
incoming.socket.once("error", cancel)
|
||||
response.once("close", cancel)
|
||||
response.once("error", cancel)
|
||||
try {
|
||||
tunnel = await connect(target(normalizeHostname(url.hostname), port), abort.signal)
|
||||
const headers = forwardedHeaders(incoming.headers)
|
||||
headers.host = url.host
|
||||
headers.connection = "close"
|
||||
const connection = tunnel
|
||||
agent = new Agent({ keepAlive: false, maxSockets: 1, noDelay: false })
|
||||
agent.createConnection = () => connection
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upstream = request(
|
||||
{
|
||||
agent,
|
||||
hostname: url.hostname,
|
||||
port,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: incoming.method,
|
||||
headers,
|
||||
maxHeaderSize: 64 * 1_024,
|
||||
signal: abort.signal,
|
||||
},
|
||||
(result) => {
|
||||
const headers = forwardedHeaders(result.headers)
|
||||
headers.connection = "close"
|
||||
response.writeHead(result.statusCode ?? 502, result.statusMessage, headers)
|
||||
result.once("error", reject)
|
||||
result.once("aborted", () => reject(new Error("Browser proxy target response was aborted")))
|
||||
result.once("end", resolve)
|
||||
result.pipe(response)
|
||||
},
|
||||
)
|
||||
upstream.once("continue", () => response.writeContinue())
|
||||
upstream.once("error", reject)
|
||||
incoming.pipe(upstream)
|
||||
})
|
||||
} finally {
|
||||
incoming.off("aborted", cancel)
|
||||
incoming.off("error", cancel)
|
||||
incoming.socket.off("close", cancel)
|
||||
incoming.socket.off("end", cancel)
|
||||
incoming.socket.off("error", cancel)
|
||||
response.off("close", cancel)
|
||||
response.off("error", cancel)
|
||||
agent?.destroy()
|
||||
tunnel?.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
function forwardedHeaders(input: IncomingHttpHeaders) {
|
||||
const headers = { ...input }
|
||||
const named = singleHeader(headers.connection)
|
||||
?.split(",")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
named?.forEach((name) => delete headers[name])
|
||||
;[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
].forEach((name) => delete headers[name])
|
||||
return headers
|
||||
}
|
||||
|
||||
function bridge(client: Duplex, tunnel: Duplex) {
|
||||
client.on("error", () => tunnel.destroy())
|
||||
tunnel.on("error", () => client.destroy())
|
||||
client.on("close", () => {
|
||||
if (!tunnel.destroyed) tunnel.destroy()
|
||||
})
|
||||
tunnel.on("close", () => {
|
||||
if (!client.destroyed) client.destroy()
|
||||
})
|
||||
client.pipe(tunnel)
|
||||
tunnel.pipe(client)
|
||||
}
|
||||
|
||||
function authority(value: string, defaultPort: number) {
|
||||
const bracket = /^\[([^\]]+)](?::([0-9]+))?$/.exec(value)
|
||||
if (bracket) return validAuthority(bracket[1], bracket[2], defaultPort)
|
||||
if (value.includes("[")) return undefined
|
||||
const separator = value.lastIndexOf(":")
|
||||
if (separator < 0) return validAuthority(value, undefined, defaultPort)
|
||||
if (value.slice(0, separator).includes(":")) return undefined
|
||||
return validAuthority(value.slice(0, separator), value.slice(separator + 1), defaultPort)
|
||||
}
|
||||
|
||||
function validAuthority(host: string, value: string | undefined, defaultPort: number) {
|
||||
if (!host || (value !== undefined && !/^[0-9]+$/.test(value))) return undefined
|
||||
const port = value === undefined ? defaultPort : Number(value)
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return undefined
|
||||
try {
|
||||
return { host: BrowserTunnel.Host.make(host), port: BrowserTunnel.Port.make(port) }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function target(host: string, port: number): BrowserTunnel.Target {
|
||||
return { host: BrowserTunnel.Host.make(host), port: BrowserTunnel.Port.make(port) }
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname
|
||||
}
|
||||
|
||||
function singleHeader(value: string | ReadonlyArray<string> | undefined) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function connectionLimiter(limit: number, capacity: number) {
|
||||
const queue: Array<() => void> = []
|
||||
let active = 0
|
||||
let closed = false
|
||||
const drain = (): void => {
|
||||
if (active >= limit) return
|
||||
const start = queue.shift()
|
||||
if (!start) return
|
||||
start()
|
||||
drain()
|
||||
}
|
||||
return {
|
||||
run<Result>(task: () => Promise<Result>, signal: AbortSignal) {
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
if (closed || signal.aborted) {
|
||||
reject(signal.reason ?? new Error("Browser proxy connection was cancelled"))
|
||||
return
|
||||
}
|
||||
if (active >= limit && queue.length >= capacity) {
|
||||
reject(new Error("Browser proxy connection queue is full"))
|
||||
return
|
||||
}
|
||||
const start = () => {
|
||||
signal.removeEventListener("abort", cancel)
|
||||
if (closed || signal.aborted) {
|
||||
reject(signal.reason ?? new Error("Browser proxy connection was cancelled"))
|
||||
return
|
||||
}
|
||||
active++
|
||||
void Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
active--
|
||||
drain()
|
||||
})
|
||||
}
|
||||
const cancel = () => {
|
||||
const index = queue.indexOf(start)
|
||||
if (index === -1) return
|
||||
queue.splice(index, 1)
|
||||
reject(signal.reason ?? new Error("Browser proxy connection was cancelled"))
|
||||
}
|
||||
if (active < limit) start()
|
||||
else {
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
queue.push(start)
|
||||
}
|
||||
})
|
||||
},
|
||||
close() {
|
||||
closed = true
|
||||
queue.splice(0).forEach((start) => start())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Duplex } from "node:stream"
|
||||
import WebSocket from "ws"
|
||||
|
||||
export type BrowserTunnelEndpoint = {
|
||||
readonly url: string
|
||||
readonly authorization?: string
|
||||
readonly fetch?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
export type BrowserTunnelOpen = {
|
||||
readonly endpoint: BrowserTunnelEndpoint
|
||||
readonly sessionID: Session.ID
|
||||
readonly leaseID: Browser.LeaseID
|
||||
readonly target: BrowserTunnel.Target
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export class BrowserTunnelError extends Error {
|
||||
constructor(
|
||||
readonly code: BrowserTunnel.OpenErrorCode | BrowserTunnel.ResetCode | "transport",
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "BrowserTunnelError"
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens one flow-controlled WebSocket tunnel and exposes it as a single TCP-like stream. */
|
||||
export async function openBrowserTunnel(input: BrowserTunnelOpen): Promise<Duplex> {
|
||||
const tunnel = new BrowserTunnelStream(input)
|
||||
await tunnel.opened
|
||||
return tunnel
|
||||
}
|
||||
|
||||
class BrowserTunnelStream extends Duplex {
|
||||
readonly opened: Promise<void>
|
||||
readonly connecting = false
|
||||
private resolveOpened!: () => void
|
||||
private rejectOpened!: (error: Error) => void
|
||||
private readonly socket: WebSocket
|
||||
private readonly signal?: AbortSignal
|
||||
private state: "ready" | "opening" | "open" | "closed" = "ready"
|
||||
private inbound = Promise.resolve()
|
||||
private timer?: ReturnType<typeof setTimeout>
|
||||
private sendWindowBytes = 0
|
||||
private sendWindowFrames = 0
|
||||
private readonly outstanding: number[] = []
|
||||
private activeWrite?: { readonly data: Buffer; offset: number; readonly callback: (error?: Error | null) => void }
|
||||
private sending = false
|
||||
private receiveWindowBytes = BrowserTunnelProtocol.InitialWindowBytes
|
||||
private receiveWindowFrames = BrowserTunnelProtocol.InitialFrameWindow
|
||||
private heldWindowBytes = 0
|
||||
private heldWindowFrames = 0
|
||||
private paused = false
|
||||
private localEnded = false
|
||||
private remoteEnded = false
|
||||
private remoteReset = false
|
||||
private resetCode: BrowserTunnel.ResetCode = "cancelled"
|
||||
|
||||
constructor(input: BrowserTunnelOpen) {
|
||||
super({
|
||||
allowHalfOpen: true,
|
||||
readableHighWaterMark: BrowserTunnelProtocol.InitialWindowBytes,
|
||||
writableHighWaterMark: BrowserTunnelProtocol.InitialWindowBytes,
|
||||
})
|
||||
this.opened = new Promise<void>((resolve, reject) => {
|
||||
this.resolveOpened = resolve
|
||||
this.rejectOpened = reject
|
||||
})
|
||||
this.on("error", () => undefined)
|
||||
this.signal = input.signal
|
||||
this.socket = new WebSocket(endpointURL(input.endpoint), BROWSER_TUNNEL_PROTOCOL, {
|
||||
...(input.endpoint.authorization ? { headers: { Authorization: input.endpoint.authorization } } : {}),
|
||||
handshakeTimeout: 10_000,
|
||||
maxPayload: Math.max(BrowserTunnelProtocol.MaxDataBytes, BrowserTunnelProtocol.MaxControlBytes) + 1,
|
||||
perMessageDeflate: false,
|
||||
followRedirects: false,
|
||||
})
|
||||
this.timer = setTimeout(
|
||||
() => this.fail(new BrowserTunnelError("transport", "Browser tunnel handshake timed out.")),
|
||||
15_000,
|
||||
)
|
||||
this.timer.unref()
|
||||
this.socket.on("message", (data, binary) => {
|
||||
this.socket.pause()
|
||||
const processing = this.inbound.then(
|
||||
() => this.receive(input, data, binary),
|
||||
() => this.receive(input, data, binary),
|
||||
)
|
||||
this.inbound = processing
|
||||
void processing.then(
|
||||
() => {
|
||||
if (this.inbound === processing && !this.paused && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.resume()
|
||||
}
|
||||
},
|
||||
(error) =>
|
||||
this.fail(new BrowserTunnelError("transport", error instanceof Error ? error.message : String(error))),
|
||||
)
|
||||
})
|
||||
this.socket.on("error", (error) => this.fail(new BrowserTunnelError("transport", error.message)))
|
||||
this.socket.on("close", (code, reason) => {
|
||||
if (this.state === "closed") return
|
||||
if (this.localEnded && this.remoteEnded) {
|
||||
this.state = "closed"
|
||||
this.destroy()
|
||||
return
|
||||
}
|
||||
this.fail(new BrowserTunnelError("transport", `Browser tunnel closed (${code}): ${reason.toString()}`))
|
||||
})
|
||||
this.signal?.addEventListener("abort", this.onAbort, { once: true })
|
||||
if (this.signal?.aborted) this.onAbort()
|
||||
}
|
||||
|
||||
override _read() {
|
||||
if (!this.paused) return
|
||||
this.paused = false
|
||||
this.releaseReceiveWindow()
|
||||
if (this.socket.readyState === WebSocket.OPEN) this.socket.resume()
|
||||
}
|
||||
|
||||
override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : chunk
|
||||
if (this.state !== "open" || this.localEnded) {
|
||||
callback(new BrowserTunnelError("transport", "Browser tunnel is not writable."))
|
||||
return
|
||||
}
|
||||
if (data.byteLength === 0) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
this.activeWrite = { data, offset: 0, callback }
|
||||
this.pumpWrite()
|
||||
}
|
||||
|
||||
override _final(callback: (error?: Error | null) => void) {
|
||||
if (this.state !== "open" || this.localEnded) {
|
||||
callback(new BrowserTunnelError("transport", "Browser tunnel is not writable."))
|
||||
return
|
||||
}
|
||||
this.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), (error) => {
|
||||
if (error) {
|
||||
callback(error)
|
||||
this.fail(new BrowserTunnelError("transport", error.message))
|
||||
return
|
||||
}
|
||||
this.localEnded = true
|
||||
callback()
|
||||
this.finish()
|
||||
})
|
||||
}
|
||||
|
||||
override _destroy(error: Error | null, callback: (error?: Error | null) => void) {
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.signal?.removeEventListener("abort", this.onAbort)
|
||||
if (this.state === "closed") {
|
||||
callback(error)
|
||||
return
|
||||
}
|
||||
const opened = this.state === "open"
|
||||
this.state = "closed"
|
||||
const pending = this.activeWrite
|
||||
this.activeWrite = undefined
|
||||
pending?.callback(error ?? new BrowserTunnelError("cancelled", "Browser tunnel was closed."))
|
||||
if (
|
||||
!opened ||
|
||||
this.remoteReset ||
|
||||
this.socket.readyState !== WebSocket.OPEN ||
|
||||
(this.localEnded && this.remoteEnded)
|
||||
) {
|
||||
this.socket.terminate()
|
||||
callback(error)
|
||||
return
|
||||
}
|
||||
this.socket.send(
|
||||
BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.reset", code: this.resetCode }),
|
||||
{ binary: true },
|
||||
() => {
|
||||
this.socket.close(1000)
|
||||
},
|
||||
)
|
||||
const terminate = setTimeout(() => this.socket.terminate(), 1_000)
|
||||
terminate.unref()
|
||||
callback(error)
|
||||
}
|
||||
|
||||
setKeepAlive() {
|
||||
return this
|
||||
}
|
||||
|
||||
setNoDelay() {
|
||||
return this
|
||||
}
|
||||
|
||||
setTimeout(_timeout: number, callback?: () => void) {
|
||||
if (callback) this.once("timeout", callback)
|
||||
return this
|
||||
}
|
||||
|
||||
ref() {
|
||||
return this
|
||||
}
|
||||
|
||||
unref() {
|
||||
return this
|
||||
}
|
||||
|
||||
private async receive(input: BrowserTunnelOpen, data: WebSocket.RawData, binary: boolean) {
|
||||
if (!binary) return this.protocolFailure("Browser tunnel frames must be binary.")
|
||||
const frame = await Effect.runPromise(BrowserTunnelProtocol.decodeFromServer(rawData(data))).catch(() => undefined)
|
||||
if (!frame || this.state === "closed") return this.protocolFailure("Browser tunnel frame is invalid.")
|
||||
if (frame.type === "data") {
|
||||
if (this.state !== "open" || this.remoteEnded) return this.protocolFailure("Unexpected browser tunnel data.")
|
||||
if (frame.data.byteLength > this.receiveWindowBytes || this.receiveWindowFrames === 0) {
|
||||
return this.protocolFailure("Browser tunnel receive window was exceeded.")
|
||||
}
|
||||
this.receiveWindowBytes -= frame.data.byteLength
|
||||
this.receiveWindowFrames--
|
||||
this.heldWindowBytes += frame.data.byteLength
|
||||
this.heldWindowFrames++
|
||||
if (!this.push(frame.data)) {
|
||||
this.paused = true
|
||||
return
|
||||
}
|
||||
this.releaseReceiveWindow()
|
||||
return
|
||||
}
|
||||
const message = frame.message
|
||||
if (message.type === "browser.tunnel.ready") {
|
||||
if (this.state !== "ready") return this.protocolFailure("Browser tunnel sent duplicate ready.")
|
||||
this.state = "opening"
|
||||
this.send(
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID: input.sessionID,
|
||||
leaseID: input.leaseID,
|
||||
target: input.target,
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
(error) => {
|
||||
if (error) this.fail(new BrowserTunnelError("transport", error.message))
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
if (message.type === "browser.tunnel.opened") {
|
||||
if (this.state !== "opening") return this.protocolFailure("Unexpected browser tunnel opened message.")
|
||||
this.state = "open"
|
||||
this.sendWindowBytes = message.receiveWindow
|
||||
this.sendWindowFrames = message.receiveFrames
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.resolveOpened()
|
||||
this.pumpWrite()
|
||||
return
|
||||
}
|
||||
if (message.type === "browser.tunnel.rejected") {
|
||||
if (this.state !== "opening") return this.protocolFailure("Unexpected browser tunnel rejection.")
|
||||
this.fail(new BrowserTunnelError(message.code, message.message))
|
||||
return
|
||||
}
|
||||
if (message.type === "browser.tunnel.window") {
|
||||
if (this.state !== "open" || message.frames > this.outstanding.length) {
|
||||
return this.protocolFailure("Unexpected browser tunnel window update.")
|
||||
}
|
||||
const bytes = this.outstanding.slice(0, message.frames).reduce((total, size) => total + size, 0)
|
||||
if (bytes !== message.bytes)
|
||||
return this.protocolFailure("Browser tunnel window update does not match sent frames.")
|
||||
this.outstanding.splice(0, message.frames)
|
||||
this.sendWindowBytes += message.bytes
|
||||
this.sendWindowFrames += message.frames
|
||||
this.pumpWrite()
|
||||
return
|
||||
}
|
||||
if (message.type === "browser.tunnel.end") {
|
||||
if (this.state !== "open" || this.remoteEnded) return this.protocolFailure("Unexpected browser tunnel end.")
|
||||
this.remoteEnded = true
|
||||
this.push(null)
|
||||
this.finish()
|
||||
return
|
||||
}
|
||||
this.remoteReset = true
|
||||
this.fail(new BrowserTunnelError(message.code, `Browser tunnel was reset: ${message.code}`))
|
||||
}
|
||||
|
||||
private pumpWrite() {
|
||||
const write = this.activeWrite
|
||||
if (!write || this.sending || this.state !== "open") return
|
||||
if (this.sendWindowBytes === 0 || this.sendWindowFrames === 0) return
|
||||
const size = Math.min(
|
||||
BrowserTunnelProtocol.MaxDataBytes,
|
||||
this.sendWindowBytes,
|
||||
write.data.byteLength - write.offset,
|
||||
)
|
||||
this.sendWindowBytes -= size
|
||||
this.sendWindowFrames--
|
||||
this.outstanding.push(size)
|
||||
this.sending = true
|
||||
this.send(BrowserTunnelProtocol.data(write.data.subarray(write.offset, write.offset + size)), (error) => {
|
||||
this.sending = false
|
||||
if (this.activeWrite !== write) return
|
||||
if (error) {
|
||||
this.activeWrite = undefined
|
||||
write.callback(error)
|
||||
this.fail(new BrowserTunnelError("transport", error.message))
|
||||
return
|
||||
}
|
||||
write.offset += size
|
||||
if (write.offset === write.data.byteLength) {
|
||||
this.activeWrite = undefined
|
||||
write.callback()
|
||||
return
|
||||
}
|
||||
this.pumpWrite()
|
||||
})
|
||||
}
|
||||
|
||||
private releaseReceiveWindow() {
|
||||
if (this.heldWindowFrames === 0 || this.state !== "open") return
|
||||
const bytes = this.heldWindowBytes
|
||||
const frames = this.heldWindowFrames
|
||||
this.heldWindowBytes = 0
|
||||
this.heldWindowFrames = 0
|
||||
this.receiveWindowBytes += bytes
|
||||
this.receiveWindowFrames += frames
|
||||
this.send(
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(bytes),
|
||||
frames: BrowserTunnel.FrameWindow.make(frames),
|
||||
}),
|
||||
(error) => {
|
||||
if (error) this.fail(new BrowserTunnelError("transport", error.message))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private send(frame: Uint8Array, callback: (error?: Error) => void) {
|
||||
if (this.socket.readyState !== WebSocket.OPEN) {
|
||||
callback(new Error("Browser tunnel WebSocket is not open."))
|
||||
return
|
||||
}
|
||||
this.socket.send(frame, { binary: true }, callback)
|
||||
}
|
||||
|
||||
private protocolFailure(message: string) {
|
||||
this.fail(new BrowserTunnelError("protocol_error", message))
|
||||
}
|
||||
|
||||
private fail(error: BrowserTunnelError) {
|
||||
if (this.state === "closed") return
|
||||
if (Schema.is(BrowserTunnel.ResetCode)(error.code)) this.resetCode = error.code
|
||||
if (this.state !== "open") this.rejectOpened(error)
|
||||
this.destroy(error)
|
||||
}
|
||||
|
||||
private finish() {
|
||||
if (!this.localEnded || !this.remoteEnded || this.socket.readyState !== WebSocket.OPEN) return
|
||||
this.socket.close(1000)
|
||||
}
|
||||
|
||||
private readonly onAbort = () => {
|
||||
this.fail(new BrowserTunnelError("cancelled", "Browser tunnel was cancelled."))
|
||||
}
|
||||
}
|
||||
|
||||
function endpointURL(endpoint: BrowserTunnelEndpoint) {
|
||||
const url = new URL(endpoint.url)
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
|
||||
throw new TypeError("Browser server endpoint must be an HTTP URL without embedded credentials")
|
||||
}
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.pathname = "/api/browser/tunnel"
|
||||
url.search = ""
|
||||
url.hash = ""
|
||||
return url
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.RawData) {
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { OpenCode } from "../promise/generated/index.js"
|
||||
import { createBrowserClient } from "./browser/client.js"
|
||||
|
||||
export type ClientOptions = OpenCode.ClientOptions
|
||||
export type RequestOptions = OpenCode.RequestOptions
|
||||
|
||||
/** Creates the Promise client with Node-only browser attachment support. */
|
||||
export function make(options: ClientOptions) {
|
||||
return {
|
||||
...OpenCode.make(options),
|
||||
browser: createBrowserClient(options),
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export { ClientError, type ClientErrorReason } from "../promise/generated/client-error.js"
|
||||
export * from "../promise/generated/types.js"
|
||||
export type {
|
||||
AgentApi,
|
||||
CatalogApi,
|
||||
CommandApi,
|
||||
EventApi,
|
||||
IntegrationApi,
|
||||
ModelApi,
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "../promise/api.js"
|
||||
export * as OpenCode from "./client.js"
|
||||
export { Browser } from "@opencode-ai/schema/browser"
|
||||
export { BrowserDriver, BrowserDriverError } from "./browser/driver.js"
|
||||
export type {
|
||||
BrowserDriverContext,
|
||||
BrowserDriverFactory,
|
||||
BrowserDriverInstance,
|
||||
BrowserProxy,
|
||||
} from "./browser/driver.js"
|
||||
export type { ChromiumController, ChromiumDriver, ChromiumPort } from "./browser/chromium.js"
|
||||
export type { BrowserAttachment, BrowserAttachOptions, BrowserClient } from "./browser/client.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "../promise/generated/types.js"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./client.js").make>
|
||||
@@ -7,8 +7,6 @@ import type {
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
SessionListInput,
|
||||
@@ -213,8 +211,8 @@ import type {
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
} from "./types.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
export interface ClientOptions {
|
||||
readonly baseUrl: string
|
||||
@@ -405,18 +403,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
|
||||
request<AgentGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
plugin: {
|
||||
list: (input?: PluginListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { ClientError, type ClientErrorReason } from "./client-error.js"
|
||||
export * as OpenCode from "./client.js"
|
||||
export * from "./types.js"
|
||||
export { ClientError, type ClientErrorReason } from "./client-error"
|
||||
export * as OpenCode from "./client"
|
||||
export * from "./types"
|
||||
|
||||
@@ -6,11 +6,11 @@ export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
export type ProviderSettings = { [x: string]: JsonValue }
|
||||
|
||||
export type AgentColor = string
|
||||
|
||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
export type PermissionV2Effect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginInfo = { id: string }
|
||||
|
||||
@@ -110,7 +110,7 @@ export type SessionMessageToolStateRunning = {
|
||||
|
||||
export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
|
||||
@@ -163,8 +163,6 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
|
||||
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
|
||||
|
||||
export type SessionMessageProviderState8 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState9 = { [x: string]: any }
|
||||
@@ -177,24 +175,24 @@ export type ModelCapabilities = { tools: boolean; input: Array<string>; output:
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: { [x: string]: any }
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type ProviderInfo = {
|
||||
export type ProviderV2Info = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
disabled?: boolean
|
||||
package: string
|
||||
settings?: { [x: string]: any }
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||
@@ -301,7 +299,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
|
||||
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
|
||||
export type PermissionV2Source = { type: "tool"; messageID: string; callID: string }
|
||||
|
||||
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
|
||||
|
||||
@@ -325,9 +323,9 @@ export type FileDiffLegacyInfo = {
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PermissionV1Action = "allow" | "deny" | "ask"
|
||||
export type PermissionAction = "allow" | "deny" | "ask"
|
||||
|
||||
export type SessionV1JSONSchema = { [x: string]: any }
|
||||
export type JSONSchema = { [x: string]: any }
|
||||
|
||||
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
|
||||
@@ -358,7 +356,7 @@ export type APIError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1TextPart = {
|
||||
export type TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -370,7 +368,7 @@ export type SessionV1TextPart = {
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1SubtaskPart = {
|
||||
export type SubtaskPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -382,7 +380,7 @@ export type SessionV1SubtaskPart = {
|
||||
command?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1ReasoningPart = {
|
||||
export type ReasoningPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -392,13 +390,13 @@ export type SessionV1ReasoningPart = {
|
||||
time: { start: number; end?: number | undefined }
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
|
||||
export type FilePartSourceText = { value: string; start: number; end: number }
|
||||
|
||||
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
export type Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
|
||||
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
export type ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
|
||||
export type SessionV1ToolStateRunning = {
|
||||
export type ToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
title?: string | undefined
|
||||
@@ -406,7 +404,7 @@ export type SessionV1ToolStateRunning = {
|
||||
time: { start: number }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateError = {
|
||||
export type ToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: string
|
||||
@@ -414,7 +412,7 @@ export type SessionV1ToolStateError = {
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type SessionV1StepStartPart = {
|
||||
export type StepStartPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -422,7 +420,7 @@ export type SessionV1StepStartPart = {
|
||||
snapshot?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1StepFinishPart = {
|
||||
export type StepFinishPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -439,15 +437,9 @@ export type SessionV1StepFinishPart = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1SnapshotPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "snapshot"
|
||||
snapshot: string
|
||||
}
|
||||
export type SnapshotPart = { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string }
|
||||
|
||||
export type SessionV1PatchPart = {
|
||||
export type PatchPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -456,7 +448,7 @@ export type SessionV1PatchPart = {
|
||||
files: Array<string>
|
||||
}
|
||||
|
||||
export type SessionV1AgentPart = {
|
||||
export type AgentPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -465,7 +457,7 @@ export type SessionV1AgentPart = {
|
||||
source?: { value: string; start: number; end: number } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1CompactionPart = {
|
||||
export type CompactionPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -475,7 +467,7 @@ export type SessionV1CompactionPart = {
|
||||
tail_start_id?: string | undefined
|
||||
}
|
||||
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
export type PermissionV2Reply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
id: string
|
||||
@@ -488,11 +480,11 @@ export type Pty = {
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
export type QuestionV2Option = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; callID: string }
|
||||
export type QuestionV2Tool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
export type QuestionV2Answer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
@@ -509,6 +501,12 @@ export type SessionStatus =
|
||||
}
|
||||
| { type: "busy" }
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -566,10 +564,10 @@ export type CommandInfo = {
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
body: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
export type PermissionV2Rule = { action: string; resource: string; effect: PermissionV2Effect }
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
@@ -1046,11 +1044,11 @@ export type ShellDeleted = {
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type QuestionRejected = {
|
||||
export type QuestionV2Rejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.rejected"
|
||||
type: "question.v2.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
@@ -1188,6 +1186,41 @@ export type McpResourcesChanged = {
|
||||
data: { server: string }
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: { [x: string]: any }
|
||||
always: Array<string>
|
||||
tool?: { messageID: string; callID: string } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
|
||||
}
|
||||
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
|
||||
export type V2EventServerConnected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
@@ -1228,7 +1261,7 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
export type LLMToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
|
||||
@@ -1390,8 +1423,6 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1513,21 +1544,21 @@ export type FormMultiselectField = {
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
export type PermissionV2Request = {
|
||||
id: string
|
||||
sessionID: string
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
source?: PermissionV2Source
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
export type PermissionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.asked"
|
||||
type: "permission.v2.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
id: string
|
||||
@@ -1536,17 +1567,17 @@ export type PermissionAsked = {
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
source?: PermissionV2Source
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
|
||||
export type PermissionRule = { permission: string; pattern: string; action: PermissionAction }
|
||||
|
||||
export type SessionV1OutputFormat =
|
||||
export type OutputFormat =
|
||||
| { type: "text" }
|
||||
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
|
||||
| { type: "json_schema"; schema: JSONSchema; retryCount?: number | undefined | undefined }
|
||||
|
||||
export type SessionV1AssistantMessage = {
|
||||
export type AssistantMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "assistant"
|
||||
@@ -1581,7 +1612,7 @@ export type SessionV1AssistantMessage = {
|
||||
finish?: string | undefined
|
||||
}
|
||||
|
||||
export type SessionV1RetryPart = {
|
||||
export type RetryPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -1612,31 +1643,26 @@ export type SessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
|
||||
export type FileSource = { text: FilePartSourceText; type: "file"; path: string }
|
||||
|
||||
export type SessionV1ResourceSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "resource"
|
||||
clientName: string
|
||||
uri: string
|
||||
}
|
||||
export type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
|
||||
|
||||
export type SessionV1SymbolSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
export type SymbolSource = {
|
||||
text: FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: SessionV1Range
|
||||
range: Range
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
|
||||
export type PermissionReplied = {
|
||||
export type PermissionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.replied"
|
||||
type: "permission.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: PermissionReply }
|
||||
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
|
||||
}
|
||||
|
||||
export type PtyCreated = {
|
||||
@@ -1657,21 +1683,21 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
export type QuestionV2Info = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionOption>
|
||||
options: Array<QuestionV2Option>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
export type QuestionReplied = {
|
||||
export type QuestionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.replied"
|
||||
type: "question.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
@@ -1748,9 +1774,26 @@ export type SessionStatus2 = {
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionOption>
|
||||
multiple?: boolean | undefined
|
||||
custom?: boolean | undefined
|
||||
}
|
||||
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
}
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
export type PermissionV2Ruleset = Array<PermissionV2Rule>
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
@@ -1805,7 +1848,7 @@ export type SessionPendingUserData1 = {
|
||||
export type SessionMessageToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: JsonValue }
|
||||
content: [ToolContent, ...Array<ToolContent>]
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
@@ -1813,15 +1856,10 @@ export type SessionMessageToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: JsonValue }
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent, ...Array<ToolContent>]
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type SessionToolSuccess = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1833,7 +1871,7 @@ export type SessionToolSuccess = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
content: [ToolContent1, ...Array<ToolContent1>]
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState8
|
||||
@@ -1852,13 +1890,18 @@ export type SessionToolFailed = {
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
content?: [ToolContent1, ...Array<ToolContent1>]
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState9
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
@@ -1867,9 +1910,9 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: any }
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
@@ -1905,14 +1948,14 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type PermissionV1Ruleset = Array<PermissionV1Rule>
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionV1UserMessage = {
|
||||
export type UserMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
format?: SessionV1OutputFormat | undefined
|
||||
format?: OutputFormat | undefined
|
||||
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string | undefined }
|
||||
@@ -1920,18 +1963,23 @@ export type SessionV1UserMessage = {
|
||||
tools?: { [x: string]: boolean } | undefined
|
||||
}
|
||||
|
||||
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
|
||||
export type FilePartSource = FileSource | SymbolSource | ResourceSource
|
||||
|
||||
export type QuestionAsked = {
|
||||
export type QuestionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.asked"
|
||||
type: "question.v2.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
|
||||
}
|
||||
|
||||
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
export type QuestionV2Request = {
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: Array<QuestionV2Info>
|
||||
tool?: QuestionV2Tool
|
||||
}
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
@@ -1941,6 +1989,15 @@ export type FormField1 =
|
||||
| FormMultiselectField1
|
||||
| FormExternalField
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
name: string
|
||||
path: string
|
||||
@@ -1960,7 +2017,7 @@ export type AgentInfo = {
|
||||
hidden: boolean
|
||||
color?: AgentColor
|
||||
steps?: number
|
||||
permissions: PermissionRuleset
|
||||
permissions: PermissionV2Ruleset
|
||||
}
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
@@ -2018,13 +2075,13 @@ export type SessionV1Info = {
|
||||
version: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; updated: number; compacting?: number; archived?: number }
|
||||
permission?: PermissionV1Ruleset
|
||||
permission?: PermissionRuleset
|
||||
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
|
||||
}
|
||||
|
||||
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
|
||||
export type Message = UserMessage | AssistantMessage
|
||||
|
||||
export type SessionV1FilePart = {
|
||||
export type FilePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -2032,7 +2089,7 @@ export type SessionV1FilePart = {
|
||||
mime: string
|
||||
filename?: string | undefined
|
||||
url: string
|
||||
source?: SessionV1FilePartSource | undefined
|
||||
source?: FilePartSource | undefined
|
||||
}
|
||||
|
||||
export type FormFields1 = [FormField1, ...Array<FormField1>]
|
||||
@@ -2103,17 +2160,17 @@ export type MessageUpdated = {
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: SessionV1Message }
|
||||
data: { sessionID: string; info: Message }
|
||||
}
|
||||
|
||||
export type SessionV1ToolStateCompleted = {
|
||||
export type ToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
output: string
|
||||
title: string
|
||||
metadata: { [x: string]: any }
|
||||
time: { start: number; end: number; compacted?: number | undefined }
|
||||
attachments?: Array<SessionV1FilePart> | undefined
|
||||
attachments?: Array<FilePart> | undefined
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
@@ -2139,11 +2196,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type SessionV1ToolState =
|
||||
| SessionV1ToolStatePending
|
||||
| SessionV1ToolStateRunning
|
||||
| SessionV1ToolStateCompleted
|
||||
| SessionV1ToolStateError
|
||||
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
@@ -2199,32 +2252,32 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type SessionV1ToolPart = {
|
||||
export type ToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: SessionV1ToolState
|
||||
state: ToolState
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type SessionV1Part =
|
||||
| SessionV1TextPart
|
||||
| SessionV1SubtaskPart
|
||||
| SessionV1ReasoningPart
|
||||
| SessionV1FilePart
|
||||
| SessionV1ToolPart
|
||||
| SessionV1StepStartPart
|
||||
| SessionV1StepFinishPart
|
||||
| SessionV1SnapshotPart
|
||||
| SessionV1PatchPart
|
||||
| SessionV1AgentPart
|
||||
| SessionV1RetryPart
|
||||
| SessionV1CompactionPart
|
||||
export type Part =
|
||||
| TextPart
|
||||
| SubtaskPart
|
||||
| ReasoningPart
|
||||
| FilePart
|
||||
| ToolPart
|
||||
| StepStartPart
|
||||
| StepFinishPart
|
||||
| SnapshotPart
|
||||
| PatchPart
|
||||
| AgentPart
|
||||
| RetryPart
|
||||
| CompactionPart
|
||||
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
@@ -2233,7 +2286,7 @@ export type MessagePartUpdated = {
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; part: SessionV1Part; time: number }
|
||||
data: { sessionID: string; part: Part; time: number }
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
@@ -2294,8 +2347,8 @@ export type V2Event =
|
||||
| SessionRevertCommitted
|
||||
| FilesystemChanged
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PermissionV2Asked
|
||||
| PermissionV2Replied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectDirectoriesUpdated
|
||||
@@ -2309,9 +2362,9 @@ export type V2Event =
|
||||
| ShellCreated
|
||||
| ShellExited
|
||||
| ShellDeleted
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| QuestionV2Asked
|
||||
| QuestionV2Replied
|
||||
| QuestionV2Rejected
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
@@ -2327,6 +2380,11 @@ export type V2Event =
|
||||
| VcsBranchUpdated
|
||||
| McpStatusChanged
|
||||
| McpResourcesChanged
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| SessionError
|
||||
| V2EventServerConnected
|
||||
|
||||
@@ -2343,14 +2401,6 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2534,18 +2584,6 @@ export type AgentListOutput = {
|
||||
data: Array<AgentInfo>
|
||||
}
|
||||
|
||||
export type AgentGetInput = {
|
||||
readonly agentID: { readonly agentID: string }["agentID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type AgentGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: AgentInfo
|
||||
}
|
||||
|
||||
export type PluginListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -3274,7 +3312,7 @@ export type ProviderListInput = {
|
||||
|
||||
export type ProviderListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ProviderInfo>
|
||||
data: Array<ProviderV2Info>
|
||||
}
|
||||
|
||||
export type ProviderGetInput = {
|
||||
@@ -3286,7 +3324,7 @@ export type ProviderGetInput = {
|
||||
|
||||
export type ProviderGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ProviderInfo
|
||||
data: ProviderV2Info
|
||||
}
|
||||
|
||||
export type IntegrationListInput = {
|
||||
@@ -4438,7 +4476,7 @@ export type PermissionRequestListInput = {
|
||||
|
||||
export type PermissionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PermissionRequest>
|
||||
data: Array<PermissionV2Request>
|
||||
}
|
||||
|
||||
export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
|
||||
@@ -4516,18 +4554,18 @@ export type PermissionCreateInput = {
|
||||
}["agent"]
|
||||
}
|
||||
|
||||
export type PermissionCreateOutput = { data: { id: string; effect: PermissionEffect } }["data"]
|
||||
export type PermissionCreateOutput = { data: { id: string; effect: PermissionV2Effect } }["data"]
|
||||
|
||||
export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type PermissionListOutput = { data: Array<PermissionRequest> }["data"]
|
||||
export type PermissionListOutput = { data: Array<PermissionV2Request> }["data"]
|
||||
|
||||
export type PermissionGetInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type PermissionGetOutput = { data: PermissionRequest }["data"]
|
||||
export type PermissionGetOutput = { data: PermissionV2Request }["data"]
|
||||
|
||||
export type PermissionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
@@ -4826,12 +4864,12 @@ export type QuestionRequestListInput = {
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<QuestionRequest>
|
||||
data: Array<QuestionV2Request>
|
||||
}
|
||||
|
||||
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
|
||||
export type QuestionListOutput = { data: Array<QuestionV2Request> }["data"]
|
||||
|
||||
export type QuestionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
|
||||
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
@@ -82,7 +80,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (failure !== undefined) throw failure
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
ownerHeld = true
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
|
||||
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
|
||||
@@ -10,10 +9,6 @@ declare const promiseClient: PromiseClient
|
||||
|
||||
const effectApi: EffectApi<unknown> = effectClient
|
||||
|
||||
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
|
||||
sessionID: "ses_test" as Session.ID,
|
||||
})
|
||||
|
||||
declare const sessionID: Parameters<typeof effectApi.session.instructions.entry.list>[0]["sessionID"]
|
||||
|
||||
const effectList: Effect.Effect<
|
||||
@@ -42,4 +37,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
|
||||
key: "review-notes",
|
||||
})
|
||||
|
||||
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
|
||||
void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
|
||||
|
||||
@@ -14,14 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
test("shared DTO schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
|
||||
@@ -5,7 +5,6 @@ import { join, resolve, sep } from "node:path"
|
||||
|
||||
const directory = resolve(import.meta.dir, "..")
|
||||
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
|
||||
const ws = realpathSync(resolve(import.meta.dir, "../node_modules/ws"))
|
||||
const schema = resolve(import.meta.dir, "../../schema")
|
||||
const protocol = resolve(import.meta.dir, "../../protocol")
|
||||
const core = resolve(import.meta.dir, "../../core")
|
||||
@@ -18,7 +17,6 @@ describe("public import boundaries", () => {
|
||||
expect(within(root, effect)).toEqual([])
|
||||
expect(within(root, schema)).toEqual([])
|
||||
expect(within(root, protocol)).toEqual([])
|
||||
expect(within(root, ws)).toEqual([])
|
||||
expect(within(root, core)).toEqual([])
|
||||
expect(within(root, server)).toEqual([])
|
||||
|
||||
@@ -30,15 +28,6 @@ describe("public import boundaries", () => {
|
||||
expect(within(network, core)).toEqual([])
|
||||
expect(within(network, server)).toEqual([])
|
||||
|
||||
const node = await bundleInputs("@opencode-ai/client/node", "node")
|
||||
|
||||
expect(within(node, effect).length).toBeGreaterThan(0)
|
||||
expect(within(node, schema).length).toBeGreaterThan(0)
|
||||
expect(within(node, protocol).length).toBeGreaterThan(0)
|
||||
expect(within(node, ws).length).toBeGreaterThan(0)
|
||||
expect(within(node, core)).toEqual([])
|
||||
expect(within(node, server)).toEqual([])
|
||||
|
||||
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
|
||||
|
||||
expect(within(promiseService, effect)).toEqual([])
|
||||
@@ -56,7 +45,7 @@ describe("public import boundaries", () => {
|
||||
})
|
||||
})
|
||||
|
||||
async function bundleInputs(specifier: string, target: "browser" | "bun" | "node") {
|
||||
async function bundleInputs(specifier: string, target: "browser" | "bun") {
|
||||
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
|
||||
const entrypoint = join(temporary, "index.ts")
|
||||
const metafile = join(temporary, "meta.json")
|
||||
|
||||
@@ -1,856 +0,0 @@
|
||||
import { BrowserControlProtocol } from "@opencode-ai/protocol/browser-control"
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { once } from "node:events"
|
||||
import { createServer } from "node:http"
|
||||
import { connect, type TLSSocket } from "node:tls"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import {
|
||||
Browser,
|
||||
BrowserDriver,
|
||||
BrowserDriverError,
|
||||
OpenCode,
|
||||
type BrowserDriverContext,
|
||||
type BrowserDriverInstance,
|
||||
type BrowserProxy,
|
||||
} from "@opencode-ai/client/node"
|
||||
|
||||
const initialState: Browser.State = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
}
|
||||
|
||||
describe("Node browser client", () => {
|
||||
test("rejects authentication failures on Bun without unsupported ws response events", async () => {
|
||||
const server = await controlServer("Basic expected")
|
||||
const adapter = driver("unauthorized")
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
rejected(client.browser.attach({ sessionID: "ses_node_unauthorized", driver: adapter.descriptor })),
|
||||
Bun.sleep(1_000).then(() => new Error("Authentication rejection timed out")),
|
||||
])
|
||||
expect(result.message).toContain("HTTP 401")
|
||||
expect(server.authorizations).toEqual([undefined])
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels a pending Bun authentication probe when attachment setup is aborted", async () => {
|
||||
const abort = new AbortController()
|
||||
const adapter = driver("preflight")
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
let cancelled = false
|
||||
const fetch: typeof globalThis.fetch = (_input, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const signal = init?.signal
|
||||
if (!signal) return reject(new Error("Missing preflight signal"))
|
||||
const stop = () => {
|
||||
cancelled = true
|
||||
reject(signal.reason)
|
||||
}
|
||||
signal.addEventListener("abort", stop, { once: true })
|
||||
if (signal.aborted) stop()
|
||||
})
|
||||
const client = OpenCode.make({ baseUrl: "http://127.0.0.1:1", fetch })
|
||||
const attaching = client.browser.attach({
|
||||
sessionID: "ses_node_preflight_abort",
|
||||
driver: adapter.descriptor,
|
||||
signal: abort.signal,
|
||||
})
|
||||
|
||||
await started
|
||||
const reason = new Error("stop preflight")
|
||||
abort.abort(reason)
|
||||
expect(await rejected(attaching)).toBe(reason)
|
||||
expect(cancelled).toBe(true)
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
})
|
||||
|
||||
test("keeps constructor authentication immutable and waits for the lease sync acknowledgement", async () => {
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const server = await controlServer(authorization)
|
||||
const headers = new Headers({ authorization })
|
||||
const adapter = driver("first")
|
||||
const client = OpenCode.make({ baseUrl: server.url, headers })
|
||||
headers.set("authorization", "Basic changed")
|
||||
|
||||
try {
|
||||
expect(client.session).toBeDefined()
|
||||
expect(client.browser.attach).toBeFunction()
|
||||
let settled = false
|
||||
expect(adapter.descriptor).toBe(adapter.factory)
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_barrier", driver: adapter.factory })
|
||||
void attaching.then(() => {
|
||||
settled = true
|
||||
})
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
|
||||
expect(server.authorizations).toEqual([authorization])
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await next()
|
||||
if (sync.type !== "browser.control.sync") throw new Error("Expected browser control sync")
|
||||
expect(sync.attachments).toHaveLength(1)
|
||||
expect(sync.attachments[0]?.sessionID).toBe("ses_node_barrier")
|
||||
expect(sync.attachments[0]?.leaseID.startsWith("brl_")).toBe(true)
|
||||
expect(adapter.proxy?.url.startsWith("https://")).toBe(true)
|
||||
expect(adapter.proxy?.credentials.username).toBeTruthy()
|
||||
await Bun.sleep(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
if (!adapter.proxy) throw new Error("Browser proxy is unavailable")
|
||||
const proxyConnection = connectProxy(adapter.proxy, "target.example:443")
|
||||
await Bun.sleep(10)
|
||||
expect(server.tunnelConnections).toBe(0)
|
||||
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
expect(attachment.resource).toEqual({ name: "first" })
|
||||
|
||||
const tunnel = await server.nextTunnelConnection()
|
||||
const nextTunnel = tunnelReader(tunnel)
|
||||
tunnel.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
const open = await nextTunnel()
|
||||
expect(open).toMatchObject({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.open",
|
||||
sessionID: "ses_node_barrier",
|
||||
leaseID: sync.attachments[0]?.leaseID,
|
||||
target: { host: "target.example", port: 443 },
|
||||
},
|
||||
})
|
||||
tunnel.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const connected = await proxyConnection
|
||||
expect(connected.response.startsWith("HTTP/1.1 200 Connection Established")).toBe(true)
|
||||
connected.socket.destroy()
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await attachment.close()
|
||||
await closed
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.unsubscribeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("multiplexes Sessions, coalesces state, routes requests and cancellation, and preserves siblings", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const first = driver("first")
|
||||
const second = driver("second")
|
||||
|
||||
try {
|
||||
const attachingFirst = client.browser.attach({ sessionID: "ses_node_first", driver: first.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const firstSync = await nextSync(next)
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: firstSync.revision }),
|
||||
)
|
||||
const firstAttachment = await attachingFirst
|
||||
|
||||
const attachingSecond = client.browser.attach({ sessionID: "ses_node_second", driver: second.descriptor })
|
||||
const secondSync = await nextSync(next)
|
||||
expect(secondSync.attachments.map((attachment) => attachment.sessionID)).toEqual([
|
||||
"ses_node_first",
|
||||
"ses_node_second",
|
||||
])
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: secondSync.revision }),
|
||||
)
|
||||
const secondAttachment = await attachingSecond
|
||||
expect(server.connections).toBe(1)
|
||||
|
||||
second.setState({ ...initialState, title: "Intermediate" })
|
||||
const pendingState = await nextSync(next)
|
||||
second.setState({ ...initialState, title: "Latest" })
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: pendingState.revision }),
|
||||
)
|
||||
const latestState = await nextSync(next)
|
||||
expect(
|
||||
latestState.attachments.find((attachment) => attachment.sessionID === "ses_node_second")?.state.title,
|
||||
).toBe("Latest")
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: latestState.revision }),
|
||||
)
|
||||
|
||||
const secondLease = secondSync.attachments.find(
|
||||
(attachment) => attachment.sessionID === "ses_node_second",
|
||||
)?.leaseID
|
||||
if (!secondLease) throw new Error("Second browser lease is unavailable")
|
||||
const requestID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID,
|
||||
outcome: { type: "success", result: { type: "snapshot", content: "second" } },
|
||||
})
|
||||
|
||||
const commandState = { ...initialState, title: "Updated by command", generation: 2 }
|
||||
second.setExecute(async () => ({ type: "navigate", state: commandState }))
|
||||
const stateRequestID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: stateRequestID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "navigate", url: "https://updated.example/", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: stateRequestID,
|
||||
outcome: { type: "success", result: { state: { title: "Updated by command" } } },
|
||||
})
|
||||
const commandStateSync = await nextSync(next)
|
||||
expect(
|
||||
commandStateSync.attachments.find((attachment) => attachment.sessionID === "ses_node_second")?.state,
|
||||
).toEqual(commandState)
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.synced",
|
||||
revision: commandStateSync.revision,
|
||||
}),
|
||||
)
|
||||
|
||||
second.setExecute(async () => ({ type: "click", state: initialState }))
|
||||
const invalidID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: invalidID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: invalidID,
|
||||
outcome: { type: "failure", code: "protocol" },
|
||||
})
|
||||
|
||||
second.setExecute(async () => {
|
||||
throw new BrowserDriverError("stale_ref", "The reference is stale")
|
||||
})
|
||||
const errorID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: errorID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: errorID,
|
||||
outcome: { type: "failure", code: "stale_ref", message: "The reference is stale" },
|
||||
})
|
||||
|
||||
second.setExecute(async () => {
|
||||
throw Object.assign(new Error("Invalid adapter code"), { code: "not_a_browser_error" })
|
||||
})
|
||||
const unsafeErrorID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: unsafeErrorID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "snapshot", generation: 2 },
|
||||
}),
|
||||
)
|
||||
expect(await next()).toMatchObject({
|
||||
type: "browser.control.response",
|
||||
requestID: unsafeErrorID,
|
||||
outcome: { type: "failure", code: "internal", message: "Invalid adapter code" },
|
||||
})
|
||||
|
||||
let resolveStarted!: () => void
|
||||
let resolveCancelled!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const cancelled = new Promise<void>((resolve) => {
|
||||
resolveCancelled = resolve
|
||||
})
|
||||
second.setExecute(
|
||||
(_command, options) =>
|
||||
new Promise<Browser.Result>((_, reject) => {
|
||||
resolveStarted()
|
||||
options.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolveCancelled()
|
||||
reject(Object.assign(new Error("cancelled"), { code: "aborted" }))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
const cancelID = BrowserControl.RequestID.create()
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.request",
|
||||
requestID: cancelID,
|
||||
sessionID: Session.ID.make("ses_node_second"),
|
||||
leaseID: secondLease,
|
||||
command: { type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
|
||||
}),
|
||||
)
|
||||
await started
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({
|
||||
type: "browser.control.cancel",
|
||||
requestID: cancelID,
|
||||
leaseID: secondLease,
|
||||
}),
|
||||
)
|
||||
await cancelled
|
||||
|
||||
await firstAttachment.close()
|
||||
const detached = await nextSync(next)
|
||||
expect(detached.attachments).toHaveLength(1)
|
||||
expect(detached.attachments[0]?.sessionID).toBe("ses_node_second")
|
||||
socket.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: detached.revision }),
|
||||
)
|
||||
expect(server.connections).toBe(1)
|
||||
expect(second.disposeCount).toBe(0)
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await secondAttachment.close()
|
||||
await closed
|
||||
expect(first.disposeCount).toBe(1)
|
||||
expect(second.disposeCount).toBe(1)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("reconnects and republishes the complete attachment snapshot", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("reconnect")
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_reconnect", driver: adapter.descriptor })
|
||||
const original = await server.nextConnection()
|
||||
const nextOriginal = controlReader(original)
|
||||
original.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const initial = await nextSync(nextOriginal)
|
||||
original.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: initial.revision }),
|
||||
)
|
||||
const attachment = await attaching
|
||||
original.terminate()
|
||||
|
||||
const replacement = await server.nextConnection()
|
||||
const nextReplacement = controlReader(replacement)
|
||||
replacement.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const republished = await nextSync(nextReplacement)
|
||||
expect(republished.attachments).toEqual(initial.attachments)
|
||||
replacement.send(
|
||||
BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: republished.revision }),
|
||||
)
|
||||
expect(server.connections).toBe(2)
|
||||
|
||||
const closed = once(replacement, "close")
|
||||
await attachment.close()
|
||||
await closed
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects duplicate Sessions and aborts setup while close remains idempotent", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("owner")
|
||||
|
||||
try {
|
||||
const invalid = driver("invalid")
|
||||
expect(
|
||||
(await rejected(client.browser.attach({ sessionID: "invalid", driver: invalid.descriptor }))).message,
|
||||
).toContain("valid Session ID")
|
||||
expect(invalid.createCount).toBe(0)
|
||||
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_owner", driver: adapter.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await nextSync(next)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
|
||||
const duplicate = driver("duplicate")
|
||||
expect(
|
||||
(await rejected(client.browser.attach({ sessionID: "ses_node_owner", driver: duplicate.descriptor }))).message,
|
||||
).toContain("already attached")
|
||||
expect(duplicate.createCount).toBe(0)
|
||||
|
||||
const closed = once(socket, "close")
|
||||
await Promise.all([attachment.close(), attachment.close(), attachment[Symbol.asyncDispose]()])
|
||||
await closed
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.unsubscribeCount).toBe(1)
|
||||
|
||||
const abort = new AbortController()
|
||||
const abortedAdapter = driver("aborted")
|
||||
const aborted = client.browser.attach({
|
||||
sessionID: "ses_node_aborted",
|
||||
driver: abortedAdapter.descriptor,
|
||||
signal: abort.signal,
|
||||
})
|
||||
const abortSocket = await server.nextConnection()
|
||||
const nextAbort = controlReader(abortSocket)
|
||||
abortSocket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
await nextSync(nextAbort)
|
||||
const reason = new Error("stop attaching")
|
||||
const abortClosed = once(abortSocket, "close")
|
||||
abort.abort(reason)
|
||||
expect(await rejected(aborted)).toBe(reason)
|
||||
await abortClosed
|
||||
expect(abortedAdapter.disposeCount).toBe(1)
|
||||
expect(abortedAdapter.signal?.aborted).toBe(true)
|
||||
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort(reason)
|
||||
const unused = driver("unused")
|
||||
expect(
|
||||
await rejected(
|
||||
client.browser.attach({
|
||||
sessionID: "ses_node_unused",
|
||||
driver: unused.descriptor,
|
||||
signal: alreadyAborted.signal,
|
||||
}),
|
||||
),
|
||||
).toBe(reason)
|
||||
expect(unused.createCount).toBe(0)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("fails pending attachments on fatal control closes without reconnecting", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const adapter = driver("fatal")
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_fatal", driver: adapter.descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
await nextSync(next)
|
||||
const closed = once(socket, "close")
|
||||
socket.close(1002, "invalid control state")
|
||||
|
||||
expect((await rejected(attaching)).message).toContain("fatal code 1002")
|
||||
await closed
|
||||
await Bun.sleep(250)
|
||||
expect(server.connections).toBe(1)
|
||||
expect(adapter.disposeCount).toBe(1)
|
||||
expect(adapter.signal?.aborted).toBe(true)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts non-cooperative driver creation and disposes a late instance", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const abort = new AbortController()
|
||||
let resolveStarted!: () => void
|
||||
let resolveFactory!: (instance: BrowserDriverInstance<{ readonly name: string }>) => void
|
||||
let resolveDisposed!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const disposed = new Promise<void>((resolve) => {
|
||||
resolveDisposed = resolve
|
||||
})
|
||||
const factory = () => {
|
||||
resolveStarted()
|
||||
return new Promise<BrowserDriverInstance<{ readonly name: string }>>((resolve) => {
|
||||
resolveFactory = resolve
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({
|
||||
sessionID: "ses_node_blocked_factory",
|
||||
driver: factory,
|
||||
signal: abort.signal,
|
||||
})
|
||||
await started
|
||||
const reason = new Error("abort blocked factory")
|
||||
abort.abort(reason)
|
||||
expect(
|
||||
await rejected(
|
||||
Promise.race([
|
||||
attaching,
|
||||
Bun.sleep(250).then(() => {
|
||||
throw new Error("Browser attachment did not abort promptly")
|
||||
}),
|
||||
]),
|
||||
),
|
||||
).toBe(reason)
|
||||
|
||||
resolveFactory({
|
||||
resource: { name: "late" },
|
||||
state: () => initialState,
|
||||
subscribe: () => () => undefined,
|
||||
execute: async () => ({
|
||||
type: "snapshot",
|
||||
state: initialState,
|
||||
format: "opencode.semantic.v1",
|
||||
content: "late",
|
||||
}),
|
||||
dispose: () => resolveDisposed(),
|
||||
})
|
||||
await disposed
|
||||
expect(server.authorizations).toEqual([])
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("cleans up in reverse acquisition order and surfaces the first error", async () => {
|
||||
const server = await controlServer()
|
||||
const client = OpenCode.make({ baseUrl: server.url })
|
||||
const first = new Error("unsubscribe failed")
|
||||
const second = new Error("dispose failed")
|
||||
const order: string[] = []
|
||||
let proxy: BrowserProxy | undefined
|
||||
let proxyOpenDuringDispose = false
|
||||
const descriptor = BrowserDriver.define((context) => {
|
||||
proxy = context.proxy
|
||||
return {
|
||||
resource: context.proxy,
|
||||
state: () => initialState,
|
||||
subscribe: () => () => {
|
||||
order.push("unsubscribe")
|
||||
throw first
|
||||
},
|
||||
execute: async () => ({
|
||||
type: "snapshot",
|
||||
state: initialState,
|
||||
format: "opencode.semantic.v1",
|
||||
content: "cleanup",
|
||||
}),
|
||||
dispose: async () => {
|
||||
order.push("dispose")
|
||||
proxyOpenDuringDispose = await proxyAvailable(context.proxy)
|
||||
throw second
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const attaching = client.browser.attach({ sessionID: "ses_node_cleanup", driver: descriptor })
|
||||
const socket = await server.nextConnection()
|
||||
const next = controlReader(socket)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.ready" }))
|
||||
const sync = await nextSync(next)
|
||||
socket.send(BrowserControlProtocol.encodeFromServer({ type: "browser.control.synced", revision: sync.revision }))
|
||||
const attachment = await attaching
|
||||
|
||||
expect(await rejected(attachment.close())).toBe(first)
|
||||
expect(await rejected(attachment.close())).toBe(first)
|
||||
expect(order).toEqual(["unsubscribe", "dispose"])
|
||||
expect(proxyOpenDuringDispose).toBe(true)
|
||||
if (!proxy) throw new Error("Browser proxy is unavailable")
|
||||
expect(await proxyAvailable(proxy)).toBe(false)
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
type Execute = BrowserDriverInstance<unknown>["execute"]
|
||||
|
||||
function driver(name: string) {
|
||||
let current = initialState
|
||||
let execute: Execute = async () => ({
|
||||
type: "snapshot",
|
||||
state: current,
|
||||
format: "opencode.semantic.v1",
|
||||
content: name,
|
||||
})
|
||||
let proxy: BrowserProxy | undefined
|
||||
let signal: AbortSignal | undefined
|
||||
let createCount = 0
|
||||
let disposeCount = 0
|
||||
let unsubscribeCount = 0
|
||||
const listeners = new Set<(state: Browser.State) => void>()
|
||||
const factory = ({ proxy: nextProxy, signal: nextSignal }: BrowserDriverContext) => {
|
||||
createCount++
|
||||
proxy = nextProxy
|
||||
signal = nextSignal
|
||||
return {
|
||||
resource: { name },
|
||||
state: () => current,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
if (listeners.delete(listener)) unsubscribeCount++
|
||||
}
|
||||
},
|
||||
execute: (command, options) => execute(command, options),
|
||||
dispose() {
|
||||
disposeCount++
|
||||
},
|
||||
}
|
||||
}
|
||||
const descriptor = BrowserDriver.define(factory)
|
||||
return {
|
||||
factory,
|
||||
descriptor,
|
||||
get proxy() {
|
||||
return proxy
|
||||
},
|
||||
get signal() {
|
||||
return signal
|
||||
},
|
||||
get createCount() {
|
||||
return createCount
|
||||
},
|
||||
get disposeCount() {
|
||||
return disposeCount
|
||||
},
|
||||
get unsubscribeCount() {
|
||||
return unsubscribeCount
|
||||
},
|
||||
setState(state: Browser.State) {
|
||||
current = state
|
||||
listeners.forEach((listener) => listener(state))
|
||||
},
|
||||
setExecute(next: Execute) {
|
||||
execute = next
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function rejected(promise: Promise<unknown>) {
|
||||
return promise.then(
|
||||
() => new Error("Expected operation to reject"),
|
||||
(error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
)
|
||||
}
|
||||
|
||||
async function nextSync(next: () => Promise<BrowserControl.FromDesktop>) {
|
||||
const message = await next()
|
||||
if (message.type !== "browser.control.sync") throw new Error("Expected browser control sync")
|
||||
return message
|
||||
}
|
||||
|
||||
async function controlServer(authorization?: string) {
|
||||
const http = createServer((request, response) => {
|
||||
response.statusCode = request.headers.authorization === authorization ? 200 : 401
|
||||
response.end()
|
||||
})
|
||||
const webSockets = new WebSocketServer({ noServer: true })
|
||||
const tunnels = new WebSocketServer({ noServer: true })
|
||||
const waiting: Array<(socket: WebSocket) => void> = []
|
||||
const queued: WebSocket[] = []
|
||||
const tunnelWaiting: Array<(socket: WebSocket) => void> = []
|
||||
const tunnelQueued: WebSocket[] = []
|
||||
const authorizations: Array<string | undefined> = []
|
||||
let connections = 0
|
||||
let tunnelConnections = 0
|
||||
webSockets.on("connection", (socket) => {
|
||||
connections++
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve(socket)
|
||||
else queued.push(socket)
|
||||
})
|
||||
tunnels.on("connection", (socket) => {
|
||||
tunnelConnections++
|
||||
const resolve = tunnelWaiting.shift()
|
||||
if (resolve) resolve(socket)
|
||||
else tunnelQueued.push(socket)
|
||||
})
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
authorizations.push(request.headers.authorization)
|
||||
if (request.headers.authorization !== authorization) {
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
return
|
||||
}
|
||||
if (
|
||||
request.url === "/api/browser/control" &&
|
||||
request.headers["sec-websocket-protocol"] === BROWSER_CONTROL_PROTOCOL
|
||||
) {
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
|
||||
return
|
||||
}
|
||||
if (
|
||||
request.url === "/api/browser/tunnel" &&
|
||||
request.headers["sec-websocket-protocol"] === BROWSER_TUNNEL_PROTOCOL
|
||||
) {
|
||||
tunnels.handleUpgrade(request, socket, head, (webSocket) => tunnels.emit("connection", webSocket, request))
|
||||
return
|
||||
}
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
})
|
||||
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === "string") throw new Error("Browser control server did not bind TCP")
|
||||
return {
|
||||
authorizations,
|
||||
get connections() {
|
||||
return connections
|
||||
},
|
||||
get tunnelConnections() {
|
||||
return tunnelConnections
|
||||
},
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
nextConnection() {
|
||||
const socket = queued.shift()
|
||||
if (socket) return Promise.resolve(socket)
|
||||
return new Promise<WebSocket>((resolve) => waiting.push(resolve))
|
||||
},
|
||||
nextTunnelConnection() {
|
||||
const socket = tunnelQueued.shift()
|
||||
if (socket) return Promise.resolve(socket)
|
||||
return new Promise<WebSocket>((resolve) => tunnelWaiting.push(resolve))
|
||||
},
|
||||
async close() {
|
||||
webSockets.clients.forEach((socket) => socket.terminate())
|
||||
tunnels.clients.forEach((socket) => socket.terminate())
|
||||
webSockets.close()
|
||||
tunnels.close()
|
||||
http.closeAllConnections()
|
||||
http.close()
|
||||
await Bun.sleep(10)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function proxyAvailable(proxy: BrowserProxy) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const socket = connect({
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host,
|
||||
rejectUnauthorized: false,
|
||||
})
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
socket.destroy()
|
||||
resolve(available)
|
||||
}
|
||||
const timeout = setTimeout(() => finish(false), 250)
|
||||
timeout.unref()
|
||||
socket.once("secureConnect", () => finish(true))
|
||||
socket.once("error", () => finish(false))
|
||||
})
|
||||
}
|
||||
|
||||
function connectProxy(proxy: BrowserProxy, authority: string) {
|
||||
return new Promise<{ readonly socket: TLSSocket; readonly response: string }>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
socket.write(
|
||||
`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}\r\n\r\n`,
|
||||
)
|
||||
},
|
||||
)
|
||||
let response = ""
|
||||
socket.on("error", () => undefined)
|
||||
socket.once("error", reject)
|
||||
socket.on("data", (data) => {
|
||||
response += data.toString()
|
||||
if (!response.includes("\r\n\r\n")) return
|
||||
resolve({ socket, response })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function tunnelReader(socket: WebSocket) {
|
||||
const queued: WebSocket.RawData[] = []
|
||||
const waiting: Array<(data: WebSocket.RawData) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
if (!binary) throw new Error("Expected a binary browser tunnel frame")
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve(data)
|
||||
else queued.push(data)
|
||||
})
|
||||
return async () => {
|
||||
const data = queued.shift() ?? (await new Promise<WebSocket.RawData>((resolve) => waiting.push(resolve)))
|
||||
return Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(rawData(data)))
|
||||
}
|
||||
}
|
||||
|
||||
function controlReader(socket: WebSocket) {
|
||||
const queued: Array<{ readonly data: WebSocket.RawData; readonly binary: boolean }> = []
|
||||
const waiting: Array<(message: { readonly data: WebSocket.RawData; readonly binary: boolean }) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve({ data, binary })
|
||||
else queued.push({ data, binary })
|
||||
})
|
||||
return async () => {
|
||||
const message =
|
||||
queued.shift() ??
|
||||
(await new Promise<{ readonly data: WebSocket.RawData; readonly binary: boolean }>((resolve) =>
|
||||
waiting.push(resolve),
|
||||
))
|
||||
if (message.binary) throw new Error("Expected a text browser control message")
|
||||
return Effect.runPromise(BrowserControlProtocol.decodeFromDesktop(rawData(message.data)))
|
||||
}
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.RawData) {
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
Browser,
|
||||
BrowserDriver,
|
||||
BrowserDriverError,
|
||||
type BrowserDriverContext,
|
||||
type ChromiumPort,
|
||||
} from "@opencode-ai/client/node"
|
||||
|
||||
type PortListener = Parameters<ChromiumPort<unknown>["subscribe"]>[0]
|
||||
type PortState = ReturnType<ChromiumPort<unknown>["state"]>
|
||||
|
||||
describe("Chromium browser driver", () => {
|
||||
test("increments generations and invalidates refs only for main-document changes", async () => {
|
||||
const app = await setup()
|
||||
const snapshot = await app.instance.execute(
|
||||
{ type: "snapshot", generation: 0 },
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
if (snapshot.type !== "snapshot") throw new Error("Expected snapshot result")
|
||||
expect(snapshot.content).toContain('e1 [button] "Save" disabled=false')
|
||||
|
||||
await app.instance.execute(
|
||||
{ type: "click", ref: Browser.Ref.make("e1"), generation: 0 },
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
app.port.emit({ title: "Updated" }, false)
|
||||
expect(app.controller.state()).toMatchObject({ title: "Updated", generation: 0 })
|
||||
|
||||
app.port.emit({ url: "https://next.example/" }, true)
|
||||
expect(app.controller.state()).toMatchObject({ url: "https://next.example/", generation: 1 })
|
||||
expect(app.port.calls.some((call) => call.method === "Runtime.releaseObject")).toBe(true)
|
||||
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute(
|
||||
{ type: "click", ref: Browser.Ref.make("e1"), generation: 1 },
|
||||
{ signal: new AbortController().signal },
|
||||
),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "stale_ref", message: "The browser page changed. Call browser_snapshot again." })
|
||||
await app.controller.dispose()
|
||||
})
|
||||
|
||||
test("parses bounded snapshots and rejects untrusted oversized nodes", async () => {
|
||||
const app = await setup()
|
||||
const result = await app.instance.execute(
|
||||
{ type: "snapshot", generation: 0 },
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
if (result.type !== "snapshot") throw new Error("Expected snapshot result")
|
||||
expect(result.content).toBe(
|
||||
["Page: Example", "URL: https://example.com/", "", ' e1 [button] "Save" disabled=false'].join("\n"),
|
||||
)
|
||||
expect(app.port.expressions[0]).toContain("while (visited++ < 500)")
|
||||
expect(app.port.expressions[0]).toContain('editable ? ""')
|
||||
expect(app.port.expressions[0]).not.toContain("textContent")
|
||||
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(app.port.expressions[0] ?? "")).not.toThrow()
|
||||
|
||||
app.port.snapshotValue = {
|
||||
nodes: Array.from({ length: 501 }, () => ({ role: "button", name: "Save", value: "", depth: 0 })),
|
||||
nextRef: 501,
|
||||
}
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
|
||||
|
||||
app.port.snapshotValue = {
|
||||
nodes: [{ token: "e2", role: "bad role", name: "Save", value: "", depth: 0 }],
|
||||
nextRef: 2,
|
||||
}
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "internal", message: "Invalid browser snapshot nodes." })
|
||||
expect(app.port.calls.filter((call) => call.method === "Runtime.releaseObject").length).toBeGreaterThanOrEqual(2)
|
||||
await app.controller.dispose()
|
||||
})
|
||||
|
||||
test("releases paired input and fences the port when cancellation races a press", async () => {
|
||||
const app = await setup()
|
||||
await app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
|
||||
const abort = new AbortController()
|
||||
const input: string[] = []
|
||||
app.port.onSend = (method, params) => {
|
||||
if (method !== "Input.dispatchMouseEvent") return app.port.response(method, params)
|
||||
if (params?.type === "mousePressed") {
|
||||
input.push("press")
|
||||
abort.abort()
|
||||
return new Promise<never>(() => undefined)
|
||||
}
|
||||
if (params?.type === "mouseReleased") input.push("release")
|
||||
return app.port.response(method, params)
|
||||
}
|
||||
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "click", ref: Browser.Ref.make("e1"), generation: 0 }, { signal: abort.signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "aborted", message: "The browser action was aborted." })
|
||||
await app.controller.dispose()
|
||||
expect(input).toEqual(["press", "release"])
|
||||
expect(app.port.stopCount).toBe(1)
|
||||
expect(app.port.disposeCount).toBe(1)
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
).code,
|
||||
).toBe("not_attached")
|
||||
})
|
||||
|
||||
test("serializes local controller work with remote commands", async () => {
|
||||
const app = await setup()
|
||||
const navigation = deferred()
|
||||
const started = deferred()
|
||||
app.port.onNavigate = () => {
|
||||
started.resolve()
|
||||
return navigation.promise
|
||||
}
|
||||
|
||||
const local = app.controller.navigate("example.com")
|
||||
await started.promise
|
||||
const remote = app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal })
|
||||
await Promise.resolve()
|
||||
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(false)
|
||||
expect(app.port.navigateURLs).toEqual(["https://example.com/"])
|
||||
|
||||
navigation.resolve()
|
||||
await local
|
||||
expect((await remote).type).toBe("snapshot")
|
||||
expect(app.port.calls.some((call) => call.method === "Runtime.evaluate")).toBe(true)
|
||||
await app.controller.dispose()
|
||||
})
|
||||
|
||||
test("maps navigation, CDP, and adapter failures to driver error codes", async () => {
|
||||
const app = await setup()
|
||||
expect(requireDriverError(await rejected(app.controller.navigate("javascript:alert(1)")))).toMatchObject({
|
||||
code: "invalid_url",
|
||||
})
|
||||
expect(app.port.navigateURLs).toEqual([])
|
||||
|
||||
app.port.onNavigate = () => Promise.reject(new Error("net::ERR_NAME_NOT_RESOLVED"))
|
||||
expect(requireDriverError(await rejected(app.controller.navigate("missing.example")))).toMatchObject({
|
||||
code: "navigation_failed",
|
||||
message: "net::ERR_NAME_NOT_RESOLVED",
|
||||
})
|
||||
|
||||
app.port.onSend = () => Promise.reject(new Error("Could not find object with given id"))
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "stale_ref", message: "The element reference is stale. Call browser_snapshot again." })
|
||||
|
||||
app.port.onSend = () => Promise.reject(new Error("Method not found"))
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "internal", message: "Method not found" })
|
||||
|
||||
app.port.onSend = () => Promise.reject(new BrowserDriverError("page_crashed", "Renderer gone"))
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "snapshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "page_crashed", message: "Renderer gone" })
|
||||
await app.controller.dispose()
|
||||
})
|
||||
|
||||
test("preserves viewport scroll and bounded PNG capture contracts", async () => {
|
||||
const app = await setup()
|
||||
app.port.viewportSize = { width: 101, height: 99 }
|
||||
await app.instance.execute(
|
||||
{ type: "scroll", direction: "down", pixels: 5_000, generation: 0 },
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
expect(app.port.calls.find((call) => call.params?.type === "mouseWheel")?.params).toEqual({
|
||||
type: "mouseWheel",
|
||||
x: 51,
|
||||
y: 50,
|
||||
deltaX: 0,
|
||||
deltaY: 2_000,
|
||||
})
|
||||
|
||||
const result = await app.instance.execute(
|
||||
{ type: "screenshot", generation: 0 },
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
if (result.type !== "screenshot") throw new Error("Expected screenshot result")
|
||||
expect(app.port.screenshotLimits).toEqual([2_000])
|
||||
expect(result).toMatchObject({ mediaType: "image/png", width: 100, height: 50 })
|
||||
expect(result.data).toEqual(new Uint8Array([137, 80, 78, 71]))
|
||||
|
||||
app.port.screenshotValue = {
|
||||
data: new Uint8Array(5 * 1_024 * 1_024 + 1),
|
||||
width: 100,
|
||||
height: 50,
|
||||
}
|
||||
expect(
|
||||
requireDriverError(
|
||||
await rejected(
|
||||
app.instance.execute({ type: "screenshot", generation: 0 }, { signal: new AbortController().signal }),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ code: "result_too_large", message: "The browser screenshot exceeds 5 MiB." })
|
||||
await app.controller.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
class FakeChromiumPort implements ChromiumPort<{ readonly name: string }> {
|
||||
readonly resource = { name: "fake-chromium" }
|
||||
readonly listeners = new Set<PortListener>()
|
||||
readonly calls: Array<{ readonly method: string; readonly params?: Record<string, unknown> }> = []
|
||||
readonly expressions: string[] = []
|
||||
readonly navigateURLs: string[] = []
|
||||
readonly screenshotLimits: number[] = []
|
||||
current: PortState = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
}
|
||||
snapshotValue: unknown = {
|
||||
nodes: [{ token: "e1", role: "button", name: "Save", value: "", depth: 1, disabled: false }],
|
||||
nextRef: 1,
|
||||
}
|
||||
screenshotValue = { data: new Uint8Array([137, 80, 78, 71]), width: 100, height: 50 }
|
||||
viewportSize = { width: 800, height: 600 }
|
||||
onNavigate?: (url: string) => PromiseLike<void> | void
|
||||
onSend?: (method: string, params?: Record<string, unknown>) => unknown
|
||||
stopCount = 0
|
||||
disposeCount = 0
|
||||
private object = 0
|
||||
|
||||
state() {
|
||||
return this.current
|
||||
}
|
||||
|
||||
subscribe(listener: PortListener) {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
navigate(url: string) {
|
||||
this.navigateURLs.push(url)
|
||||
return Promise.resolve().then(() => this.onNavigate?.(url))
|
||||
}
|
||||
|
||||
back() {}
|
||||
|
||||
forward() {}
|
||||
|
||||
reload() {}
|
||||
|
||||
stop() {
|
||||
this.stopCount++
|
||||
}
|
||||
|
||||
send(method: string, params?: Record<string, unknown>) {
|
||||
this.calls.push({ method, ...(params ? { params } : {}) })
|
||||
if (method === "Runtime.evaluate" && typeof params?.expression === "string") {
|
||||
this.expressions.push(params.expression)
|
||||
}
|
||||
return Promise.resolve().then(() => (this.onSend ? this.onSend(method, params) : this.response(method, params)))
|
||||
}
|
||||
|
||||
response(method: string, params?: Record<string, unknown>) {
|
||||
if (method === "Runtime.evaluate") return { result: { objectId: `snapshot-${++this.object}` } }
|
||||
if (method !== "Runtime.callFunctionOn") return {}
|
||||
if (params?.functionDeclaration === "function() { return this.result }") {
|
||||
return { result: { value: this.snapshotValue } }
|
||||
}
|
||||
if (typeof params?.functionDeclaration === "string" && params.functionDeclaration.includes("const editable")) {
|
||||
return { result: { value: true } }
|
||||
}
|
||||
return { result: { value: { x: 25, y: 40 } } }
|
||||
}
|
||||
|
||||
viewport() {
|
||||
return this.viewportSize
|
||||
}
|
||||
|
||||
screenshot(options: { readonly maxDimension: number }) {
|
||||
this.screenshotLimits.push(options.maxDimension)
|
||||
return Promise.resolve(this.screenshotValue)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposeCount++
|
||||
}
|
||||
|
||||
emit(state: Partial<PortState>, mainDocumentChanged: boolean) {
|
||||
this.current = { ...this.current, ...state }
|
||||
this.listeners.forEach((listener) => listener({ state: this.current, mainDocumentChanged }))
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const port = new FakeChromiumPort()
|
||||
const lifetime = new AbortController()
|
||||
const driver = BrowserDriver.chromium(() => port)
|
||||
const instance = await driver({
|
||||
proxy: {
|
||||
url: "https://127.0.0.1:1234",
|
||||
host: "127.0.0.1",
|
||||
port: 1234,
|
||||
credentials: { username: "user", password: "pass" },
|
||||
certificateFingerprint: "fingerprint",
|
||||
},
|
||||
signal: lifetime.signal,
|
||||
} satisfies BrowserDriverContext)
|
||||
return { port, lifetime, instance, controller: instance.resource }
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function rejected(promise: Promise<unknown>) {
|
||||
return promise.then(
|
||||
() => new Error("Expected operation to reject"),
|
||||
(error: unknown) => error,
|
||||
)
|
||||
}
|
||||
|
||||
function requireDriverError(input: unknown) {
|
||||
if (!(input instanceof BrowserDriverError)) throw input
|
||||
return input
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { join, relative, resolve } from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
const directory = resolve(import.meta.dir, "../..")
|
||||
|
||||
test("built Node entrypoint imports in Node and treats HTTP authentication rejection as fatal", async () => {
|
||||
await buildClient()
|
||||
const output = await Bun.file(join(directory, "dist/node/index.js")).text()
|
||||
expect(output).not.toMatch(/(?:from\s+|import\s*)["']\.\.?\//)
|
||||
|
||||
const temporary = await mkdtemp(join(import.meta.dir, ".node-package-"))
|
||||
try {
|
||||
await Bun.write(join(temporary, "index.mjs"), output)
|
||||
await stageWorkspaceDependencies(temporary)
|
||||
const child = Bun.spawn(
|
||||
["node", "--input-type=module", "-e", nodeScenario(pathToFileURL(join(temporary, "index.mjs")).href)],
|
||||
{ cwd: temporary, stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stderr || stdout)
|
||||
expect(stdout.trim()).toBe("ok")
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
async function buildClient() {
|
||||
const child = Bun.spawn([process.execPath, "run", "build"], {
|
||||
cwd: directory,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(stdout + stderr)
|
||||
}
|
||||
|
||||
async function stageWorkspaceDependencies(temporary: string) {
|
||||
const schema = join(temporary, "node_modules/@opencode-ai/schema")
|
||||
const protocol = join(temporary, "node_modules/@opencode-ai/protocol")
|
||||
await Promise.all([mkdir(schema, { recursive: true }), mkdir(protocol, { recursive: true })])
|
||||
|
||||
const schemaEntry = join(temporary, "schema.ts")
|
||||
const protocolEntry = join(temporary, "protocol.ts")
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
schemaEntry,
|
||||
[
|
||||
`export { Browser } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser.ts")))}`,
|
||||
`export { BrowserControl } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnel } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/browser-tunnel.ts")))}`,
|
||||
`export { Session } from ${JSON.stringify(importPath(temporary, resolve(directory, "../schema/src/session.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
Bun.write(
|
||||
protocolEntry,
|
||||
[
|
||||
`export { BrowserControlProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-control.ts")))}`,
|
||||
`export { BrowserTunnelProtocol } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/browser-tunnel.ts")))}`,
|
||||
`export { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from ${JSON.stringify(importPath(temporary, resolve(directory, "../protocol/src/groups/browser.ts")))}`,
|
||||
].join("\n"),
|
||||
),
|
||||
])
|
||||
const [schemaBuild, protocolBuild] = await Promise.all([
|
||||
Bun.build({
|
||||
entrypoints: [schemaEntry],
|
||||
outdir: schema,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
Bun.build({
|
||||
entrypoints: [protocolEntry],
|
||||
outdir: protocol,
|
||||
naming: "index.js",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "bundle",
|
||||
}),
|
||||
])
|
||||
if (!schemaBuild.success) throw new Error(schemaBuild.logs.map((log) => log.message).join("\n"))
|
||||
if (!protocolBuild.success) throw new Error(protocolBuild.logs.map((log) => log.message).join("\n"))
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
join(schema, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser": "./index.js",
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
"./session": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
Bun.write(
|
||||
join(protocol, "package.json"),
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
exports: {
|
||||
"./browser-control": "./index.js",
|
||||
"./browser-tunnel": "./index.js",
|
||||
"./groups/browser": "./index.js",
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
function importPath(from: string, to: string) {
|
||||
const path = relative(from, to).replaceAll("\\", "/")
|
||||
return path.startsWith(".") ? path : `./${path}`
|
||||
}
|
||||
|
||||
function nodeScenario(moduleURL: string) {
|
||||
return `import { createServer } from "node:http"
|
||||
const sdk = await import(${JSON.stringify(moduleURL)})
|
||||
if (typeof sdk.OpenCode.make !== "function") throw new Error("Missing OpenCode.make")
|
||||
if (typeof sdk.BrowserDriver.define !== "function") throw new Error("Missing BrowserDriver.define")
|
||||
if (typeof sdk.BrowserDriver.chromium !== "function") throw new Error("Missing BrowserDriver.chromium")
|
||||
if (typeof sdk.BrowserDriverError !== "function") throw new Error("Missing BrowserDriverError")
|
||||
if (!sdk.Browser.State) throw new Error("Missing canonical Browser export")
|
||||
|
||||
let upgrades = 0
|
||||
const server = createServer()
|
||||
server.on("upgrade", (_request, socket) => {
|
||||
upgrades++
|
||||
socket.end("HTTP/1.1 401 Unauthorized\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n")
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("Server did not bind")
|
||||
let disposed = 0
|
||||
const driver = () => ({
|
||||
resource: undefined,
|
||||
state: () => ({ url: "about:blank", title: "", loading: false, canGoBack: false, canGoForward: false, generation: 0 }),
|
||||
subscribe: () => () => undefined,
|
||||
execute: async () => { throw new sdk.BrowserDriverError("internal", "unavailable") },
|
||||
dispose: () => { disposed++ },
|
||||
})
|
||||
const client = sdk.OpenCode.make({ baseUrl: \`http://127.0.0.1:\${address.port}\` })
|
||||
const error = await Promise.race([
|
||||
client.browser.attach({ sessionID: "ses_node_auth_rejection", driver }).then(
|
||||
() => new Error("Expected authentication rejection"),
|
||||
(cause) => cause,
|
||||
),
|
||||
new Promise((resolve) => setTimeout(() => resolve(new Error("Authentication rejection timed out")), 2_000)),
|
||||
])
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
if (!(error instanceof Error) || !error.message.includes("HTTP 401")) throw error
|
||||
if (upgrades !== 1) throw new Error(\`Expected one upgrade, received \${upgrades}\`)
|
||||
if (disposed !== 1) throw new Error(\`Expected one driver disposal, received \${disposed}\`)
|
||||
console.log("ok")`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createHash, X509Certificate } from "node:crypto"
|
||||
import { createSecureContext } from "node:tls"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBrowserProxyCertificate } from "../../src/node/browser/proxy-certificate"
|
||||
|
||||
describe("browser proxy certificate", () => {
|
||||
test("creates a unique valid certificate pinned to its loopback names", () => {
|
||||
const hostname = "opencode-test.localhost"
|
||||
const first = createBrowserProxyCertificate(hostname)
|
||||
const second = createBrowserProxyCertificate(hostname)
|
||||
const certificate = new X509Certificate(first.certificate)
|
||||
const fingerprint = createHash("sha256").update(certificate.raw).digest("base64")
|
||||
|
||||
expect(first.key.startsWith("-----BEGIN PRIVATE KEY-----")).toBe(true)
|
||||
expect(first.certificate.startsWith("-----BEGIN CERTIFICATE-----")).toBe(true)
|
||||
expect(first.fingerprint).toBe(`sha256/${fingerprint}`)
|
||||
expect(certificate.subjectAltName).toContain(`DNS:${hostname}`)
|
||||
expect(certificate.subjectAltName).toContain("IP Address:127.0.0.1")
|
||||
expect(first.fingerprint).not.toBe(second.fingerprint)
|
||||
expect(() => createSecureContext({ key: first.key, cert: first.certificate })).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,452 +0,0 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { once } from "node:events"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { Socket } from "node:net"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Duplex } from "node:stream"
|
||||
import { connect, type TLSSocket } from "node:tls"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBrowserProxy } from "../../src/node/browser/proxy"
|
||||
|
||||
describe("browser proxy", () => {
|
||||
test("forwards authenticated CONNECT streams without directly dialing the target", async () => {
|
||||
let directConnections = 0
|
||||
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
|
||||
decoy.on("connection", () => directConnections++)
|
||||
await new Promise<void>((resolve) => decoy.listen(0, "127.0.0.1", resolve))
|
||||
const decoyAddress = decoy.address()
|
||||
if (decoyAddress === null || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
|
||||
const target = createServer((incoming, response) => response.end(`TARGET ${incoming.method} ${incoming.url}`))
|
||||
await new Promise<void>((resolve) => target.listen(0, "127.0.0.1", resolve))
|
||||
const targetAddress = target.address()
|
||||
if (targetAddress === null || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
|
||||
const opened: Array<{ host: string; port: number }> = []
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: async (destination, signal) => {
|
||||
opened.push(destination)
|
||||
const socket = new Socket({ allowHalfOpen: true })
|
||||
const abort = () => socket.destroy(new Error("cancelled"))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
socket.once("close", () => signal.removeEventListener("abort", abort))
|
||||
// Emulate the server-side tunnel dial. The local decoy must never receive a connection.
|
||||
socket.connect(targetAddress.port, "127.0.0.1")
|
||||
await once(socket, "connect")
|
||||
return socket
|
||||
},
|
||||
})
|
||||
try {
|
||||
const tunnel = await proxyConnect(proxy, `127.0.0.1:${decoyAddress.port}`)
|
||||
expect(tunnel.status).toBe(200)
|
||||
tunnel.socket.write(
|
||||
`GET /connected HTTP/1.1\r\nHost: 127.0.0.1:${decoyAddress.port}\r\nConnection: close\r\n\r\n`,
|
||||
)
|
||||
const response = await readAll(tunnel.socket, tunnel.leftover)
|
||||
expect(response).toContain("TARGET GET /connected")
|
||||
expect(opened).toEqual([{ host: "127.0.0.1", port: decoyAddress.port }])
|
||||
|
||||
const certificate = await peerCertificate(proxy)
|
||||
expect(`sha256/${createHash("sha256").update(certificate).digest("base64")}`).toBe(proxy.certificateFingerprint)
|
||||
expect(directConnections).toBe(0)
|
||||
} finally {
|
||||
await proxy.close()
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => target.close(() => resolve())),
|
||||
new Promise<void>((resolve) => decoy.close(() => resolve())),
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("forwards absolute-form HTTP with Node framing and closes held-open targets", async () => {
|
||||
expect(await nodeAbsoluteFormScenario()).toEqual({
|
||||
unauthorized: { status: 407, body: "" },
|
||||
forwarded: { status: 200, body: "TARGET GET /absolute?q=1" },
|
||||
posted: { status: 200, body: "TARGET POST /post request body" },
|
||||
chunked: { status: 200, body: "TARGET CHUNKED" },
|
||||
held: { status: 200, body: "ok" },
|
||||
opened: 4,
|
||||
destinationsCorrect: true,
|
||||
directConnections: 0,
|
||||
heldDestroyed: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("limits concurrent tunnel setup to six connections", async () => {
|
||||
let active = 0
|
||||
let maximum = 0
|
||||
let started = 0
|
||||
const releases: Array<() => void> = []
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((resolve, reject) => {
|
||||
active++
|
||||
started++
|
||||
maximum = Math.max(maximum, active)
|
||||
const finish = (result: () => void) => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
active--
|
||||
result()
|
||||
}
|
||||
const abort = () => finish(() => reject(new Error("cancelled")))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
releases.push(() =>
|
||||
finish(() =>
|
||||
resolve(
|
||||
new Duplex({
|
||||
read() {},
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback()
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const connections = Array.from({ length: 7 }, () =>
|
||||
proxyConnect(proxy, "target.example:443").then(
|
||||
(result) => result.socket.destroy(),
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
try {
|
||||
await waitFor(() => started === 6)
|
||||
expect(maximum).toBe(6)
|
||||
expect(started).toBe(6)
|
||||
|
||||
releases.shift()?.()
|
||||
await waitFor(() => started === 7)
|
||||
expect(maximum).toBe(6)
|
||||
} finally {
|
||||
await proxy.close()
|
||||
await Promise.all(connections)
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts pending CONNECT setup when the downstream client closes", async () => {
|
||||
let aborted = false
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
reject(new Error("cancelled"))
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
}),
|
||||
})
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`CONNECT target.example:443 HTTP/1.1\r\nHost: target.example:443\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
try {
|
||||
await started
|
||||
socket.destroy()
|
||||
await waitFor(() => aborted)
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("aborts pending absolute-form setup when the downstream client closes", async () => {
|
||||
let aborted = false
|
||||
let resolveStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve
|
||||
})
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: (_destination, signal) =>
|
||||
new Promise<Duplex>((_resolve, reject) => {
|
||||
resolveStarted()
|
||||
const abort = () => {
|
||||
aborted = true
|
||||
reject(new Error("cancelled"))
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
if (signal.aborted) abort()
|
||||
}),
|
||||
})
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`GET http://target.example/pending HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
try {
|
||||
await started
|
||||
socket.destroy()
|
||||
await waitFor(() => aborted)
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("closes absolute-form requests when the target tunnel cannot connect", async () => {
|
||||
const proxy = await createBrowserProxy({ connect: () => Promise.reject(new Error("target unavailable")) })
|
||||
const socket = proxyClient(
|
||||
proxy,
|
||||
`GET http://target.example/unavailable HTTP/1.1\r\nHost: target.example\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
socket.on("data", (chunk) => chunks.push(chunk))
|
||||
try {
|
||||
await once(socket, "close")
|
||||
expect(Buffer.concat(chunks).toString()).not.toContain("502 Bad Gateway")
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await proxy.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
type ProxyInfo = Awaited<ReturnType<typeof createBrowserProxy>>
|
||||
|
||||
function authorization(proxy: ProxyInfo) {
|
||||
return `Basic ${Buffer.from(`${proxy.credentials.username}:${proxy.credentials.password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function proxyClient(proxy: ProxyInfo, request: string) {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => socket.write(request),
|
||||
)
|
||||
socket.on("error", () => undefined)
|
||||
return socket
|
||||
}
|
||||
|
||||
async function nodeAbsoluteFormScenario() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-browser-proxy-"))
|
||||
try {
|
||||
const built = await Bun.build({
|
||||
entrypoints: [join(import.meta.dir, "../../src/node/browser/proxy.ts")],
|
||||
outdir: directory,
|
||||
naming: "browser-proxy.mjs",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
})
|
||||
if (!built.success) throw new Error(built.logs.map((log) => log.message).join("\n"))
|
||||
const output = built.outputs[0]
|
||||
if (!output) throw new Error("Browser proxy Node bundle was not emitted")
|
||||
const child = Bun.spawn(
|
||||
["node", "--input-type=module", "-e", absoluteFormScenario(pathToFileURL(output.path).href)],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (code !== 0) throw new Error(stderr || stdout)
|
||||
const result: unknown = JSON.parse(stdout)
|
||||
return result
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function absoluteFormScenario(moduleURL: string) {
|
||||
return `import { createBrowserProxy } from ${JSON.stringify(moduleURL)}
|
||||
import { once } from "node:events"
|
||||
import { Agent, createServer, request } from "node:http"
|
||||
import { Socket } from "node:net"
|
||||
import { Duplex } from "node:stream"
|
||||
import { connect } from "node:tls"
|
||||
|
||||
const proxyRequest = async (proxy, path, authenticated, options = {}) => {
|
||||
const socket = await new Promise((resolve, reject) => {
|
||||
const socket = connect({ host: "127.0.0.1", port: proxy.port, servername: proxy.host, rejectUnauthorized: false }, () => resolve(socket))
|
||||
socket.once("error", reject)
|
||||
})
|
||||
const agent = new Agent({ keepAlive: false, maxSockets: 1, noDelay: false })
|
||||
agent.createConnection = () => socket
|
||||
const body = Buffer.from(options.body ?? "")
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const incoming = request({
|
||||
agent,
|
||||
hostname: proxy.host,
|
||||
path,
|
||||
method: options.method ?? "GET",
|
||||
headers: {
|
||||
connection: "close",
|
||||
...(authenticated ? { "proxy-authorization": "Basic " + Buffer.from(proxy.credentials.username + ":" + proxy.credentials.password).toString("base64") } : {}),
|
||||
...(options.chunked ? { "transfer-encoding": "chunked" } : { "content-length": body.byteLength.toString() }),
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks = []
|
||||
response.on("data", (chunk) => chunks.push(chunk))
|
||||
response.once("end", () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString() }))
|
||||
})
|
||||
incoming.once("error", reject)
|
||||
incoming.end(body)
|
||||
})
|
||||
} finally {
|
||||
agent.destroy()
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
let directConnections = 0
|
||||
const decoy = createServer((_incoming, response) => response.end("DIRECT DIAL"))
|
||||
decoy.on("connection", () => directConnections++)
|
||||
decoy.listen(0, "127.0.0.1")
|
||||
await once(decoy, "listening")
|
||||
const decoyAddress = decoy.address()
|
||||
if (!decoyAddress || typeof decoyAddress === "string") throw new Error("decoy server did not bind TCP")
|
||||
|
||||
const targetServer = createServer((incoming, response) => {
|
||||
const chunks = []
|
||||
incoming.on("data", (chunk) => chunks.push(chunk))
|
||||
incoming.on("end", () => {
|
||||
response.writeHead(200, { "content-type": "text/plain" })
|
||||
if (incoming.url === "/chunked") {
|
||||
response.write("TARGET ")
|
||||
response.end("CHUNKED")
|
||||
return
|
||||
}
|
||||
const body = Buffer.concat(chunks).toString()
|
||||
response.end("TARGET " + incoming.method + " " + incoming.url + (body ? " " + body : ""))
|
||||
})
|
||||
})
|
||||
targetServer.listen(0, "127.0.0.1")
|
||||
await once(targetServer, "listening")
|
||||
const targetAddress = targetServer.address()
|
||||
if (!targetAddress || typeof targetAddress === "string") throw new Error("target server did not bind TCP")
|
||||
|
||||
const opened = []
|
||||
let held
|
||||
const proxy = await createBrowserProxy({
|
||||
connect: async (destination, signal) => {
|
||||
opened.push(destination)
|
||||
if (destination.host === "held.example") {
|
||||
let responded = false
|
||||
held = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
if (!responded && Buffer.from(chunk).includes(Buffer.from("\\r\\n\\r\\n"))) {
|
||||
responded = true
|
||||
this.push(Buffer.from("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok"))
|
||||
}
|
||||
callback()
|
||||
},
|
||||
})
|
||||
return held
|
||||
}
|
||||
const socket = new Socket({ allowHalfOpen: true })
|
||||
const abort = () => socket.destroy(new Error("cancelled"))
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
socket.once("close", () => signal.removeEventListener("abort", abort))
|
||||
socket.connect(targetAddress.port, "127.0.0.1")
|
||||
await once(socket, "connect")
|
||||
return socket
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const base = "http://127.0.0.1:" + decoyAddress.port
|
||||
const unauthorized = await proxyRequest(proxy, base + "/private", false)
|
||||
const forwarded = await proxyRequest(proxy, base + "/absolute?q=1", true)
|
||||
const posted = await proxyRequest(proxy, base + "/post", true, { method: "POST", body: "request body", chunked: true })
|
||||
const chunked = await proxyRequest(proxy, base + "/chunked", true)
|
||||
const heldResult = await proxyRequest(proxy, "http://held.example/held-open", true)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
console.log(JSON.stringify({
|
||||
unauthorized,
|
||||
forwarded,
|
||||
posted,
|
||||
chunked,
|
||||
held: heldResult,
|
||||
opened: opened.length,
|
||||
destinationsCorrect: opened.slice(0, 3).every((item) => item.host === "127.0.0.1" && item.port === decoyAddress.port) && opened[3]?.host === "held.example" && opened[3]?.port === 80,
|
||||
directConnections,
|
||||
heldDestroyed: held?.destroyed === true,
|
||||
}))
|
||||
} finally {
|
||||
await proxy.close()
|
||||
targetServer.closeAllConnections()
|
||||
decoy.closeAllConnections()
|
||||
await Promise.all([new Promise((resolve) => targetServer.close(resolve)), new Promise((resolve) => decoy.close(resolve))])
|
||||
}`
|
||||
}
|
||||
|
||||
function proxyConnect(proxy: ProxyInfo, destination: string) {
|
||||
return new Promise<{ status: number; socket: TLSSocket; leftover: Buffer }>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
socket.write(
|
||||
`CONNECT ${destination} HTTP/1.1\r\nHost: ${destination}\r\nProxy-Authorization: ${authorization(proxy)}\r\n\r\n`,
|
||||
)
|
||||
},
|
||||
)
|
||||
let buffered = Buffer.alloc(0)
|
||||
let settled = false
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk])
|
||||
const end = buffered.indexOf("\r\n\r\n")
|
||||
if (end < 0) return
|
||||
socket.off("data", onData)
|
||||
const status = Number(buffered.toString("ascii", 0, end).split(" ", 2)[1])
|
||||
settled = true
|
||||
resolve({ status, socket, leftover: buffered.subarray(end + 4) })
|
||||
}
|
||||
socket.on("data", onData)
|
||||
socket.once("error", reject)
|
||||
socket.once("close", () => {
|
||||
if (!settled) reject(new Error("Browser proxy connection closed before responding"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function readAll(socket: TLSSocket, initial: Buffer) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const chunks = [initial]
|
||||
socket.on("data", (chunk) => chunks.push(chunk))
|
||||
socket.on("end", () => resolve(Buffer.concat(chunks).toString()))
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function peerCertificate(proxy: ProxyInfo) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const socket = connect(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: proxy.port,
|
||||
servername: proxy.host.endsWith(".localhost") ? proxy.host : undefined,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
resolve(socket.getPeerCertificate().raw)
|
||||
socket.end()
|
||||
},
|
||||
)
|
||||
socket.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (check()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error("Timed out waiting for browser proxy test condition")
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { once } from "node:events"
|
||||
import { createServer } from "node:http"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import { BrowserTunnelError, openBrowserTunnel } from "../../src/node/browser/tunnel"
|
||||
|
||||
const sessionID = Session.ID.make("ses_client_tunnel")
|
||||
const leaseID = Browser.LeaseID.make("brl_clienttunnel")
|
||||
|
||||
describe("browser tunnel", () => {
|
||||
test("enforces byte and frame windows and preserves both half-closes", async () => {
|
||||
const server = await tunnelServer()
|
||||
try {
|
||||
const opening = openBrowserTunnel({
|
||||
endpoint: server.endpoint,
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(443) },
|
||||
})
|
||||
const socket = await server.connected
|
||||
const next = frameReader(socket)
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
const open = await next()
|
||||
expect(open).toMatchObject({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: "target.example", port: 443 },
|
||||
},
|
||||
})
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const tunnel = await opening
|
||||
|
||||
let writeSettled = false
|
||||
const writing = new Promise<void>((resolve, reject) => {
|
||||
tunnel.write(Buffer.alloc(BrowserTunnelProtocol.InitialWindowBytes + 1, 7), (error) => {
|
||||
writeSettled = true
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
const sent = []
|
||||
for (let index = 0; index < 4; index++) sent.push(await next())
|
||||
expect(sent.every((frame) => frame.type === "data" && frame.data.byteLength === 64 * 1_024)).toBe(true)
|
||||
await Bun.sleep(25)
|
||||
expect(writeSettled).toBe(false)
|
||||
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(64 * 1_024),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const final = await next()
|
||||
expect(final.type === "data" && final.data.byteLength).toBe(1)
|
||||
await writing
|
||||
|
||||
const received = once(tunnel, "data")
|
||||
socket.send(BrowserTunnelProtocol.data(Buffer.from("from server")), { binary: true })
|
||||
expect(Buffer.from((await received)[0]).toString()).toBe("from server")
|
||||
expect(await next()).toEqual({
|
||||
type: "control",
|
||||
message: {
|
||||
type: "browser.tunnel.window",
|
||||
bytes: Buffer.byteLength("from server"),
|
||||
frames: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const localEnd = next()
|
||||
tunnel.end()
|
||||
expect(await localEnd).toEqual({ type: "control", message: { type: "browser.tunnel.end" } })
|
||||
const remoteEnd = once(tunnel, "end")
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.end" }), { binary: true })
|
||||
await remoteEnd
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects window credit that does not exactly match complete frames", async () => {
|
||||
const server = await tunnelServer()
|
||||
try {
|
||||
const opening = openBrowserTunnel({
|
||||
endpoint: server.endpoint,
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(80) },
|
||||
})
|
||||
const socket = await server.connected
|
||||
const next = frameReader(socket)
|
||||
socket.send(BrowserTunnelProtocol.encodeFromServer({ type: "browser.tunnel.ready" }), { binary: true })
|
||||
await next()
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const tunnel = await opening
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
tunnel.write(Buffer.from("frame"), (error) => (error ? reject(error) : resolve())),
|
||||
)
|
||||
await next()
|
||||
const failed = once(tunnel, "error")
|
||||
socket.send(
|
||||
BrowserTunnelProtocol.encodeFromServer({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(4),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
const error = (await failed)[0]
|
||||
if (!(error instanceof BrowserTunnelError)) throw new Error("expected browser tunnel error")
|
||||
expect(error.code).toBe("protocol_error")
|
||||
expect(await next()).toEqual({
|
||||
type: "control",
|
||||
message: { type: "browser.tunnel.reset", code: "protocol_error" },
|
||||
})
|
||||
} finally {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function tunnelServer() {
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const http = createServer()
|
||||
const webSockets = new WebSocketServer({ noServer: true })
|
||||
let resolveConnected!: (socket: WebSocket) => void
|
||||
const connected = new Promise<WebSocket>((resolve) => {
|
||||
resolveConnected = resolve
|
||||
})
|
||||
webSockets.once("connection", resolveConnected)
|
||||
http.on("upgrade", (request, socket, head) => {
|
||||
if (
|
||||
request.url !== "/api/browser/tunnel" ||
|
||||
request.headers.authorization !== authorization ||
|
||||
request.headers["sec-websocket-protocol"] !== BROWSER_TUNNEL_PROTOCOL
|
||||
) {
|
||||
socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n")
|
||||
return
|
||||
}
|
||||
webSockets.handleUpgrade(request, socket, head, (webSocket) => webSockets.emit("connection", webSocket, request))
|
||||
})
|
||||
await new Promise<void>((resolve) => http.listen(0, "127.0.0.1", resolve))
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === "string") throw new Error("browser tunnel server did not bind TCP")
|
||||
return {
|
||||
connected,
|
||||
endpoint: {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
authorization,
|
||||
},
|
||||
async close() {
|
||||
webSockets.clients.forEach((socket) => socket.terminate())
|
||||
webSockets.close()
|
||||
http.closeAllConnections()
|
||||
http.close()
|
||||
await Bun.sleep(10)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function frameReader(socket: WebSocket) {
|
||||
const queued: Array<{ readonly data: WebSocket.RawData; readonly binary: boolean }> = []
|
||||
const waiting: Array<(message: { readonly data: WebSocket.RawData; readonly binary: boolean }) => void> = []
|
||||
socket.on("message", (data, binary) => {
|
||||
const resolve = waiting.shift()
|
||||
if (resolve) resolve({ data, binary })
|
||||
else queued.push({ data, binary })
|
||||
})
|
||||
return async () => {
|
||||
const message =
|
||||
queued.shift() ??
|
||||
(await new Promise<{ readonly data: WebSocket.RawData; readonly binary: boolean }>((resolve) =>
|
||||
waiting.push(resolve),
|
||||
))
|
||||
if (!message.binary) throw new Error("Expected a binary browser tunnel frame")
|
||||
return Effect.runPromise(BrowserTunnelProtocol.decodeFromDesktop(rawData(message.data)))
|
||||
}
|
||||
}
|
||||
|
||||
function rawData(data: WebSocket.RawData) {
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import {
|
||||
Browser,
|
||||
BrowserDriver,
|
||||
BrowserDriverError,
|
||||
OpenCode,
|
||||
type BrowserAttachment,
|
||||
type ChromiumController,
|
||||
type ChromiumDriver,
|
||||
type ChromiumPort,
|
||||
} from "@opencode-ai/client/node"
|
||||
|
||||
const state: Browser.State = {
|
||||
url: "about:blank",
|
||||
title: "",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 0,
|
||||
}
|
||||
|
||||
const factory: BrowserDriver<{ readonly proxyURL: string }> = (context) => ({
|
||||
resource: { proxyURL: context.proxy.url },
|
||||
state: () => state,
|
||||
subscribe: () => () => undefined,
|
||||
execute: async (_command, options) => {
|
||||
throw new BrowserDriverError(options.signal.aborted ? "aborted" : "internal", "Command unavailable")
|
||||
},
|
||||
dispose: () => undefined,
|
||||
})
|
||||
const driver = BrowserDriver.define(factory)
|
||||
declare const port: ChromiumPort<{ readonly page: true }>
|
||||
const chromium: ChromiumDriver<{ readonly page: true }> = BrowserDriver.chromium(() => port)
|
||||
|
||||
declare const client: ReturnType<typeof OpenCode.make>
|
||||
const attachment: Promise<BrowserAttachment<{ readonly proxyURL: string }>> = client.browser.attach({
|
||||
sessionID: "ses_type_fixture",
|
||||
driver,
|
||||
})
|
||||
|
||||
void attachment
|
||||
const chromiumAttachment: Promise<BrowserAttachment<ChromiumController<{ readonly page: true }>>> =
|
||||
client.browser.attach({
|
||||
sessionID: "ses_chromium_type_fixture",
|
||||
driver: chromium,
|
||||
})
|
||||
void chromiumAttachment
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["node-consumer.ts"]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["node-consumer.ts"]
|
||||
}
|
||||
@@ -99,7 +99,6 @@
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as Account from "./account"
|
||||
export * as AccountV2 from "./account"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import type { HttpClientError } from "effect/unstable/http"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
|
||||
import { Account } from "../account"
|
||||
import { AccountV2 } from "../account"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().$type<Account.ID>().primaryKey(),
|
||||
id: text().$type<AccountV2.ID>().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
|
||||
token_expiry: integer(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text()
|
||||
.$type<Account.ID>()
|
||||
.$type<AccountV2.ID>()
|
||||
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text().$type<Account.OrgID>(),
|
||||
active_org_id: text().$type<AccountV2.OrgID>(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
+12
-12
@@ -1,9 +1,9 @@
|
||||
export * as Agent from "./agent"
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Bus } from "./bus"
|
||||
import { EventV2 } from "./event"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Agent.ID
|
||||
@@ -17,7 +17,7 @@ export const Color = Agent.Color
|
||||
export const Info = Agent.Info
|
||||
export type Info = Agent.Info
|
||||
|
||||
export { Event } from "@opencode-ai/schema/agent"
|
||||
export const Event = Agent.Event
|
||||
|
||||
export interface Selection {
|
||||
readonly id: ID
|
||||
@@ -45,12 +45,12 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
@@ -70,7 +70,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
@@ -89,17 +89,17 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("Agent.get")(function* (id) {
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
default: Effect.fn("Agent.default")(function* () {
|
||||
default: Effect.fn("AgentV2.default")(function* () {
|
||||
return selectedDefault()
|
||||
}),
|
||||
resolve: Effect.fn("Agent.resolve")(function* (id) {
|
||||
resolve: Effect.fn("AgentV2.resolve")(function* (id) {
|
||||
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
||||
return selectedDefault()
|
||||
}),
|
||||
select: Effect.fn("Agent.select")(function* (id) {
|
||||
select: Effect.fn("AgentV2.select")(function* (id) {
|
||||
if (id !== undefined) {
|
||||
const selected = ID.make(id)
|
||||
return { id: selected, info: state.get().agents.get(selected) }
|
||||
@@ -107,11 +107,11 @@ const layer = Layer.effect(
|
||||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
+20
-20
@@ -32,8 +32,8 @@ import {
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
@@ -42,14 +42,14 @@ type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: Info
|
||||
readonly model: ModelV2.Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: Info
|
||||
readonly model: ModelV2.Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
@@ -146,7 +146,7 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
if (typeof opts.body === "string" && model.body !== undefined) {
|
||||
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body))
|
||||
if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) {
|
||||
opts.body = JSON.stringify(Provider.mergeOverlay(decoded, model.body))
|
||||
opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +162,11 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
}
|
||||
|
||||
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
|
||||
providerID: Provider.ID,
|
||||
providerID: ProviderV2.ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
function initError(providerID: Provider.ID) {
|
||||
function initError(providerID: ProviderV2.ID) {
|
||||
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<Model, InitError>
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: ModelV2.Info) => Effect.Effect<Model, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
@@ -260,13 +260,13 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
const existing = languages.get(key)
|
||||
if (existing) return existing
|
||||
if (!Provider.isAISDK(model.package))
|
||||
if (!ProviderV2.isAISDK(model.package))
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error(`Unsupported package ${model.package}`),
|
||||
})
|
||||
|
||||
const packageName = Provider.packageName(model.package)
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const options = prepareOptions(model, packageName)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
@@ -301,8 +301,8 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
const packageName = ProviderV2.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
const providerOptions = (() => {
|
||||
@@ -352,7 +352,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
})
|
||||
}
|
||||
|
||||
function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, unknown>>) {
|
||||
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
|
||||
const gateway =
|
||||
typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway)
|
||||
? Object.fromEntries(Object.entries(settings.gateway))
|
||||
@@ -369,7 +369,7 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
|
||||
return { gateway: model }
|
||||
}
|
||||
|
||||
function providerOptionKey(packageName: string | undefined, providerID: Provider.ID) {
|
||||
function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
|
||||
if (packageName === "@ai-sdk/google") return "google"
|
||||
if (packageName === "@ai-sdk/google-vertex") return "vertex"
|
||||
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
|
||||
@@ -395,18 +395,18 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) &&
|
||||
(pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined)
|
||||
const normalized = forceReasoning ? Provider.mergeOverlay(settings, { forceReasoning: true }) : settings
|
||||
const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings
|
||||
if (!pro) return { settings: normalized, body: model.body }
|
||||
const body = { ...model.body }
|
||||
delete body.reasoning
|
||||
return {
|
||||
settings: Provider.mergeOverlay(normalized, { reasoningMode: "pro" }),
|
||||
settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }),
|
||||
body: Object.keys(body).length === 0 ? undefined : body,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
export * as BrowserHost from "./browser-host"
|
||||
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
Option,
|
||||
Ref,
|
||||
Schema,
|
||||
Scope,
|
||||
Stream,
|
||||
SynchronizedRef,
|
||||
} from "effect"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { Bus } from "./bus"
|
||||
import { SessionEvent } from "./session/event"
|
||||
|
||||
const PendingLimit = 32
|
||||
|
||||
export class OwnerExistsError extends Schema.TaggedErrorClass<OwnerExistsError>()("BrowserHost.OwnerExistsError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()("BrowserHost.ConnectionError", {
|
||||
kind: Schema.Literals(["closed", "invalid_message", "message_too_large", "overloaded", "transport"]),
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class ProtocolError extends Schema.TaggedErrorClass<ProtocolError>()("BrowserHost.ProtocolError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class RequestError extends Schema.TaggedErrorClass<RequestError>()("BrowserHost.RequestError", {
|
||||
code: Browser.ErrorCode,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type CloseReason =
|
||||
| "disconnected"
|
||||
| "protocol_error"
|
||||
| "message_too_large"
|
||||
| "overloaded"
|
||||
| "internal_error"
|
||||
| "restart"
|
||||
|
||||
export interface Peer {
|
||||
readonly messages: Stream.Stream<BrowserControl.FromDesktop, ConnectionError>
|
||||
readonly send: (message: BrowserControl.FromServer) => Effect.Effect<void, ConnectionError>
|
||||
readonly close: (close: CloseReason, message: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
readonly run: (peer: Peer) => Effect.Effect<void, ConnectionError | ProtocolError>
|
||||
}
|
||||
|
||||
export interface Lease {
|
||||
readonly id: Browser.LeaseID
|
||||
readonly sessionID: Session.ID
|
||||
readonly state: Browser.State
|
||||
readonly revoked: Effect.Effect<void>
|
||||
readonly request: (command: Browser.Command) => Effect.Effect<Browser.Result, RequestError>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Claims the sole desktop browser host for this server process. */
|
||||
readonly claim: Effect.Effect<Connection, OwnerExistsError, Scope.Scope>
|
||||
readonly lease: (sessionID: Session.ID) => Effect.Effect<Option.Option<Lease>>
|
||||
readonly shutdown: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/BrowserHost") {}
|
||||
|
||||
type Attachment = BrowserControl.Attachment & {
|
||||
readonly token: object
|
||||
readonly revoked: Deferred.Deferred<void>
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
readonly token: object
|
||||
readonly requestID: BrowserControl.RequestID
|
||||
readonly sessionID: Session.ID
|
||||
readonly leaseID: Browser.LeaseID
|
||||
readonly command: Browser.Command
|
||||
readonly done: Deferred.Deferred<Browser.Outcome>
|
||||
}
|
||||
|
||||
type Active = {
|
||||
readonly token: object
|
||||
readonly peer?: Peer
|
||||
readonly revision: number
|
||||
readonly attachments: ReadonlyMap<Session.ID, Attachment>
|
||||
readonly pending: ReadonlyMap<BrowserControl.RequestID, Pending>
|
||||
}
|
||||
|
||||
type State = {
|
||||
readonly shutdown: boolean
|
||||
readonly active?: Active
|
||||
}
|
||||
|
||||
type Released = {
|
||||
readonly attachments: ReadonlyArray<Attachment>
|
||||
readonly pending: ReadonlyArray<Pending>
|
||||
readonly peer?: Peer
|
||||
}
|
||||
|
||||
type SyncResult = {
|
||||
readonly revoked: ReadonlyArray<Attachment>
|
||||
readonly cancelled: ReadonlyArray<Pending>
|
||||
readonly peer: Peer
|
||||
}
|
||||
|
||||
type RequestStart =
|
||||
| { readonly type: "error"; readonly error: RequestError }
|
||||
| { readonly type: "ready"; readonly peer: Peer; readonly pending: Pending }
|
||||
|
||||
export function make(
|
||||
sessionExists: (sessionID: Session.ID) => Effect.Effect<boolean>,
|
||||
deleted: Stream.Stream<Session.ID> = Stream.never,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* SynchronizedRef.make<State>({ shutdown: false })
|
||||
|
||||
const settleReleased = Effect.fn("BrowserHost.settleReleased")(function* (
|
||||
released: Released,
|
||||
close: CloseReason,
|
||||
reason: string,
|
||||
) {
|
||||
for (const attachment of released.attachments) Deferred.doneUnsafe(attachment.revoked, Effect.void)
|
||||
for (const pending of released.pending) {
|
||||
Deferred.doneUnsafe(
|
||||
pending.done,
|
||||
Effect.succeed({
|
||||
type: "failure",
|
||||
code: "not_attached",
|
||||
message: "The browser attachment is no longer available.",
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (released.peer) yield* released.peer.close(close, reason)
|
||||
})
|
||||
|
||||
const release = Effect.fn("BrowserHost.release")(function* (token: object, close: CloseReason, reason: string) {
|
||||
const released = yield* SynchronizedRef.modify(state, (current): readonly [Released, State] => {
|
||||
if (current.active?.token !== token) return [{ attachments: [], pending: [] }, current]
|
||||
return [
|
||||
{
|
||||
attachments: Array.from(current.active.attachments.values()),
|
||||
pending: Array.from(current.active.pending.values()),
|
||||
peer: current.active.peer,
|
||||
},
|
||||
{ shutdown: current.shutdown },
|
||||
]
|
||||
})
|
||||
yield* settleReleased(released, close, reason)
|
||||
})
|
||||
|
||||
const revokeSession = Effect.fn("BrowserHost.revokeSession")(function* (sessionID: Session.ID) {
|
||||
const revoked = yield* SynchronizedRef.modify(state, (current): readonly [Released, State] => {
|
||||
const active = current.active
|
||||
const attachment = active?.attachments.get(sessionID)
|
||||
if (!active || !attachment) return [{ attachments: [], pending: [] }, current]
|
||||
const attachments = new Map(active.attachments)
|
||||
attachments.delete(sessionID)
|
||||
const pending = Array.from(active.pending.values()).filter((item) => item.sessionID === sessionID)
|
||||
const pendingIDs = new Set(pending.map((item) => item.requestID))
|
||||
return [
|
||||
{ attachments: [attachment], pending, peer: active.peer },
|
||||
{
|
||||
shutdown: current.shutdown,
|
||||
active: {
|
||||
...active,
|
||||
attachments,
|
||||
pending: new Map(Array.from(active.pending).filter(([requestID]) => !pendingIDs.has(requestID))),
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
for (const pending of revoked.pending) {
|
||||
if (!revoked.peer) break
|
||||
yield* revoked.peer
|
||||
.send({ type: "browser.control.cancel", requestID: pending.requestID, leaseID: pending.leaseID })
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
for (const attachment of revoked.attachments) Deferred.doneUnsafe(attachment.revoked, Effect.void)
|
||||
for (const pending of revoked.pending) {
|
||||
Deferred.doneUnsafe(
|
||||
pending.done,
|
||||
Effect.succeed({
|
||||
type: "failure",
|
||||
code: "not_attached",
|
||||
message: "The browser Session no longer exists.",
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const sync = Effect.fn("BrowserHost.sync")(function* (token: object, input: BrowserControl.Sync) {
|
||||
const sessionIDs = new Set(input.attachments.map((attachment) => attachment.sessionID))
|
||||
const leaseIDs = new Set(input.attachments.map((attachment) => attachment.leaseID))
|
||||
if (sessionIDs.size !== input.attachments.length || leaseIDs.size !== input.attachments.length) {
|
||||
return yield* new ProtocolError({
|
||||
message: "Browser attachment snapshots must contain unique Sessions and leases.",
|
||||
})
|
||||
}
|
||||
const existing = yield* Effect.forEach(input.attachments, (attachment) => sessionExists(attachment.sessionID), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
if (existing.some((value) => !value)) {
|
||||
return yield* new ProtocolError({ message: "Browser attachment snapshot contains an unknown Session." })
|
||||
}
|
||||
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (current) {
|
||||
const active = current.active
|
||||
if (active?.token !== token || !active.peer) {
|
||||
return yield* new ProtocolError({ message: "Browser control connection is no longer active." })
|
||||
}
|
||||
if (input.revision <= active.revision) {
|
||||
return yield* new ProtocolError({ message: "Browser attachment revision must increase monotonically." })
|
||||
}
|
||||
|
||||
const next = new Map<Session.ID, Attachment>()
|
||||
for (const info of input.attachments) {
|
||||
const previous = active.attachments.get(info.sessionID)
|
||||
next.set(
|
||||
info.sessionID,
|
||||
previous?.leaseID === info.leaseID
|
||||
? { ...previous, state: info.state }
|
||||
: { ...info, token: {}, revoked: Deferred.makeUnsafe<void>() },
|
||||
)
|
||||
}
|
||||
|
||||
const revoked = Array.from(active.attachments.values()).filter(
|
||||
(attachment) => next.get(attachment.sessionID)?.token !== attachment.token,
|
||||
)
|
||||
const cancelled = Array.from(active.pending.values()).filter(
|
||||
(pending) => next.get(pending.sessionID)?.leaseID !== pending.leaseID,
|
||||
)
|
||||
const cancelledIDs = new Set(cancelled.map((pending) => pending.requestID))
|
||||
const pending = new Map(Array.from(active.pending).filter(([requestID]) => !cancelledIDs.has(requestID)))
|
||||
return [
|
||||
{ revoked, cancelled, peer: active.peer },
|
||||
{ shutdown: current.shutdown, active: { ...active, revision: input.revision, attachments: next, pending } },
|
||||
] as readonly [SyncResult, State]
|
||||
}),
|
||||
)
|
||||
|
||||
for (const attachment of result.revoked) Deferred.doneUnsafe(attachment.revoked, Effect.void)
|
||||
for (const pending of result.cancelled) {
|
||||
Deferred.doneUnsafe(
|
||||
pending.done,
|
||||
Effect.succeed({
|
||||
type: "failure",
|
||||
code: "not_attached",
|
||||
message: "The browser attachment was replaced.",
|
||||
}),
|
||||
)
|
||||
yield* result.peer
|
||||
.send({
|
||||
type: "browser.control.cancel",
|
||||
requestID: pending.requestID,
|
||||
leaseID: pending.leaseID,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
return yield* result.peer.send({ type: "browser.control.synced", revision: input.revision })
|
||||
})
|
||||
|
||||
const respond = Effect.fn("BrowserHost.respond")(function* (token: object, input: BrowserControl.Response) {
|
||||
const pending = yield* SynchronizedRef.modifyEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (current) {
|
||||
const active = current.active
|
||||
if (active?.token !== token) {
|
||||
return yield* new ProtocolError({ message: "Browser control connection is no longer active." })
|
||||
}
|
||||
const pending = active.pending.get(input.requestID)
|
||||
if (!pending) return [undefined, current] as readonly [Pending | undefined, State]
|
||||
if (pending.leaseID !== input.leaseID) {
|
||||
return yield* new ProtocolError({ message: "Browser response lease does not match its request." })
|
||||
}
|
||||
if (input.outcome.type === "success" && !compatible(pending.command, input.outcome.result)) {
|
||||
return yield* new ProtocolError({ message: "Browser response result does not match its request command." })
|
||||
}
|
||||
const next = new Map(active.pending)
|
||||
next.delete(input.requestID)
|
||||
return [pending, { shutdown: current.shutdown, active: { ...active, pending: next } }] as readonly [
|
||||
Pending | undefined,
|
||||
State,
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (pending) Deferred.doneUnsafe(pending.done, Effect.succeed(input.outcome))
|
||||
})
|
||||
|
||||
const receive = Effect.fn("BrowserHost.receive")(function* (token: object, message: BrowserControl.FromDesktop) {
|
||||
if (message.type === "browser.control.sync") return yield* sync(token, message)
|
||||
return yield* respond(token, message)
|
||||
})
|
||||
|
||||
const removePending = Effect.fn("BrowserHost.removePending")(function* (token: object, pending: Pending) {
|
||||
return yield* SynchronizedRef.modify(state, (current): readonly [boolean, State] => {
|
||||
const active = current.active
|
||||
if (active?.token !== token || active.pending.get(pending.requestID)?.token !== pending.token) {
|
||||
return [false, current]
|
||||
}
|
||||
const next = new Map(active.pending)
|
||||
next.delete(pending.requestID)
|
||||
return [true, { shutdown: current.shutdown, active: { ...active, pending: next } }]
|
||||
})
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("BrowserHost.cancel")(function* (token: object, peer: Peer, pending: Pending) {
|
||||
if (!(yield* removePending(token, pending))) return
|
||||
yield* peer
|
||||
.send({ type: "browser.control.cancel", requestID: pending.requestID, leaseID: pending.leaseID })
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
})
|
||||
|
||||
const request = Effect.fn("BrowserHost.request")(function* (
|
||||
connectionToken: object,
|
||||
attachment: Attachment,
|
||||
command: Browser.Command,
|
||||
) {
|
||||
if (!(yield* sessionExists(attachment.sessionID))) {
|
||||
yield* revokeSession(attachment.sessionID)
|
||||
return yield* new RequestError({ code: "not_attached", message: "The browser Session no longer exists." })
|
||||
}
|
||||
const pending: Pending = {
|
||||
token: {},
|
||||
requestID: BrowserControl.RequestID.create(),
|
||||
sessionID: attachment.sessionID,
|
||||
leaseID: attachment.leaseID,
|
||||
command,
|
||||
done: Deferred.makeUnsafe<Browser.Outcome>(),
|
||||
}
|
||||
const start = yield* SynchronizedRef.modify(state, (current): readonly [RequestStart, State] => {
|
||||
const active = current.active
|
||||
const currentAttachment = active?.attachments.get(attachment.sessionID)
|
||||
if (
|
||||
active?.token !== connectionToken ||
|
||||
!active.peer ||
|
||||
currentAttachment?.token !== attachment.token ||
|
||||
currentAttachment.leaseID !== attachment.leaseID
|
||||
) {
|
||||
return [
|
||||
{
|
||||
type: "error",
|
||||
error: new RequestError({
|
||||
code: "not_attached",
|
||||
message: "The browser attachment is no longer available.",
|
||||
}),
|
||||
},
|
||||
current,
|
||||
]
|
||||
}
|
||||
if (active.pending.size >= PendingLimit) {
|
||||
return [
|
||||
{
|
||||
type: "error",
|
||||
error: new RequestError({
|
||||
code: "overloaded",
|
||||
message: "The browser host has too many pending requests.",
|
||||
}),
|
||||
},
|
||||
current,
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ type: "ready", peer: active.peer, pending },
|
||||
{
|
||||
shutdown: current.shutdown,
|
||||
active: { ...active, pending: new Map(active.pending).set(pending.requestID, pending) },
|
||||
},
|
||||
]
|
||||
})
|
||||
if (start.type === "error") return yield* start.error
|
||||
|
||||
const cancelPending = cancel(connectionToken, start.peer, start.pending)
|
||||
const outcome = yield* start.peer
|
||||
.send({
|
||||
type: "browser.control.request",
|
||||
requestID: pending.requestID,
|
||||
sessionID: pending.sessionID,
|
||||
leaseID: pending.leaseID,
|
||||
command,
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new RequestError({ code: "internal", message: `Failed to send browser request: ${error.message}` }),
|
||||
),
|
||||
Effect.andThen(
|
||||
Deferred.await(pending.done).pipe(
|
||||
Effect.raceFirst(
|
||||
Deferred.await(attachment.revoked).pipe(
|
||||
Effect.andThen(
|
||||
new RequestError({
|
||||
code: "not_attached",
|
||||
message: "The browser attachment is no longer available.",
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.timeoutOrElse({
|
||||
duration: command.type === "navigate" ? "30 seconds" : "15 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(new RequestError({ code: "timeout", message: "The browser operation timed out." })),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.tapError((error) => (error.code === "timeout" ? cancelPending : Effect.void)),
|
||||
Effect.onInterrupt(() => cancelPending),
|
||||
Effect.ensuring(removePending(connectionToken, pending)),
|
||||
)
|
||||
if (outcome.type === "failure") return yield* new RequestError(outcome)
|
||||
return outcome.result
|
||||
})
|
||||
|
||||
const claim: Interface["claim"] = Effect.gen(function* () {
|
||||
const token = {}
|
||||
yield* SynchronizedRef.modifyEffect(
|
||||
state,
|
||||
Effect.fnUntraced(function* (current) {
|
||||
if (current.active) {
|
||||
return yield* new OwnerExistsError({ message: "A desktop browser host is already connected." })
|
||||
}
|
||||
if (current.shutdown) {
|
||||
return yield* new OwnerExistsError({ message: "The desktop browser host is shutting down." })
|
||||
}
|
||||
return [
|
||||
undefined,
|
||||
{ shutdown: false, active: { token, revision: -1, attachments: new Map(), pending: new Map() } },
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => release(token, "disconnected", "Browser host disconnected"))
|
||||
const started = yield* Ref.make(false)
|
||||
return {
|
||||
run: Effect.fn("BrowserHost.Connection.run")(function* (peer: Peer) {
|
||||
if (yield* Ref.getAndSet(started, true)) {
|
||||
return yield* new ProtocolError({ message: "Browser control connection can only run once." })
|
||||
}
|
||||
const installed = yield* SynchronizedRef.modify(state, (current): readonly [boolean, State] => {
|
||||
if (current.active?.token !== token || current.active.peer) return [false, current]
|
||||
return [true, { shutdown: current.shutdown, active: { ...current.active, peer } }]
|
||||
})
|
||||
if (!installed)
|
||||
return yield* new ProtocolError({ message: "Browser control connection is no longer active." })
|
||||
const synchronized = Deferred.makeUnsafe<void>()
|
||||
const running = yield* Effect.forkChild(
|
||||
Stream.runForEach(peer.messages, (message) =>
|
||||
receive(token, message).pipe(
|
||||
Effect.tap(() =>
|
||||
message.type === "browser.control.sync"
|
||||
? Effect.sync(() => Deferred.doneUnsafe(synchronized, Effect.void))
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
if (Exit.isSuccess(exit)) return release(token, "disconnected", "Browser host disconnected")
|
||||
const error = Cause.squash(exit.cause)
|
||||
const close =
|
||||
error instanceof ProtocolError ||
|
||||
(error instanceof ConnectionError && error.kind === "invalid_message")
|
||||
? "protocol_error"
|
||||
: error instanceof ConnectionError && error.kind === "message_too_large"
|
||||
? "message_too_large"
|
||||
: error instanceof ConnectionError && error.kind === "overloaded"
|
||||
? "overloaded"
|
||||
: "internal_error"
|
||||
return release(token, close, error instanceof Error ? error.message : "Browser host connection failed")
|
||||
}),
|
||||
),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Deferred.await(synchronized).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ProtocolError({ message: "Browser host did not publish attachments after connecting." }),
|
||||
),
|
||||
}),
|
||||
Effect.raceFirst(
|
||||
Fiber.join(running).pipe(
|
||||
Effect.andThen(
|
||||
new ProtocolError({ message: "Browser host disconnected before publishing attachments." }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* Fiber.join(running)
|
||||
}).pipe(Effect.ensuring(Fiber.interrupt(running)))
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const lease: Interface["lease"] = Effect.fn("BrowserHost.lease")(function* (sessionID) {
|
||||
if (!(yield* sessionExists(sessionID))) {
|
||||
yield* revokeSession(sessionID)
|
||||
return Option.none()
|
||||
}
|
||||
const active = (yield* SynchronizedRef.get(state)).active
|
||||
const attachment = active?.peer ? active.attachments.get(sessionID) : undefined
|
||||
if (!active || !attachment) return Option.none()
|
||||
return Option.some({
|
||||
id: attachment.leaseID,
|
||||
sessionID,
|
||||
state: attachment.state,
|
||||
revoked: Deferred.await(attachment.revoked),
|
||||
request: (command) => request(active.token, attachment, command),
|
||||
})
|
||||
})
|
||||
|
||||
const shutdown = Effect.gen(function* () {
|
||||
const released = yield* SynchronizedRef.modify(state, (current): readonly [Released, State] => [
|
||||
current.active
|
||||
? {
|
||||
attachments: Array.from(current.active.attachments.values()),
|
||||
pending: Array.from(current.active.pending.values()),
|
||||
peer: current.active.peer,
|
||||
}
|
||||
: { attachments: [], pending: [] },
|
||||
{ shutdown: true },
|
||||
])
|
||||
yield* settleReleased(released, "restart", "Server restarting")
|
||||
})
|
||||
|
||||
yield* Stream.runForEach(deleted, revokeSession).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.addFinalizer(() => shutdown)
|
||||
|
||||
return Service.of({ claim, lease, shutdown })
|
||||
})
|
||||
}
|
||||
|
||||
function compatible(command: Browser.Command, result: Browser.Result) {
|
||||
return command.type === result.type
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
return yield* make(
|
||||
(sessionID) => sessions.get(sessionID).pipe(Effect.map((session) => session !== undefined)),
|
||||
bus.subscribe(SessionEvent.Deleted).pipe(Stream.map((event) => event.data.sessionID)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, Bus.node],
|
||||
})
|
||||
@@ -3,82 +3,82 @@ export * as Catalog from "./catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { Bus } from "./bus"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { EventV2 } from "./event"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: Provider.MutableInfo
|
||||
models: Map<Model.ID, Model.MutableInfo>
|
||||
provider: ProviderV2.MutableInfo
|
||||
models: Map<ModelV2.ID, ModelV2.MutableInfo>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: Provider.ID; modelID: Model.ID }
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
|
||||
export { Event } from "@opencode-ai/schema/catalog"
|
||||
export const Event = Catalog.Event
|
||||
|
||||
type Data = {
|
||||
providers: Map<Provider.ID, ProviderRecord>
|
||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: Provider.ID) => ProviderRecord | undefined
|
||||
update: (providerID: Provider.ID, fn: (provider: Provider.MutableInfo) => void) => void
|
||||
remove: (providerID: Provider.ID) => void
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID) => void
|
||||
}
|
||||
model: {
|
||||
get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined
|
||||
update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void
|
||||
remove: (providerID: Provider.ID, modelID: Model.ID) => void
|
||||
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
set: (providerID: Provider.ID, modelID: Model.ID) => void
|
||||
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Provider.Info[]>
|
||||
readonly available: () => Effect.Effect<Provider.Info[]>
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (providerID: Provider.ID, modelID: Model.ID) => Effect.Effect<Model.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Model.Info[]>
|
||||
readonly available: () => Effect.Effect<Model.Info[]>
|
||||
readonly default: () => Effect.Effect<Model.Info | undefined>
|
||||
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
|
||||
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Catalog") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
const projectModel = (model: Model.Info, provider: Provider.Info) => {
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
return {
|
||||
...model,
|
||||
package: model.package ?? provider.package,
|
||||
settings: Provider.mergeOverlay(provider.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider.body, model.body),
|
||||
} satisfies Model.Info
|
||||
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
|
||||
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
|
||||
body: ProviderV2.mergeOverlay(provider.body, model.body),
|
||||
} satisfies ModelV2.Info
|
||||
}
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
@@ -93,8 +93,8 @@ const layer = Layer.effect(
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = {
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
@@ -110,13 +110,13 @@ const layer = Layer.effect(
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = {
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model =
|
||||
record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
|
||||
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
@@ -135,8 +135,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
yield* events.publish(Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const result: Interface = {
|
||||
@@ -144,15 +144,15 @@ const layer = Layer.effect(
|
||||
reload: state.reload,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("Catalog.provider.all")(function* () {
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("Catalog.provider.available")(function* () {
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
|
||||
@@ -161,14 +161,14 @@ const layer = Layer.effect(
|
||||
},
|
||||
|
||||
model: {
|
||||
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
return model && projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("Catalog.model.all")(function* () {
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
@@ -178,9 +178,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("Catalog.model.available")(function* () {
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
const models: Model.Info[] = []
|
||||
const models: ModelV2.Info[] = []
|
||||
for (const record of state.get().providers.values()) {
|
||||
if (!providers.has(record.provider.id)) continue
|
||||
for (const model of record.models.values()) {
|
||||
@@ -194,7 +194,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
|
||||
default: Effect.fn("Catalog.model.default")(function* () {
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID)
|
||||
@@ -207,18 +207,18 @@ const layer = Layer.effect(
|
||||
return (yield* result.model.available())[0]
|
||||
}),
|
||||
|
||||
small: Effect.fn("Catalog.model.small")(function* (providerID) {
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
|
||||
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
|
||||
return
|
||||
}
|
||||
|
||||
if (providerID === Provider.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
@@ -268,4 +268,4 @@ const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export * as CodeMode from "./codemode"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { CodeModeCatalog } from "./codemode/catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
import type { Any, Registration } from "./tool/tool"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: Any
|
||||
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
registrations: ReadonlyArray<Registration & { readonly key: string }>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("CodeMode.register")(function* (registrations) {
|
||||
if (registrations.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const registration of registrations)
|
||||
local.set(registration.key, [
|
||||
...(local.get(registration.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration,
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const registration of registrations) {
|
||||
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(registration.key, remaining)
|
||||
else local.delete(registration.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
|
||||
const registrations = new Map<string, ExecuteTool.Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
tool: ExecuteTool.create(registrations),
|
||||
catalog: ExecuteTool.catalog(registrations),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as Command from "./command"
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { Bus } from "./bus"
|
||||
import { EventV2 } from "./event"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
@@ -14,7 +14,7 @@ import { ShellSelect } from "./shell/select"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export { Event } from "@opencode-ai/schema/command"
|
||||
export const Event = Command.Event
|
||||
|
||||
export type Evaluation = {
|
||||
readonly text: string
|
||||
@@ -50,13 +50,13 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
|
||||
|
||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -76,7 +76,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
draft.commands.delete(name)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
|
||||
const mcpCommands = Effect.fnUntraced(function* () {
|
||||
@@ -92,12 +92,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return Service.of({
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("Command.get")(function* (name) {
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
const command = staticCommand(name)
|
||||
if (command) return command
|
||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
||||
}),
|
||||
list: Effect.fn("Command.list")(function* () {
|
||||
list: Effect.fn("CommandV2.list")(function* () {
|
||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
||||
const names = new Set(commands.map((command) => command.name))
|
||||
return [
|
||||
@@ -105,7 +105,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
|
||||
]
|
||||
}),
|
||||
evaluate: Effect.fn("Command.evaluate")(function* (input) {
|
||||
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
|
||||
const command = staticCommand(input.name)
|
||||
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
||||
config,
|
||||
@@ -247,7 +247,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Fiber, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Credential } from "./credential"
|
||||
import { Bus } from "./bus"
|
||||
import { EventV2 } from "./event"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -159,12 +159,6 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
||||
export interface Interface {
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
/**
|
||||
* Streams raw filesystem updates under config roots. Config owns root
|
||||
* topology and watch reconciliation; domain owners filter this feed for the
|
||||
* source files they parse and rebuild their own state.
|
||||
*/
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -174,32 +168,7 @@ export const Options = Schema.Struct({
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
|
||||
|
||||
export interface TestInterface extends Interface {
|
||||
/** Replaces the entries returned by subsequent entries() calls. */
|
||||
readonly setEntries: (entries: Entry[]) => Effect.Effect<void>
|
||||
/** Emits one filesystem update to every changes() subscriber. */
|
||||
readonly emitChange: (update: Watcher.Update) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Test extends Context.Service<Test, TestInterface>()("@opencode/Config/Test") {}
|
||||
|
||||
/** In-memory config for tests: static entries with replaceable state and a test-driven change feed. */
|
||||
export const testLayer = (initial: Entry[] = []) =>
|
||||
Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Ref.make(initial)
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const service = Test.of({
|
||||
entries: () => Ref.get(entries),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
setEntries: (next) => Ref.set(entries, next),
|
||||
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
||||
})
|
||||
return Context.empty().pipe(Context.add(Service, service), Context.add(Test, service))
|
||||
}),
|
||||
)
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
|
||||
|
||||
export const layer = (options?: Options) => Layer.effect(
|
||||
Service,
|
||||
@@ -208,7 +177,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
@@ -406,7 +375,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -420,7 +389,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filterEffect((event) =>
|
||||
wellknown.entries().pipe(
|
||||
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
|
||||
@@ -432,7 +401,7 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* bus.subscribe(WellKnown.Event.Updated).pipe(
|
||||
yield* events.subscribe(WellKnown.Event.Updated).pipe(
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
|
||||
),
|
||||
@@ -461,7 +430,6 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
return configs
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -470,7 +438,7 @@ export function configured(options?: Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Watcher.node, Bus.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
|
||||
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PositiveInt } from "../schema"
|
||||
|
||||
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Agent")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
model: ConfigModel.Selection.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -3,13 +3,13 @@ export * as ConfigAttachments from "./attachments"
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
|
||||
export class Image extends Schema.Class<Image>("ConfigV2.Attachments.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Attachments")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigModel } from "./model"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Command")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -3,11 +3,11 @@ export * as ConfigCompaction from "./compaction"
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
|
||||
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Compaction")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
|
||||
auto: Schema.Boolean.pipe(Schema.optional),
|
||||
keep: Keep.pipe(Schema.optional),
|
||||
buffer: NonNegativeInt.pipe(Schema.optional),
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
|
||||
export class Entry extends Schema.Class<Entry>("ConfigV2.Formatter.Entry")({
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
command: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
})
|
||||
|
||||
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
|
||||
export class Server extends Schema.Class<Server>("ConfigV2.LSP.Server")({
|
||||
command: Schema.String.pipe(Schema.Array),
|
||||
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
|
||||
@@ -15,7 +15,7 @@ export const Remote = Mcp.RemoteConfig
|
||||
export type Remote = Mcp.RemoteConfig
|
||||
export const Server = Mcp.ServerConfig
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.MCP")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
|
||||
timeout: Timeout.pipe(Schema.optional),
|
||||
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigPlugin from "./plugin"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
|
||||
export class Entry extends Schema.Class<Entry>("ConfigV2.Plugin.Entry")({
|
||||
package: Schema.String,
|
||||
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
@@ -11,17 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "../../permission"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import type { LocationMutation } from "../../location-mutation"
|
||||
import type { ReadTool } from "../../tool/plugin/read"
|
||||
import type { EditTool } from "../../tool/plugin/edit"
|
||||
import type { ReadTool } from "../../tool/read"
|
||||
import type { EditTool } from "../../tool/edit"
|
||||
|
||||
const legacySources = [
|
||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||
{ pattern: "{mode,modes}/*.md", primary: true },
|
||||
] as const
|
||||
// Keep in sync with the legacySources patterns and the name-strip regex in decode.
|
||||
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
@@ -69,40 +67,21 @@ export const Plugin = define({
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Config.Document[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
)
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.documents = yield* load()
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const permissions = expandPermissions(
|
||||
loaded.documents.flatMap((document) => document.info.permissions ?? []),
|
||||
global.home,
|
||||
)
|
||||
const configuredDefault = Config.latest(loaded.documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(Agent.ID.make(configuredDefault))
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
}
|
||||
|
||||
for (const document of loaded.documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = Agent.ID.make(id)
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
@@ -134,20 +113,20 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||
// check: directory-level events such as renames carry no per-file paths.
|
||||
function isAgentSource(entries: Config.Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
|
||||
)
|
||||
}
|
||||
|
||||
function expandPermissions(rules: Permission.Ruleset, home: string): Permission.Ruleset {
|
||||
function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
|
||||
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
|
||||
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
|
||||
return rules.map((rule) =>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Command } from "../../command"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigCommand } from "../command"
|
||||
@@ -27,28 +27,7 @@ export const Plugin = define({
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
)
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
|
||||
),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.documents = yield* load()
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.command.transform((draft) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
@@ -67,22 +46,19 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
// Keep in sync with the loadDirectory scan pattern and the name-strip regex in decode.
|
||||
const sourceDirectories = ["command", "commands"] as const
|
||||
|
||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||
// directory-level events such as renames carry no per-file paths.
|
||||
function isCommandSource(entries: Config.Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
|
||||
)
|
||||
}
|
||||
|
||||
function loadDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigPolicyPlugin from "./policy"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Wildcard } from "../../util/wildcard"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Provider } from "../../provider"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.provider",
|
||||
@@ -49,9 +49,9 @@ export const Plugin = define({
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined)
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
provider.settings = ProviderV2.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = ProviderV2.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = ProviderV2.mergeOverlay(provider.body, item.body)
|
||||
})
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, id, (model) => {
|
||||
@@ -62,9 +62,9 @@ export const Plugin = define({
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.settings !== undefined)
|
||||
model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = ProviderV2.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = ProviderV2.mergeOverlay(model.body, config.body)
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
@@ -81,10 +81,10 @@ export const Plugin = define({
|
||||
model.variants.push(existing)
|
||||
}
|
||||
if (variant.settings !== undefined)
|
||||
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
|
||||
existing.settings = ProviderV2.mergeOverlay(existing.settings, variant.settings)
|
||||
if (variant.headers !== undefined)
|
||||
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
|
||||
existing.headers = ProviderV2.mergeHeaders(existing.headers, variant.headers)
|
||||
if (variant.body !== undefined) existing.body = ProviderV2.mergeOverlay(existing.body, variant.body)
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigReferencePlugin from "./reference"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import path from "path"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { Skill } from "../../skill"
|
||||
import { SkillV2 } from "../../skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../../location"
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Plugin = define({
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of [...claude, ...agents]) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
@@ -31,10 +31,10 @@ export const Plugin = define({
|
||||
}
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.join(directory, "skills")),
|
||||
}),
|
||||
@@ -42,12 +42,12 @@ export const Plugin = define({
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
|
||||
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
draft.source(
|
||||
Skill.DirectorySource.make({
|
||||
SkillV2.DirectorySource.make({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ConfigWebSearchPlugin from "./websearch"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
@@ -12,17 +12,17 @@ export const Overlays = {
|
||||
body: JsonRecord.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
read: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
write: Money.USDPerMillionTokens.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
@@ -32,22 +32,22 @@ class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
|
||||
cache: Cache.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
|
||||
context: Schema.Int.pipe(Schema.optional),
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
modelID: ID.pipe(Schema.optional),
|
||||
family: Family.pipe(Schema.optional),
|
||||
class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
modelID: ModelV2.ID.pipe(Schema.optional),
|
||||
family: ModelV2.Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
compatibility: Compatibility.pipe(Schema.optional),
|
||||
compatibility: ModelV2.Compatibility.pipe(Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
...Overlays,
|
||||
capabilities: Capabilities.pipe(Schema.optional),
|
||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
id: ModelV2.VariantID,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
@@ -55,7 +55,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
limit: Limit.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
package: Schema.String.pipe(Schema.optional),
|
||||
|
||||
@@ -2,14 +2,14 @@ export * as ConfigReference from "./reference"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
|
||||
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
|
||||
repository: Schema.String,
|
||||
branch: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
|
||||
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
|
||||
path: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
hidden: Schema.Boolean.pipe(Schema.optional),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user