From 4a805c2823c3a5d90265e2ea3878072e8fd0bab3 Mon Sep 17 00:00:00 2001 From: can2 Date: Tue, 11 Aug 2026 19:25:30 +0800 Subject: [PATCH] feat: integrate managed opencode plugin host --- .gitignore | 1 + Cargo.lock | 18 + Cargo.toml | 1 + package.json | 14 +- scripts/check-build-prereqs.mjs | 32 +- scripts/check-build-prereqs.test.mjs | 70 +- scripts/cli-product.mjs | 28 +- scripts/cli-product.test.mjs | 19 +- scripts/cli/package-contract.test.mjs | 15 + scripts/cli/package-unix.sh | 17 + scripts/cli/package-windows.ps1 | 19 +- scripts/cli/test-install-unix.sh | 1 + scripts/cli/test-install-windows.ps1 | 5 + .../core-boundaries/rules/crate-layout.mjs | 1 + scripts/core-boundaries/rules/crate-rules.mjs | 14 + scripts/frontend-build-all.mjs | 2 +- src/apps/cli/install.ps1 | 39 +- src/apps/cli/install.sh | 52 +- src/apps/cli/src/agent/runtime_client.rs | 40 +- src/apps/cli/src/agent/tui_client.rs | 38 +- src/apps/cli/src/logging.rs | 17 +- src/apps/cli/src/main.rs | 48 +- src/apps/cli/src/plugin_host_activation.rs | 85 + src/apps/cli/src/self_update.rs | 123 +- src/apps/cli/src/shared_runtime.rs | 55 +- src/apps/desktop/src/api/agentic_api.rs | 18 + src/apps/desktop/src/api/ssh_api.rs | 3 +- src/apps/desktop/src/api/system_api.rs | 18 +- src/apps/desktop/src/lib.rs | 121 +- .../src/runtime/session_application.rs | 26 + src/apps/desktop/src/sleep_prevention.rs | 9 +- src/apps/desktop/src/tray.rs | 5 +- src/apps/extension-host/.gitattributes | 2 + src/apps/extension-host/.gitignore | 2 + src/apps/extension-host/PROTOCOL.md | 514 +++ src/apps/extension-host/README.md | 123 + src/apps/extension-host/bun.lock | 112 + .../extension-host/examples/example-plugin.ts | 84 + src/apps/extension-host/package.json | 28 + src/apps/extension-host/protocol.schema.json | 3354 +++++++++++++++++ .../script/generate-protocol.ts | 90 + src/apps/extension-host/src/backend.ts | 56 + src/apps/extension-host/src/bun-loader.ts | 91 + src/apps/extension-host/src/errors.ts | 30 + src/apps/extension-host/src/gateway.ts | 113 + src/apps/extension-host/src/host.ts | 1136 ++++++ src/apps/extension-host/src/loader.ts | 620 +++ src/apps/extension-host/src/log.ts | 75 + src/apps/extension-host/src/main.ts | 210 ++ src/apps/extension-host/src/protocol.ts | 437 +++ src/apps/extension-host/src/rpc.ts | 518 +++ src/apps/extension-host/src/semver.d.ts | 3 + src/apps/extension-host/src/service.ts | 102 + src/apps/extension-host/src/streams.ts | 175 + src/apps/extension-host/src/tool-schema.ts | 94 + src/apps/extension-host/src/wire.ts | 79 + src/apps/extension-host/test/boundary.test.ts | 88 + .../test/fixtures/gateway/injected.ts | 50 + .../test/fixtures/loader/legacy.ts | 4 + .../test/fixtures/loader/preferred.ts | 6 + .../test/fixtures/runtime/full.js | 152 + .../test/fixtures/runtime/sequence-a.js | 33 + .../test/fixtures/runtime/sequence-b.js | 29 + src/apps/extension-host/test/gateway.test.ts | 429 +++ .../test/helpers/process-host.ts | 146 + src/apps/extension-host/test/host.test.ts | 653 ++++ src/apps/extension-host/test/loader.test.ts | 422 +++ src/apps/extension-host/test/process.test.ts | 245 ++ src/apps/extension-host/test/rpc.test.ts | 256 ++ src/apps/extension-host/tsconfig.json | 10 + src/apps/server/src/routes/dispatch.rs | 11 +- .../adapters/opencode-plugin-host/AGENTS.md | 9 + .../adapters/opencode-plugin-host/Cargo.toml | 29 + .../opencode-plugin-host/src/frame.rs | 54 + .../opencode-plugin-host/src/host_log.rs | 191 + .../adapters/opencode-plugin-host/src/http.rs | 744 ++++ .../adapters/opencode-plugin-host/src/lib.rs | 463 +++ .../adapters/opencode-plugin-host/src/peer.rs | 353 ++ .../opencode-plugin-host/src/peer_runtime.rs | 227 ++ .../src/stream_registry.rs | 328 ++ .../opencode-plugin-host/src/tests.rs | 455 +++ .../src/tests/peer_tests.rs | 349 ++ src/crates/assembly/core/Cargo.toml | 3 + .../src/agentic/persistence/session_branch.rs | 2 +- .../agentic/tools/browser_control/actions.rs | 13 +- .../agentic/tools/file_read_state_runtime.rs | 7 +- .../implementations/exec_command/command.rs | 4 +- .../infrastructure/app_paths/path_manager.rs | 5 +- src/crates/assembly/core/src/lib.rs | 6 + src/crates/assembly/core/src/plugin_host.rs | 735 ++++ .../assembly/core/src/plugin_host_http.rs | 522 +++ .../core/src/plugin_host_http_routes.rs | 841 +++++ .../core/src/plugin_host_http_routes_impl.rs | 687 ++++ .../assembly/core/src/product_runtime.rs | 55 + .../assembly/core/src/service/config/types.rs | 79 + .../src/external_integration_policy.rs | 4 - .../product-domains/src/miniapp/market.rs | 1 - .../tool-execution/src/search/glob_search.rs | 1 - .../tool-execution/src/web_readable.rs | 5 +- .../services-integrations/src/hook_import.rs | 16 +- .../src/mcp/protocol/client_info.rs | 7 +- .../src/remote_connect/page_upload.rs | 14 +- .../src/remote_ssh/transport.rs | 21 +- .../services/terminal/src/transcript.rs | 13 +- 104 files changed, 17636 insertions(+), 143 deletions(-) create mode 100644 src/apps/cli/src/plugin_host_activation.rs create mode 100644 src/apps/extension-host/.gitattributes create mode 100644 src/apps/extension-host/.gitignore create mode 100644 src/apps/extension-host/PROTOCOL.md create mode 100644 src/apps/extension-host/README.md create mode 100644 src/apps/extension-host/bun.lock create mode 100644 src/apps/extension-host/examples/example-plugin.ts create mode 100644 src/apps/extension-host/package.json create mode 100644 src/apps/extension-host/protocol.schema.json create mode 100644 src/apps/extension-host/script/generate-protocol.ts create mode 100644 src/apps/extension-host/src/backend.ts create mode 100644 src/apps/extension-host/src/bun-loader.ts create mode 100644 src/apps/extension-host/src/errors.ts create mode 100644 src/apps/extension-host/src/gateway.ts create mode 100644 src/apps/extension-host/src/host.ts create mode 100644 src/apps/extension-host/src/loader.ts create mode 100644 src/apps/extension-host/src/log.ts create mode 100644 src/apps/extension-host/src/main.ts create mode 100644 src/apps/extension-host/src/protocol.ts create mode 100644 src/apps/extension-host/src/rpc.ts create mode 100644 src/apps/extension-host/src/semver.d.ts create mode 100644 src/apps/extension-host/src/service.ts create mode 100644 src/apps/extension-host/src/streams.ts create mode 100644 src/apps/extension-host/src/tool-schema.ts create mode 100644 src/apps/extension-host/src/wire.ts create mode 100644 src/apps/extension-host/test/boundary.test.ts create mode 100644 src/apps/extension-host/test/fixtures/gateway/injected.ts create mode 100644 src/apps/extension-host/test/fixtures/loader/legacy.ts create mode 100644 src/apps/extension-host/test/fixtures/loader/preferred.ts create mode 100644 src/apps/extension-host/test/fixtures/runtime/full.js create mode 100644 src/apps/extension-host/test/fixtures/runtime/sequence-a.js create mode 100644 src/apps/extension-host/test/fixtures/runtime/sequence-b.js create mode 100644 src/apps/extension-host/test/gateway.test.ts create mode 100644 src/apps/extension-host/test/helpers/process-host.ts create mode 100644 src/apps/extension-host/test/host.test.ts create mode 100644 src/apps/extension-host/test/loader.test.ts create mode 100644 src/apps/extension-host/test/process.test.ts create mode 100644 src/apps/extension-host/test/rpc.test.ts create mode 100644 src/apps/extension-host/tsconfig.json create mode 100644 src/crates/adapters/opencode-plugin-host/AGENTS.md create mode 100644 src/crates/adapters/opencode-plugin-host/Cargo.toml create mode 100644 src/crates/adapters/opencode-plugin-host/src/frame.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/host_log.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/http.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/lib.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/peer.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/stream_registry.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/tests.rs create mode 100644 src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs create mode 100644 src/crates/assembly/core/src/plugin_host.rs create mode 100644 src/crates/assembly/core/src/plugin_host_http.rs create mode 100644 src/crates/assembly/core/src/plugin_host_http_routes.rs create mode 100644 src/crates/assembly/core/src/plugin_host_http_routes_impl.rs diff --git a/.gitignore b/.gitignore index 95806da18..339fd8ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ external/ /.flashgrep-index-engine/ .design/ +.pnpm-store/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 4fce6abf5..9126b4665 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1063,6 +1063,7 @@ dependencies = [ "bitfun-external-sources", "bitfun-harness", "bitfun-opencode-adapter", + "bitfun-opencode-plugin-host", "bitfun-plugin-runtime-client", "bitfun-product-capabilities", "bitfun-product-domains", @@ -1307,6 +1308,23 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "bitfun-opencode-plugin-host" +version = "0.2.17" +dependencies = [ + "base64 0.22.1", + "bitfun-services-core", + "log", + "rand 0.8.7", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "url", + "urlencoding", +] + [[package]] name = "bitfun-page-function-runtime" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index bc9983d5b..a90461be4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/opencode-plugin-host", "src/crates/adapters/claude-code-adapter", "src/crates/adapters/codex-adapter", "src/crates/adapters/static-hook-support", diff --git a/package.json b/package.json index 2144d0a8a..3ec84edd7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,10 @@ "type-check:skin-market": "pnpm --dir src/skin-market-web type-check", "test:skin-market": "pnpm --dir src/skin-market-web test", "prepare:mobile-web": "node scripts/mobile-web-build.cjs", + "plugin-host:install": "bun install --cwd src/apps/extension-host --frozen-lockfile", + "plugin-host:build": "bun run --cwd src/apps/extension-host build", + "plugin-host:prepare": "pnpm run plugin-host:install && pnpm run plugin-host:build", + "plugin-host:test": "bun test --cwd src/apps/extension-host", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", @@ -91,11 +95,11 @@ "installer:build:only": "pnpm --dir BitFun-Installer run installer:build:only", "installer:build:only:fast": "pnpm --dir BitFun-Installer run installer:build:only:fast", "installer:dev": "pnpm --dir BitFun-Installer run installer:dev", - "cli:dev": "node scripts/cli-product.mjs dev", - "cli:build": "node scripts/cli-product.mjs build", - "cli:install": "node scripts/install-cli.mjs", - "cli:install:unix": "bash src/apps/cli/install.sh", - "cli:install:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", + "cli:dev": "pnpm run plugin-host:prepare && node scripts/cli-product.mjs dev", + "cli:build": "pnpm run plugin-host:prepare && node scripts/cli-product.mjs build", + "cli:install": "pnpm run plugin-host:prepare && node scripts/install-cli.mjs", + "cli:install:unix": "pnpm run plugin-host:prepare && bash src/apps/cli/install.sh", + "cli:install:windows": "pnpm run plugin-host:prepare && powershell.exe -NoProfile -ExecutionPolicy Bypass -File src/apps/cli/install.ps1", "cli:run": "cd src/apps/cli && cargo run --release --", "cli:exec": "cd src/apps/cli && cargo run -- exec", "cli:check": "cd src/apps/cli && cargo check", diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs index 10908401c..d4a4a0cc0 100644 --- a/scripts/check-build-prereqs.mjs +++ b/scripts/check-build-prereqs.mjs @@ -10,6 +10,8 @@ * - src/mobile-web/dist missing → cargo check -p bitfun-desktop and * cargo check --workspace fail with "resource path '../../mobile-web/dist' * doesn't exist" in the bitfun-desktop build script + * - OpenCode extension Host dist missing → CLI builds cannot bundle the + * Bun plugin Host resources * - sherpa-onnx prebuilt libs missing → sherpa-onnx-sys build script attempts * a network download from GitHub that fails on poor connectivity * @@ -57,7 +59,25 @@ function runChecks(rootDir) { }); } - // --- Check 3: sherpa-onnx prebuilt libs --- + // --- Check 3: OpenCode extension Host dist (CLI bundled resource) --- + const pluginHostDist = join( + rootDir, + 'src', + 'apps', + 'extension-host', + 'dist', + ); + const pluginHostEntries = [join(pluginHostDist, 'extension-host.js')]; + if (pluginHostEntries.some((entry) => !existsSync(entry))) { + errors.push({ + name: 'OpenCode extension Host dist', + message: + 'src/apps/extension-host/dist is missing the Bun Host entry. CLI builds bundle this directory as the plugin Host resource.', + fix: ['pnpm', 'run', 'plugin-host:prepare'], + }); + } + + // --- Check 4: sherpa-onnx prebuilt libs --- // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt//lib/ // and returns immediately without downloading. Only warn for the first-build // scenario where no prebuilt cache exists yet. @@ -116,7 +136,15 @@ function runFixes(pendingFixes, rootDir) { const [cmd, ...args] = fix; console.log(`$ ${fix.join(' ')}`); try { - execFileSync(cmd, args, { stdio: 'inherit', cwd: rootDir }); + if (process.platform === 'win32') { + execFileSync( + process.env.ComSpec || 'cmd.exe', + ['/d', '/s', '/c', fix.join(' ')], + { stdio: 'inherit', cwd: rootDir }, + ); + } else { + execFileSync(cmd, args, { stdio: 'inherit', cwd: rootDir }); + } } catch { console.error(`Fix command failed: ${fix.join(' ')}\n`); allSucceeded = false; diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs index ef12f4a17..56f7882c7 100644 --- a/scripts/check-build-prereqs.test.mjs +++ b/scripts/check-build-prereqs.test.mjs @@ -12,7 +12,12 @@ const repoRoot = path.resolve( ); const scriptPath = path.join(repoRoot, 'scripts/check-build-prereqs.mjs'); -function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx = null } = {}) { +function createTestRoot({ + nodeModules = false, + mobileWebDist = false, + pluginHostDist = false, + sherpaOnnx = null, +} = {}) { const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-prereqs-')); if (nodeModules) { @@ -25,6 +30,18 @@ function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx writeFileSync(path.join(distDir, 'index.html'), ''); } + if (pluginHostDist) { + const distDir = path.join( + root, + 'src', + 'apps', + 'extension-host', + 'dist', + ); + mkdirSync(distDir, { recursive: true }); + writeFileSync(path.join(distDir, 'extension-host.js'), ''); + } + if (sherpaOnnx) { for (const version of sherpaOnnx) { const libDir = path.join( @@ -44,10 +61,10 @@ function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx function createFakePnpm() { const binDir = mkdtempSync(path.join(tmpdir(), 'bitfun-fake-pnpm-')); - const pnpmPath = path.join(binDir, 'pnpm'); + const fakePnpmPath = path.join(binDir, 'fake-pnpm.cjs'); writeFileSync( - pnpmPath, - `#!/usr/bin/env node + fakePnpmPath, + ` const { mkdirSync, writeFileSync } = require('fs'); const args = process.argv.slice(2); if (args[0] === 'install') { @@ -55,10 +72,25 @@ if (args[0] === 'install') { } else if (args[0] === 'run' && args[1] === 'prepare:mobile-web') { mkdirSync('src/mobile-web/dist', { recursive: true }); writeFileSync('src/mobile-web/dist/index.html', ''); +} else if (args[0] === 'run' && args[1] === 'plugin-host:prepare') { + mkdirSync('src/apps/extension-host/dist', { recursive: true }); + writeFileSync('src/apps/extension-host/dist/extension-host.js', ''); } `, ); - chmodSync(pnpmPath, 0o755); + if (process.platform === 'win32') { + writeFileSync( + path.join(binDir, 'pnpm.cmd'), + `@echo off\r\n"${process.execPath}" "%~dp0fake-pnpm.cjs" %*\r\n`, + ); + } else { + const pnpmPath = path.join(binDir, 'pnpm'); + writeFileSync( + pnpmPath, + `#!/usr/bin/env node\nrequire('./fake-pnpm.cjs');\n`, + ); + chmodSync(pnpmPath, 0o755); + } return binDir; } @@ -90,13 +122,14 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) const root = createTestRoot({ nodeModules: true, mobileWebDist: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); const result = runCheck(root, { sherpaEnv: '' }); - assert.equal(result.status, 0); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stdout, /Build prerequisite check passed/); assert.doesNotMatch(result.stderr, /\[WARN\]/); }); @@ -104,6 +137,7 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) test('fails when root node_modules is missing', (t) => { const root = createTestRoot({ mobileWebDist: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -118,6 +152,7 @@ test('fails when root node_modules is missing', (t) => { test('fails when mobile-web dist is missing', (t) => { const root = createTestRoot({ nodeModules: true, + pluginHostDist: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -129,8 +164,27 @@ test('fails when mobile-web dist is missing', (t) => { assert.match(result.stderr, /Fix: pnpm run prepare:mobile-web/); }); +test('fails when OpenCode extension Host dist is missing', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] OpenCode extension Host dist/); + assert.match(result.stderr, /Fix: pnpm run plugin-host:prepare/); +}); + test('warns when sherpa-onnx prebuilt dir does not exist (first build)', (t) => { - const root = createTestRoot({ nodeModules: true, mobileWebDist: true }); + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + pluginHostDist: true, + }); t.after(() => rmSync(root, { recursive: true, force: true })); const result = runCheck(root, { sherpaEnv: '' }); @@ -161,7 +215,7 @@ test('--fix runs fix commands, re-verifies, and exits 0 when errors resolved', ( const result = runCheck(root, { fix: true, extraPath: binDir, sherpaEnv: '' }); - assert.equal(result.status, 0); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); assert.match(result.stdout, /Attempting fixes/); assert.match(result.stdout, /\$ pnpm install/); assert.match(result.stdout, /\$ pnpm run prepare:mobile-web/); diff --git a/scripts/cli-product.mjs b/scripts/cli-product.mjs index 999ddf4c9..000e5c9f9 100644 --- a/scripts/cli-product.mjs +++ b/scripts/cli-product.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { copyFileSync, existsSync, mkdirSync } from 'node:fs'; +import { copyFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -9,6 +9,24 @@ import { ensureProductOutputDirectory, productBuildEnvironment } from './product import { ProductDefinitionError, resolveProductDefinition } from './product-customization/resolver.mjs'; const ROOT = resolve(import.meta.dirname, '..'); +const PLUGIN_HOST_DIST = join(ROOT, 'src', 'apps', 'extension-host', 'dist'); +const PLUGIN_HOST_ENTRIES = ['extension-host.js']; + +export function stagePluginHostResources(destination, sourceDirectory = PLUGIN_HOST_DIST) { + for (const entry of PLUGIN_HOST_ENTRIES) { + const source = join(sourceDirectory, entry); + if (!existsSync(source)) { + throw new Error( + `CLI plugin Host resource was not produced: ${source}. Run pnpm run plugin-host:prepare.`, + ); + } + } + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + for (const entry of PLUGIN_HOST_ENTRIES) { + copyFileSync(join(sourceDirectory, entry), join(destination, entry)); + } +} function stripDelimiter(args) { const result = [...args]; @@ -76,6 +94,12 @@ export function cliBuildPlan(resolution, mode, forwardArgs = [], platform = proc cargoArgs, internalBinaryPath: join(cargoTargetDir, ...(target ? [target] : []), profileDir, `bitfun${suffix}`), stagedBinaryPath: join(resolution.outputDir, 'package', `${resolution.assembly.binaryName}${suffix}`), + stagedPluginHostPath: join( + resolution.outputDir, + 'package', + 'resources', + 'ext-host', + ), }; } @@ -93,7 +117,9 @@ function run(plan) { ensureProductOutputDirectory(plan.resolution); mkdirSync(join(plan.stagedBinaryPath, '..'), { recursive: true }); copyFileSync(plan.internalBinaryPath, plan.stagedBinaryPath); + stagePluginHostResources(plan.stagedPluginHostPath); console.log(`[product] staged CLI: ${plan.stagedBinaryPath}`); + console.log(`[product] staged plugin Host: ${plan.stagedPluginHostPath}`); } } diff --git a/scripts/cli-product.test.mjs b/scripts/cli-product.test.mjs index d9d039164..19e2d97c3 100644 --- a/scripts/cli-product.test.mjs +++ b/scripts/cli-product.test.mjs @@ -1,13 +1,29 @@ import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import test from 'node:test'; -import { cliBuildPlan } from './cli-product.mjs'; +import { cliBuildPlan, stagePluginHostResources } from './cli-product.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; const ROOT = resolve(import.meta.dirname, '..'); const ACME = join(ROOT, 'products', 'fixtures', 'acme', 'product.jsonc'); +test('CLI stages only the supported plugin Host entry', (t) => { + const root = mkdtempSync(join(tmpdir(), 'bitfun-cli-plugin-host-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const source = join(root, 'source'); + const destination = join(root, 'destination'); + mkdirSync(source); + writeFileSync(join(source, 'extension-host.js'), 'current'); + writeFileSync(join(source, 'stale-runtime.js'), 'stale'); + + stagePluginHostResources(destination, source); + + assert.deepEqual(readdirSync(destination), ['extension-host.js']); +}); + test('CLI uses the shared resolver and stages the internal binary under the member name', () => { const resolution = resolveProductDefinition({ rootDir: ROOT, productConfig: ACME, member: 'cli' }); const plan = cliBuildPlan(resolution, 'build', ['--locked'], 'win32'); @@ -16,6 +32,7 @@ test('CLI uses the shared resolver and stages the internal binary under the memb assert.ok(plan.cargoArgs.includes('--locked')); assert.ok(plan.internalBinaryPath.endsWith('bitfun.exe')); assert.ok(plan.stagedBinaryPath.endsWith('acme.exe')); + assert.ok(plan.stagedPluginHostPath.endsWith(join('resources', 'ext-host'))); assert.equal(plan.environment.BITFUN_PRODUCT_DISPLAY_NAME, 'Acme CLI'); }); diff --git a/scripts/cli/package-contract.test.mjs b/scripts/cli/package-contract.test.mjs index f66046bb7..d3663c4f1 100644 --- a/scripts/cli/package-contract.test.mjs +++ b/scripts/cli/package-contract.test.mjs @@ -17,6 +17,21 @@ for (const packageScript of [ assert.match(content, /THIRD_PARTY_NOTICES\.md/); assert.match(content, /models-dev\.LICENSE\.txt/); assert.match(content, /models-dev\.provenance\.json/); + assert.match(content, /extension-host/); + assert.match(content, /resources[\\/]ext-host/); + assert.match(content, /extension-host\.js/); +} + +for (const workflow of [ + '.github/workflows/cli-package.yml', + '.github/workflows/cli-package-manual.yml', + '.github/workflows/linux-binaries.yml', + '.github/workflows/nightly.yml', +]) { + const content = read(workflow); + assert.match(content, /oven-sh\/setup-bun@v2/); + assert.match(content, /plugin Host resources/); + assert.match(content, /plugin-host:prepare|extension-host/); } for (const workflow of [ diff --git a/scripts/cli/package-unix.sh b/scripts/cli/package-unix.sh index 53601a463..ae1543a9b 100644 --- a/scripts/cli/package-unix.sh +++ b/scripts/cli/package-unix.sh @@ -16,6 +16,18 @@ OUTPUT_DIR="${4:-${REPO_ROOT}}" PRIMARY="${RELEASE_DIR}/bitfun" LEGACY="${RELEASE_DIR}/bitfun-cli" DEPRECATION='Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' +PLUGIN_HOST_DIST="${REPO_ROOT}/src/apps/extension-host/dist" +PLUGIN_HOST_RESOURCE_DIR="resources/ext-host" + +assert_plugin_host_resources() { + local directory="$1" + for entry in extension-host.js; do + if [ ! -f "${directory}/${entry}" ]; then + echo "Error: plugin Host resource is missing: ${directory}/${entry}" >&2 + return 1 + fi + done +} assert_legacy_entrypoint() { local executable="$1" @@ -37,6 +49,7 @@ assert_legacy_entrypoint() { "$PRIMARY" --version "$PRIMARY" --help >/dev/null assert_legacy_entrypoint "$LEGACY" +assert_plugin_host_resources "$PLUGIN_HOST_DIST" STAGE_NAME="bitfun-cli-${VERSION}-${TARGET}" STAGE_DIR="${OUTPUT_DIR}/dist-cli/${STAGE_NAME}" @@ -59,6 +72,9 @@ fi if [ -d "${REPO_ROOT}/src/apps/cli/prompts" ]; then cp -R "${REPO_ROOT}/src/apps/cli/prompts" "$STAGE_DIR/prompts" fi +mkdir -p "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR" +cp "$PLUGIN_HOST_DIST/extension-host.js" "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR/" +assert_plugin_host_resources "$STAGE_DIR/$PLUGIN_HOST_RESOURCE_DIR" ARCHIVE="${OUTPUT_DIR}/${STAGE_NAME}.tar.gz" tar -C "$(dirname "$STAGE_DIR")" -czf "$ARCHIVE" "$(basename "$STAGE_DIR")" @@ -87,6 +103,7 @@ LEGACY_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) [ -f "$EXTRACT_DIR/$STAGE_NAME/THIRD_PARTY_NOTICES.md" ] [ -f "$EXTRACT_DIR/$STAGE_NAME/third-party/models.dev/LICENSE.txt" ] [ -f "$EXTRACT_DIR/$STAGE_NAME/third-party/models.dev/provenance.json" ] +assert_plugin_host_resources "$EXTRACT_DIR/$STAGE_NAME/$PLUGIN_HOST_RESOURCE_DIR" "${PRIMARY_CANDIDATES[0]}" --version "${PRIMARY_CANDIDATES[0]}" --help >/dev/null assert_legacy_entrypoint "${LEGACY_CANDIDATES[0]}" diff --git a/scripts/cli/package-windows.ps1 b/scripts/cli/package-windows.ps1 index b29aa94d2..ff2bbbb82 100644 --- a/scripts/cli/package-windows.ps1 +++ b/scripts/cli/package-windows.ps1 @@ -27,6 +27,17 @@ $OutputDir = [IO.Path]::GetFullPath($OutputDir) $primary = Join-Path $ReleaseDir 'bitfun.exe' $legacy = Join-Path $ReleaseDir 'bitfun-cli.exe' $deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' +$pluginHostDist = Join-Path $repoRoot 'src\apps\extension-host\dist' +$pluginHostResourceRelative = 'resources\ext-host' + +function Assert-PluginHostResources([string]$Directory) { + foreach ($entry in @('extension-host.js')) { + $path = Join-Path $Directory $entry + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Plugin Host resource is missing: $path" + } + } +} function Assert-LastExitCode([string]$Description) { if ($LASTEXITCODE -ne 0) { @@ -83,6 +94,7 @@ Assert-LastExitCode 'bitfun --help' Assert-LegacyEntrypoint $legacy Assert-NoRedistributableRuntime $primary Assert-NoRedistributableRuntime $legacy +Assert-PluginHostResources $pluginHostDist $stageName = "bitfun-cli-$Version-$Target" $stageDir = Join-Path (Join-Path $OutputDir 'dist-cli') $stageName @@ -111,6 +123,10 @@ if (Test-Path -LiteralPath $themes -PathType Container) { if (Test-Path -LiteralPath $prompts -PathType Container) { Copy-Item -LiteralPath $prompts -Destination (Join-Path $stageDir 'prompts') -Recurse -Force } +$pluginHostResources = Join-Path $stageDir $pluginHostResourceRelative +New-Item -ItemType Directory -Path $pluginHostResources -Force | Out-Null +Copy-Item -LiteralPath (Join-Path $pluginHostDist 'extension-host.js') -Destination $pluginHostResources -Force +Assert-PluginHostResources $pluginHostResources $archive = Join-Path $OutputDir "$stageName.zip" Compress-Archive -Path $stageDir -DestinationPath $archive -CompressionLevel Optimal -Force @@ -136,7 +152,8 @@ try { 'PROJECT-README.md', 'THIRD_PARTY_NOTICES.md', 'third-party\models.dev\LICENSE.txt', - 'third-party\models.dev\provenance.json' + 'third-party\models.dev\provenance.json', + 'resources\ext-host\extension-host.js' )) { if (-not (Test-Path -LiteralPath (Join-Path $primaryCandidates[0].DirectoryName $requiredFile) -PathType Leaf)) { throw "Packaged archive is missing $requiredFile" diff --git a/scripts/cli/test-install-unix.sh b/scripts/cli/test-install-unix.sh index 51cc1b2e9..ae0c6a330 100644 --- a/scripts/cli/test-install-unix.sh +++ b/scripts/cli/test-install-unix.sh @@ -33,6 +33,7 @@ bash "${REPO_ROOT}/src/apps/cli/install.sh" bash "${REPO_ROOT}/src/apps/cli/install.sh" "${BITFUN_CLI_BIN_DIR}/bitfun" --version >/dev/null +[ -f "${BITFUN_CLI_BIN_DIR}/resources/ext-host/extension-host.js" ] LEGACY_STDERR="${TEST_ROOT}/legacy.err" "${BITFUN_CLI_BIN_DIR}/bitfun-cli" --version >/dev/null 2>"$LEGACY_STDERR" grep -Fxq 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' "$LEGACY_STDERR" diff --git a/scripts/cli/test-install-windows.ps1 b/scripts/cli/test-install-windows.ps1 index 1a7957aab..e0d5cdc7e 100644 --- a/scripts/cli/test-install-windows.ps1 +++ b/scripts/cli/test-install-windows.ps1 @@ -21,6 +21,11 @@ try { if ($LASTEXITCODE -ne 0) { throw 'Installed bitfun smoke check failed' } + foreach ($entry in @('extension-host.js')) { + if (-not (Test-Path -LiteralPath (Join-Path $binDir "resources\ext-host\$entry") -PathType Leaf)) { + throw "Installed CLI is missing plugin Host resource: $entry" + } + } $primary = Join-Path $binDir 'bitfun.exe' $legacy = Join-Path $binDir 'bitfun-cli.exe' diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 47d3c8024..924f92712 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -39,6 +39,7 @@ export const crateLayoutRules = [ { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, + { crateName: 'opencode-plugin-host', layer: 'adapters', path: 'src/crates/adapters/opencode-plugin-host' }, { crateName: 'static-hook-support', layer: 'adapters', path: 'src/crates/adapters/static-hook-support' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index 15e0032a7..38ebbab99 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -63,6 +63,7 @@ export const noCoreDependencyCrates = [ 'claude-code-adapter', 'codex-adapter', 'opencode-adapter', + 'opencode-plugin-host', 'static-hook-support', 'external-sources', 'terminal', @@ -117,6 +118,19 @@ export const forbiddenManifestDependencyRules = [ message: 'only bitfun-core product-full assembly may register bitfun-opencode-adapter through reviewed capability composition roots', }, + { + dependencyNames: ['bitfun-opencode-plugin-host'], + scanRoots: ['src/apps', 'src/crates', 'BitFun-Installer/src-tauri'], + workspaceManifestPath: 'Cargo.toml', + allowManifestPaths: [ + 'src/crates/adapters/opencode-plugin-host/Cargo.toml', + 'src/crates/assembly/core/Cargo.toml', + ], + reason: + 'OpenCode plugin host process dependencies are limited to the reviewed product composition root', + message: + 'only bitfun-core product-full assembly may register bitfun-opencode-plugin-host', + }, ...[ ['bitfun-claude-code-adapter', 'claude-code-adapter'], ['bitfun-codex-adapter', 'codex-adapter'], diff --git a/scripts/frontend-build-all.mjs b/scripts/frontend-build-all.mjs index 4813cb1ef..5f2073b45 100644 --- a/scripts/frontend-build-all.mjs +++ b/scripts/frontend-build-all.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Runs the two independent frontend build pipelines in parallel: + * Runs the independent desktop resource build pipelines in parallel: * - build:web (type-check + vite build + monaco asset verify) * - prepare:mobile-web (mobile-web install/build with mtime short-circuit) * diff --git a/src/apps/cli/install.ps1 b/src/apps/cli/install.ps1 index 6f5ec3442..4003eb597 100644 --- a/src/apps/cli/install.ps1 +++ b/src/apps/cli/install.ps1 @@ -99,29 +99,47 @@ function Assert-EntrypointPair([string]$Primary, [string]$Legacy) { } } +function Assert-PluginHostResources([string]$Directory) { + foreach ($entry in @('extension-host.js')) { + $path = Join-Path $Directory $entry + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Plugin Host resource is missing: $path" + } + } +} + function Install-EntrypointPair( [string]$PrimarySource, [string]$LegacySource, + [string]$PluginHostSource, [string]$Destination ) { New-Item -ItemType Directory -Path $Destination -Force | Out-Null $stageDir = Join-Path $Destination ".bitfun-install-$([guid]::NewGuid().ToString('N'))" $stagedPrimary = Join-Path $stageDir 'bitfun.exe' $stagedLegacy = Join-Path $stageDir 'bitfun-cli.exe' + $stagedPluginHost = Join-Path $stageDir 'ext-host' $primaryTarget = Join-Path $Destination 'bitfun.exe' $legacyTarget = Join-Path $Destination 'bitfun-cli.exe' + $pluginHostTarget = Join-Path $Destination 'resources\ext-host' $primaryBackup = Join-Path $stageDir 'previous-bitfun.exe' $legacyBackup = Join-Path $stageDir 'previous-bitfun-cli.exe' + $pluginHostBackup = Join-Path $stageDir 'previous-ext-host' $primaryBackedUp = $false $legacyBackedUp = $false + $pluginHostBackedUp = $false $primaryCommitted = $false $legacyCommitted = $false + $pluginHostCommitted = $false New-Item -ItemType Directory -Path $stageDir | Out-Null try { Copy-Item -LiteralPath $PrimarySource -Destination $stagedPrimary Copy-Item -LiteralPath $LegacySource -Destination $stagedLegacy + New-Item -ItemType Directory -Path $stagedPluginHost | Out-Null + Copy-Item -LiteralPath (Join-Path $PluginHostSource 'extension-host.js') -Destination $stagedPluginHost Assert-EntrypointPair $stagedPrimary $stagedLegacy + Assert-PluginHostResources $stagedPluginHost if (Test-Path -LiteralPath $primaryTarget -PathType Leaf) { Move-Item -LiteralPath $primaryTarget -Destination $primaryBackup @@ -131,15 +149,26 @@ function Install-EntrypointPair( Move-Item -LiteralPath $legacyTarget -Destination $legacyBackup $legacyBackedUp = $true } + if (Test-Path -LiteralPath $pluginHostTarget -PathType Container) { + Move-Item -LiteralPath $pluginHostTarget -Destination $pluginHostBackup + $pluginHostBackedUp = $true + } Move-Item -LiteralPath $stagedPrimary -Destination $primaryTarget $primaryCommitted = $true Move-Item -LiteralPath $stagedLegacy -Destination $legacyTarget $legacyCommitted = $true + New-Item -ItemType Directory -Path (Split-Path -Parent $pluginHostTarget) -Force | Out-Null + Move-Item -LiteralPath $stagedPluginHost -Destination $pluginHostTarget + $pluginHostCommitted = $true Assert-EntrypointPair $primaryTarget $legacyTarget + Assert-PluginHostResources $pluginHostTarget } catch { $installError = $_ + if ($pluginHostCommitted) { + Remove-Item -LiteralPath $pluginHostTarget -Recurse -Force -ErrorAction SilentlyContinue + } if ($legacyCommitted) { Remove-Item -LiteralPath $legacyTarget -Force -ErrorAction SilentlyContinue } @@ -152,6 +181,10 @@ function Install-EntrypointPair( if ($primaryBackedUp) { Move-Item -LiteralPath $primaryBackup -Destination $primaryTarget -Force } + if ($pluginHostBackedUp) { + New-Item -ItemType Directory -Path (Split-Path -Parent $pluginHostTarget) -Force | Out-Null + Move-Item -LiteralPath $pluginHostBackup -Destination $pluginHostTarget -Force + } throw "CLI installation failed; the previous entrypoint pair was restored. $installError" } finally { @@ -163,6 +196,7 @@ $repoRoot = Resolve-RepoRoot $releaseDir = Resolve-ReleaseDir $repoRoot $primarySource = Join-Path $releaseDir 'bitfun.exe' $legacySource = Join-Path $releaseDir 'bitfun-cli.exe' +$pluginHostSource = Join-Path $repoRoot 'src\apps\extension-host\dist' $primaryInstalled = Join-Path $BinDir 'bitfun.exe' $legacyInstalled = Join-Path $BinDir 'bitfun-cli.exe' $deprecation = 'Warning: `bitfun-cli` is deprecated; use `bitfun` instead.' @@ -193,11 +227,13 @@ foreach ($source in @($primarySource, $legacySource)) { throw "Built executable was not found at $source" } } +Assert-PluginHostResources $pluginHostSource Write-Host '[2/3] Installing executables...' -Install-EntrypointPair $primarySource $legacySource $BinDir +Install-EntrypointPair $primarySource $legacySource $pluginHostSource $BinDir Write-Host "Installed: $primaryInstalled" Write-Host "Installed deprecated compatibility entrypoint: $legacyInstalled" +Write-Host "Installed plugin Host resources: $(Join-Path $BinDir 'resources\ext-host')" if (-not $SkipPathUpdate) { Add-BinDirToUserPath $BinDir @@ -208,6 +244,7 @@ else { Write-Host '[3/3] Verifying both entrypoints...' Assert-EntrypointPair $primaryInstalled $legacyInstalled +Assert-PluginHostResources (Join-Path $BinDir 'resources\ext-host') Write-Host '=== Install complete ===' Write-Host 'Open a new terminal, then run: bitfun' diff --git a/src/apps/cli/install.sh b/src/apps/cli/install.sh index e92aa5057..58ee9d04e 100755 --- a/src/apps/cli/install.sh +++ b/src/apps/cli/install.sh @@ -160,52 +160,91 @@ assert_entrypoint_pair() { fi } +assert_plugin_host_resources() { + local directory="$1" + local entry + for entry in extension-host.js; do + if [ ! -f "${directory}/${entry}" ]; then + echo "Error: plugin Host resource is missing: ${directory}/${entry}" >&2 + return 1 + fi + done +} + install_entrypoint_pair() { local primary_source="$1" local legacy_source="$2" - local destination="$3" - local stage_dir staged_primary staged_legacy primary_target legacy_target - local primary_backup legacy_backup - local primary_backed_up=0 legacy_backed_up=0 primary_committed=0 legacy_committed=0 + local plugin_host_source="$3" + local destination="$4" + local stage_dir staged_primary staged_legacy staged_plugin_host + local primary_target legacy_target plugin_host_target + local primary_backup legacy_backup plugin_host_backup + local primary_backed_up=0 legacy_backed_up=0 plugin_host_backed_up=0 + local primary_committed=0 legacy_committed=0 plugin_host_committed=0 local failed=0 mkdir -p "$destination" stage_dir="$(mktemp -d "${destination}/.bitfun-install.XXXXXX")" staged_primary="${stage_dir}/bitfun" staged_legacy="${stage_dir}/bitfun-cli" + staged_plugin_host="${stage_dir}/ext-host" primary_target="${destination}/bitfun" legacy_target="${destination}/bitfun-cli" + plugin_host_target="${destination}/resources/ext-host" primary_backup="${stage_dir}/previous-bitfun" legacy_backup="${stage_dir}/previous-bitfun-cli" + plugin_host_backup="${stage_dir}/previous-ext-host" install -m 755 "$primary_source" "$staged_primary" || failed=1 if [ "$failed" -eq 0 ]; then install -m 755 "$legacy_source" "$staged_legacy" || failed=1 fi + if [ "$failed" -eq 0 ]; then + mkdir -p "$staged_plugin_host" + cp "$plugin_host_source/extension-host.js" "$staged_plugin_host/" || failed=1 + fi if [ "$failed" -eq 0 ]; then assert_entrypoint_pair "$staged_primary" "$staged_legacy" || failed=1 fi + if [ "$failed" -eq 0 ]; then + assert_plugin_host_resources "$staged_plugin_host" || failed=1 + fi if [ "$failed" -eq 0 ] && { [ -e "$primary_target" ] || [ -L "$primary_target" ]; }; then if mv "$primary_target" "$primary_backup"; then primary_backed_up=1; else failed=1; fi fi if [ "$failed" -eq 0 ] && { [ -e "$legacy_target" ] || [ -L "$legacy_target" ]; }; then if mv "$legacy_target" "$legacy_backup"; then legacy_backed_up=1; else failed=1; fi fi + if [ "$failed" -eq 0 ] && [ -d "$plugin_host_target" ]; then + if mv "$plugin_host_target" "$plugin_host_backup"; then plugin_host_backed_up=1; else failed=1; fi + fi if [ "$failed" -eq 0 ]; then if mv "$staged_primary" "$primary_target"; then primary_committed=1; else failed=1; fi fi if [ "$failed" -eq 0 ]; then if mv "$staged_legacy" "$legacy_target"; then legacy_committed=1; else failed=1; fi fi + if [ "$failed" -eq 0 ]; then + mkdir -p "$(dirname "$plugin_host_target")" + if mv "$staged_plugin_host" "$plugin_host_target"; then plugin_host_committed=1; else failed=1; fi + fi if [ "$failed" -eq 0 ]; then assert_entrypoint_pair "$primary_target" "$legacy_target" || failed=1 fi + if [ "$failed" -eq 0 ]; then + assert_plugin_host_resources "$plugin_host_target" || failed=1 + fi if [ "$failed" -ne 0 ]; then + if [ "$plugin_host_committed" -eq 1 ]; then rm -rf "$plugin_host_target"; fi if [ "$legacy_committed" -eq 1 ]; then rm -f "$legacy_target"; fi if [ "$primary_committed" -eq 1 ]; then rm -f "$primary_target"; fi if [ "$legacy_backed_up" -eq 1 ]; then mv "$legacy_backup" "$legacy_target"; fi if [ "$primary_backed_up" -eq 1 ]; then mv "$primary_backup" "$primary_target"; fi + if [ "$plugin_host_backed_up" -eq 1 ]; then + mkdir -p "$(dirname "$plugin_host_target")" + mv "$plugin_host_backup" "$plugin_host_target" + fi rm -rf "$stage_dir" echo "Error: CLI installation failed; the previous entrypoint pair was restored." >&2 return 1 @@ -324,19 +363,22 @@ else fi BUILT_BIN="${RELEASE_DIR}/bitfun" BUILT_LEGACY_BIN="${RELEASE_DIR}/bitfun-cli" +PLUGIN_HOST_DIST="${REPO_ROOT}/src/apps/extension-host/dist" for binary in "$BUILT_BIN" "$BUILT_LEGACY_BIN"; do if [ ! -x "$binary" ]; then echo "Error: built binary not found at $binary" exit 1 fi done +assert_plugin_host_resources "$PLUGIN_HOST_DIST" echo "" echo "[2/4] Installing binaries..." -install_entrypoint_pair "$BUILT_BIN" "$BUILT_LEGACY_BIN" "$BIN_DIR" +install_entrypoint_pair "$BUILT_BIN" "$BUILT_LEGACY_BIN" "$PLUGIN_HOST_DIST" "$BIN_DIR" echo "Installed: ${BIN_DIR}/bitfun" echo "Installed deprecated compatibility entrypoint: ${BIN_DIR}/bitfun-cli" assert_entrypoint_pair "${BIN_DIR}/bitfun" "${BIN_DIR}/bitfun-cli" +assert_plugin_host_resources "${BIN_DIR}/resources/ext-host" echo "" echo "[3/4] Configuring shell PATH..." diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 07ffd5d56..1cab46d5b 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -761,6 +761,8 @@ impl ExecAgentRuntimeClient { self.resolve_session_workspace_binding(session_id, &project_workspace) .await? }; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; let mut session_id_guard = self.session_id.lock().await; let mut turn_id_guard = self.current_turn_id.lock().await; *session_id_guard = Some(session_id.to_string()); @@ -821,6 +823,26 @@ impl ExecAgentRuntimeClient { .await } + async fn ensure_embedded_plugin_workspace_ready( + &self, + binding: &AgentSessionWorkspaceBinding, + ) -> Result<()> { + if matches!(&self.backend, CliAgentRuntimeBackend::Shared(_)) { + return Ok(()); + } + crate::plugin_host_activation::ensure_plugin_workspace_ready(binding) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + + async fn ensure_embedded_plugin_session_ready(&self, session_id: &str) -> Result<()> { + if matches!(&self.backend, CliAgentRuntimeBackend::Shared(_)) { + return Ok(()); + } + let binding = self.session_workspace_binding(session_id).await?; + self.ensure_embedded_plugin_workspace_ready(&binding).await + } + pub(crate) async fn delete_session( &self, session_id: &str, @@ -1026,6 +1048,8 @@ impl ExecAgentRuntimeClient { self.resolve_session_workspace_binding(&session.session_id, Path::new(&workspace_path)) .await? }; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; *self.session_id.lock().await = Some(session.session_id.clone()); *self.current_turn_id.lock().await = None; self.shared_pending_permissions @@ -1213,7 +1237,10 @@ impl ExecAgentRuntimeClient { .await { Ok(_) => { - self.resolve_session_workspace_binding(session_id, &project_workspace) + let binding = self + .resolve_session_workspace_binding(session_id, &project_workspace) + .await?; + self.ensure_embedded_plugin_workspace_ready(&binding) .await?; tracing::info!("Backend session restored: {}", session_id); Ok(()) @@ -1225,7 +1252,9 @@ impl ExecAgentRuntimeClient { "Session is unavailable, recreating backend session: {}", session_id ); - self.recreate_session_with_id(session_id, agent_type).await + self.recreate_session_with_id(session_id, agent_type) + .await?; + self.ensure_embedded_plugin_session_ready(session_id).await } else { Err(with_session_conflict_help(anyhow::Error::new(error))) } @@ -1264,6 +1293,7 @@ impl ExecAgentRuntimeClient { .map_err(with_session_conflict_help)?; let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *session_id_guard = Some(id.clone()); tracing::info!("Created runtime session with fixed id: {}", id); @@ -1317,6 +1347,7 @@ impl ExecAgentRuntimeClient { let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *session_id_guard = Some(id.clone()); drop(session_id_guard); self.refresh_shared_pending_permissions().await?; @@ -1439,6 +1470,8 @@ impl ExecAgentRuntimeClient { agent_type: &str, ) -> Result { tracing::info!("Sending message to session {}: {}", session_id, message); + self.ensure_embedded_plugin_session_ready(&session_id) + .await?; // Generate a turn_id let turn_id = uuid::Uuid::new_v4().to_string(); @@ -1576,6 +1609,8 @@ impl ExecAgentRuntimeClient { agent_type: &str, ) -> Result { let session_id = self.ensure_session(agent_type).await?; + self.ensure_embedded_plugin_session_ready(&session_id) + .await?; let turn_id = uuid::Uuid::new_v4().to_string(); let request = AgentUserShellCommandRequest { session_id: session_id.clone(), @@ -1758,6 +1793,7 @@ impl ExecAgentRuntimeClient { let id = session.session_id.clone(); + self.ensure_embedded_plugin_session_ready(&id).await?; *self.session_id.lock().await = Some(id.clone()); *self.current_turn_id.lock().await = None; self.shared_pending_permissions diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index bd5fc646b..552349209 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -844,7 +844,8 @@ impl TuiAgentClient { project_workspace_path: Option, ) -> Result { let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); - self.backend + let response = self + .backend .worktree_bind_session(WorktreeBindSessionRequest { operation_id: worktree_operation_id(), session_id, @@ -853,7 +854,10 @@ impl TuiAgentClient { remote_ssh_host, }) .await - .map_err(Into::into) + .map_err(anyhow::Error::from)?; + self.ensure_embedded_plugin_workspace_ready(&response.workspace_binding) + .await?; + Ok(response) } pub(crate) async fn worktree_release_session( @@ -862,7 +866,8 @@ impl TuiAgentClient { project_workspace_path: Option, ) -> Result { let (remote_connection_id, remote_ssh_host) = self.remote_workspace_scope(); - self.backend + let response = self + .backend .worktree_release_session(WorktreeReleaseSessionRequest { operation_id: worktree_operation_id(), session_id, @@ -871,7 +876,10 @@ impl TuiAgentClient { remote_ssh_host, }) .await - .map_err(Into::into) + .map_err(anyhow::Error::from)?; + self.ensure_embedded_plugin_workspace_ready(&response.workspace_binding) + .await?; + Ok(response) } fn remote_workspace_scope(&self) -> (Option, Option) { @@ -985,6 +993,8 @@ impl TuiAgentClient { remote_ssh_host: None, }) .await?; + self.ensure_embedded_plugin_workspace_ready(&response.workspace_binding) + .await?; self.set_workspace_binding(&response.workspace_binding); *self.session_id.lock().await = Some(session_id.to_string()); *self.current_turn_id.lock().await = match &response.state { @@ -1029,6 +1039,18 @@ impl TuiAgentClient { }) } + async fn ensure_embedded_plugin_workspace_ready( + &self, + binding: &AgentSessionWorkspaceBinding, + ) -> Result<()> { + if self.shared { + return Ok(()); + } + crate::plugin_host_activation::ensure_plugin_workspace_ready(binding) + .await + .map_err(|error| anyhow::anyhow!(error.to_string())) + } + pub(crate) async fn delete_session( &self, session_id: &str, @@ -1275,6 +1297,8 @@ impl TuiAgentClient { remote_connection_id: None, remote_ssh_host: None, }; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; self.set_workspace_binding(&binding); *self.session_id.lock().await = Some(id.clone()); *self.current_turn_id.lock().await = None; @@ -1361,6 +1385,9 @@ impl TuiAgentClient { agent_type: &str, ) -> Result { let session_id = self.ensure_session(agent_type).await?; + let binding = self.session_workspace_binding(&session_id).await?; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; let turn_id = uuid::Uuid::new_v4().to_string(); *self.current_turn_id.lock().await = Some(turn_id.clone()); let mut metadata = approval_metadata(self.approval_policy()); @@ -1442,6 +1469,9 @@ impl TuiAgentClient { agent_type: &str, ) -> Result { let session_id = self.ensure_session(agent_type).await?; + let binding = self.session_workspace_binding(&session_id).await?; + self.ensure_embedded_plugin_workspace_ready(&binding) + .await?; let turn_id = uuid::Uuid::new_v4().to_string(); *self.current_turn_id.lock().await = Some(turn_id.clone()); let response = self diff --git a/src/apps/cli/src/logging.rs b/src/apps/cli/src/logging.rs index 5d7a24f07..c8a978b9c 100644 --- a/src/apps/cli/src/logging.rs +++ b/src/apps/cli/src/logging.rs @@ -3,7 +3,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use chrono::Local; use tracing_subscriber::filter::filter_fn; @@ -28,8 +28,11 @@ pub(crate) struct CliLogPaths { pub app_log_path: PathBuf, pub ai_log_path: PathBuf, pub flashgrep_log_path: PathBuf, + pub plugin_host_log_path: PathBuf, } +static ACTIVE_LOG_PATHS: OnceLock = OnceLock::new(); + struct RotatingFile { dir: PathBuf, file_name: String, @@ -238,9 +241,16 @@ pub(crate) fn build_log_paths(session_log_dir: &Path) -> CliLogPaths { app_log_path: session_log_dir.join("app.log"), ai_log_path: session_log_dir.join("ai.log"), flashgrep_log_path: session_log_dir.join("flashgrep.log"), + plugin_host_log_path: session_log_dir.join("plugin-host.log"), } } +pub(crate) fn active_plugin_host_log_path() -> Option { + ACTIVE_LOG_PATHS + .get() + .map(|paths| paths.plugin_host_log_path.clone()) +} + fn create_rotating_writer( session_log_dir: &Path, file_name: &str, @@ -353,6 +363,7 @@ pub(crate) fn init_file_logging_at( ) -> CliLogPaths { fs::create_dir_all(session_log_dir).ok(); let paths = build_log_paths(session_log_dir); + let _ = ACTIVE_LOG_PATHS.set(paths.clone()); let app_writer = create_rotating_writer(session_log_dir, "app"); let ai_writer = create_rotating_writer(session_log_dir, "ai"); @@ -410,6 +421,10 @@ mod tests { assert_eq!(paths.app_log_path, temp.path().join("app.log")); assert_eq!(paths.ai_log_path, temp.path().join("ai.log")); assert_eq!(paths.flashgrep_log_path, temp.path().join("flashgrep.log")); + assert_eq!( + paths.plugin_host_log_path, + temp.path().join("plugin-host.log") + ); } #[test] diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 2cfc3d4f3..6d66a96ef 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -27,6 +27,7 @@ mod model_selection; mod modes; mod peer_host; mod plugin_diagnostics; +mod plugin_host_activation; mod product_assembly; mod prompt_stash; mod prompts; @@ -52,6 +53,9 @@ use mcp_import::{McpImportCommand, McpImportOutputFormat}; use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; +pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled; + // ======================== Global MCP Service ======================== static MCP_SERVICE: OnceLock> = @@ -554,6 +558,10 @@ impl BootstrapProfile { const fn starts_mcp(self) -> bool { matches!(self, Self::Interactive | Self::Execution) } + + const fn starts_plugin_host(self) -> bool { + matches!(self, Self::Interactive | Self::Execution) + } } impl SessionAction { @@ -807,6 +815,18 @@ async fn initialize_core_services_for_deployment( .await .map_err(|error| anyhow!("Failed to initialize global config service: {error}"))?; tracing::info!("Global config service initialized"); + if bootstrap_profile.starts_plugin_host() { + match bitfun_core::plugin_host::initialize_configured_plugin_host_with_log_file( + PLUGIN_HOST_LAUNCH_POLICY, + logging::active_plugin_host_log_path(), + ) + .await + { + Ok(bitfun_core::plugin_host::PluginHostStartup::Disabled) => {} + Ok(status) => tracing::info!("Plugin host initialization completed: {:?}", status), + Err(error) => tracing::error!("Failed to initialize configured plugin host: {error}"), + } + } let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() .map_err(|error| anyhow!(error.to_string()))?; let entrypoint = match (deployment, bootstrap_profile) { @@ -1609,7 +1629,24 @@ fn main() { .enable_all() .build() .expect("failed to build tokio runtime"); - runtime.block_on(run_cli()) + runtime.block_on(async { + let result = run_cli().await; + match bitfun_core::plugin_host::shutdown_configured_plugin_host().await { + Ok(Some(report)) => tracing::info!( + generation = report.generation, + disposition = ?report.disposition, + rpc_completed = report.rpc_completed, + exit_code = ?report.exit_code, + duration_ms = report.duration_ms, + "CLI plugin host shutdown completed" + ), + Ok(None) => { + tracing::debug!("CLI plugin host shutdown skipped: host not started") + } + Err(error) => tracing::warn!("CLI plugin host shutdown failed: {error}"), + } + result + }) }) .expect("failed to spawn bitfun worker thread"); @@ -1800,12 +1837,12 @@ mod bootstrap_profile_tests { #[test] fn profiles_start_only_their_requested_background_services() { let cases = [ - (BootstrapProfile::Interactive, true, true), - (BootstrapProfile::Execution, false, true), - (BootstrapProfile::Management, false, false), + (BootstrapProfile::Interactive, true, true, true), + (BootstrapProfile::Execution, false, true, true), + (BootstrapProfile::Management, false, false, false), ]; - for (profile, starts_peer_host, starts_mcp) in cases { + for (profile, starts_peer_host, starts_mcp, starts_plugin_host) in cases { assert_eq!( profile.starts_peer_host( bitfun_services_core::runtime_ownership::RuntimeDeployment::Embedded, @@ -1813,6 +1850,7 @@ mod bootstrap_profile_tests { starts_peer_host ); assert_eq!(profile.starts_mcp(), starts_mcp); + assert_eq!(profile.starts_plugin_host(), starts_plugin_host); } } diff --git a/src/apps/cli/src/plugin_host_activation.rs b/src/apps/cli/src/plugin_host_activation.rs new file mode 100644 index 000000000..2fe31d71d --- /dev/null +++ b/src/apps/cli/src/plugin_host_activation.rs @@ -0,0 +1,85 @@ +use std::path::PathBuf; + +use bitfun_runtime_ports::AgentSessionWorkspaceBinding; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PluginWorkspaceActivationTarget { + directory: PathBuf, + worktree: PathBuf, + project_id: Option, +} + +fn activation_target( + binding: &AgentSessionWorkspaceBinding, +) -> Option { + if binding.remote_connection_id.is_some() || binding.remote_ssh_host.is_some() { + return None; + } + + let workspace = PathBuf::from(&binding.workspace_path); + Some(PluginWorkspaceActivationTarget { + directory: workspace.clone(), + worktree: workspace, + project_id: binding.workspace_id.clone(), + }) +} + +pub(crate) async fn ensure_plugin_workspace_ready( + binding: &AgentSessionWorkspaceBinding, +) -> bitfun_core::BitFunResult<()> { + let Some(target) = activation_target(binding) else { + tracing::debug!( + workspace_path = %binding.workspace_path, + "Configured plugin host activation skipped for remote CLI workspace" + ); + return Ok(()); + }; + + bitfun_core::plugin_host::ensure_configured_plugin_instance( + crate::PLUGIN_HOST_LAUNCH_POLICY, + target.directory, + target.worktree, + target.project_id, + serde_json::Map::new(), + ) + .await + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::{activation_target, PluginWorkspaceActivationTarget}; + use bitfun_runtime_ports::{AgentSessionWorkspaceBinding, SessionExecutionTarget}; + use std::path::PathBuf; + + fn binding() -> AgentSessionWorkspaceBinding { + AgentSessionWorkspaceBinding { + workspace_id: Some("workspace-1".to_string()), + workspace_path: "C:/workspace/project".to_string(), + project_workspace_path: Some("C:/workspace/project".to_string()), + execution_target: Some(SessionExecutionTarget::local("C:/workspace/project")), + remote_connection_id: None, + remote_ssh_host: None, + } + } + + #[test] + fn local_binding_maps_to_plugin_workspace_target() { + assert_eq!( + activation_target(&binding()), + Some(PluginWorkspaceActivationTarget { + directory: PathBuf::from("C:/workspace/project"), + worktree: PathBuf::from("C:/workspace/project"), + project_id: Some("workspace-1".to_string()), + }) + ); + } + + #[test] + fn remote_binding_skips_local_plugin_host() { + let mut binding = binding(); + binding.remote_connection_id = Some("remote-1".to_string()); + + assert_eq!(activation_target(&binding), None); + } +} diff --git a/src/apps/cli/src/self_update.rs b/src/apps/cli/src/self_update.rs index 2224dc831..0f15c5398 100644 --- a/src/apps/cli/src/self_update.rs +++ b/src/apps/cli/src/self_update.rs @@ -950,6 +950,7 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { )); } let legacy_target = install_dir.join("bitfun-cli"); + let plugin_host_target = install_dir.join("resources").join("ext-host"); if !legacy_target.is_file() { return Err(anyhow!( "official bitfun-cli companion was not found beside {}", @@ -964,7 +965,9 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { let package_dir = find_package_dir(extract_dir.path())?; let new_primary = package_dir.join("bitfun"); let new_legacy = package_dir.join("bitfun-cli"); + let new_plugin_host = package_dir.join("resources").join("ext-host"); validate_entrypoint_pair(&new_primary, &new_legacy)?; + validate_plugin_host_resources(&new_plugin_host)?; let stage = tempfile::Builder::new() .prefix(".bitfun-update.") @@ -977,14 +980,19 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { })?; let staged_primary = stage.path().join("bitfun"); let staged_legacy = stage.path().join("bitfun-cli"); + let staged_plugin_host = stage.path().join("ext-host"); fs::copy(&new_primary, &staged_primary).context("stage bitfun")?; fs::copy(&new_legacy, &staged_legacy).context("stage bitfun-cli")?; + copy_plugin_host_resources(&new_plugin_host, &staged_plugin_host)?; fs::set_permissions(&staged_primary, fs::Permissions::from_mode(0o755))?; fs::set_permissions(&staged_legacy, fs::Permissions::from_mode(0o755))?; validate_entrypoint_pair(&staged_primary, &staged_legacy)?; + validate_plugin_host_resources(&staged_plugin_host)?; let primary_backup = stage.path().join("previous-bitfun"); let legacy_backup = stage.path().join("previous-bitfun-cli"); + let plugin_host_backup = stage.path().join("previous-ext-host"); + let plugin_host_existed = plugin_host_target.is_dir(); // Rollback runs while something has already gone wrong, so its own failures // are the ones that matter most: they are the difference between "the update @@ -1016,7 +1024,25 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { rollback_failures, )); } + if plugin_host_existed { + if let Err(error) = fs::rename(&plugin_host_target, &plugin_host_backup) { + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "back up current plugin Host resources", + rollback_failures, + )); + } + } if let Err(error) = fs::rename(&staged_primary, current_exe) { + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); return Err(rollback_error( @@ -1029,6 +1055,13 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { if let Err(remove_error) = fs::remove_file(current_exe) { rollback_failures.push(format!("remove {}: {remove_error}", current_exe.display())); } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); return Err(rollback_error( @@ -1037,12 +1070,73 @@ fn install_archive(archive: &[u8], current_exe: &Path) -> Result<()> { rollback_failures, )); } - if let Err(error) = validate_entrypoint_pair(current_exe, &legacy_target) { + if let Err(error) = fs::create_dir_all( + plugin_host_target + .parent() + .expect("plugin Host resource directory has a parent"), + ) { + for path in [current_exe, legacy_target.as_path()] { + if let Err(remove_error) = fs::remove_file(path) { + rollback_failures.push(format!("remove {}: {remove_error}", path.display())); + } + } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "create plugin Host resource directory", + rollback_failures, + )); + } + if let Err(error) = fs::rename(&staged_plugin_host, &plugin_host_target) { for path in [current_exe, legacy_target.as_path()] { if let Err(remove_error) = fs::remove_file(path) { rollback_failures.push(format!("remove {}: {remove_error}", path.display())); } } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } + restore(&legacy_backup, &legacy_target, &mut rollback_failures); + restore(&primary_backup, current_exe, &mut rollback_failures); + return Err(rollback_error( + error, + "install updated plugin Host resources", + rollback_failures, + )); + } + let validation = validate_entrypoint_pair(current_exe, &legacy_target) + .and_then(|_| validate_plugin_host_resources(&plugin_host_target)); + if let Err(error) = validation { + for path in [current_exe, legacy_target.as_path()] { + if let Err(remove_error) = fs::remove_file(path) { + rollback_failures.push(format!("remove {}: {remove_error}", path.display())); + } + } + if let Err(remove_error) = fs::remove_dir_all(&plugin_host_target) { + rollback_failures.push(format!( + "remove {}: {remove_error}", + plugin_host_target.display() + )); + } + if plugin_host_existed { + restore( + &plugin_host_backup, + &plugin_host_target, + &mut rollback_failures, + ); + } restore(&legacy_backup, &legacy_target, &mut rollback_failures); restore(&primary_backup, current_exe, &mut rollback_failures); let failed = error.context("validate installed CLI update"); @@ -1097,6 +1191,33 @@ fn validate_entrypoint_pair(primary: &Path, legacy: &Path) -> Result<()> { Ok(()) } +fn validate_plugin_host_resources(directory: &Path) -> Result<()> { + for entry in ["extension-host.js"] { + let path = directory.join(entry); + if !path.is_file() { + return Err(anyhow!( + "CLI package is missing plugin Host resource {}", + path.display() + )); + } + } + Ok(()) +} + +fn copy_plugin_host_resources(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| { + format!( + "create plugin Host staging directory {}", + destination.display() + ) + })?; + for entry in ["extension-host.js"] { + fs::copy(source.join(entry), destination.join(entry)) + .with_context(|| format!("stage plugin Host resource {entry}"))?; + } + Ok(()) +} + fn current_platform_key() -> Option<&'static str> { if !cfg!(target_os = "linux") { return None; diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 3c2cad6a3..9a2d542b3 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -266,12 +266,17 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .await .map(|sessions| RuntimeIpcOperationResult::Sessions { sessions }) .map_err(runtime_ipc_error), - RuntimeIpcOperation::CreateSession { request } => self - .runtime - .create_session(request) - .await - .map(|session| RuntimeIpcOperationResult::SessionCreated { session }) - .map_err(runtime_ipc_error), + RuntimeIpcOperation::CreateSession { request } => { + let session = self + .runtime + .create_session(request) + .await + .map_err(runtime_ipc_error)?; + let workspace_binding = self.session_workspace_binding(&session.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; + Ok(RuntimeIpcOperationResult::SessionCreated { session }) + } RuntimeIpcOperation::RestoreSession { request } => { let restored = self .runtime @@ -308,6 +313,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let workspace_binding = self .session_workspace_binding(&restored.session.session_id) .await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; Ok(RuntimeIpcOperationResult::SessionRestored { session: restored.session, state: runtime_session_state(restored.state), @@ -364,6 +371,8 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { let workspace_binding = self .session_workspace_binding(&restored.session.session_id) .await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; Ok(RuntimeIpcOperationResult::SessionForked { session: restored.session, workspace_binding, @@ -472,6 +481,9 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .map(|snapshot| RuntimeIpcOperationResult::WorkspaceDiff { snapshot }) .map_err(runtime_ipc_error), RuntimeIpcOperation::SubmitTurn { request } => { + let workspace_binding = self.session_workspace_binding(&request.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; let outcome = self .runtime .submit_dialog_turn(request) @@ -508,15 +520,19 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { }, }) .map_err(runtime_ipc_error), - RuntimeIpcOperation::RunUserShellCommand { request } => self - .runtime - .run_user_shell_command(request) - .await - .map(|result| RuntimeIpcOperationResult::TurnAccepted { - session_id: result.session_id, - turn_id: result.turn_id, - }) - .map_err(runtime_ipc_error), + RuntimeIpcOperation::RunUserShellCommand { request } => { + let workspace_binding = self.session_workspace_binding(&request.session_id).await?; + self.ensure_plugin_workspace_ready(&workspace_binding) + .await?; + self.runtime + .run_user_shell_command(request) + .await + .map(|result| RuntimeIpcOperationResult::TurnAccepted { + session_id: result.session_id, + turn_id: result.turn_id, + }) + .map_err(runtime_ipc_error) + } RuntimeIpcOperation::CancelTurn { request } => self .runtime .cancel_turn(request) @@ -751,6 +767,15 @@ async fn await_permission_route( } impl SharedRuntimeHandler { + async fn ensure_plugin_workspace_ready( + &self, + binding: &AgentSessionWorkspaceBinding, + ) -> std::result::Result<(), RuntimeIpcError> { + crate::plugin_host_activation::ensure_plugin_workspace_ready(binding) + .await + .map_err(core_ipc_error) + } + async fn session_workspace_binding( &self, session_id: &str, diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 1c1f59b32..93313b629 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1526,6 +1526,24 @@ pub async fn create_session( None }; + if let Err(error) = runtime + .session_application() + .ensure_configured_plugin_instance( + desktop_session_scope( + request.workspace_path.clone(), + remote_conn.clone(), + remote_ssh_host.clone(), + ), + request.workspace_id.clone(), + ) + .await + { + warn!( + "Configured workspace plugin activation failed before session creation: {}", + error + ); + } + if is_idempotent_managed_create { let session_id = request .session_id diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 2d72ed00e..779bf3820 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -540,8 +540,7 @@ fn validate_remote_name_for_local_download(name: &str) -> Result<(), String> { fn local_download_name_key(name: &str) -> String { #[cfg(any(windows, target_os = "macos"))] { - name.trim_end_matches(['.', ' ']) - .to_lowercase() + name.trim_end_matches(['.', ' ']).to_lowercase() } #[cfg(not(any(windows, target_os = "macos")))] { diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 990e1c10f..061de7521 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -395,11 +395,11 @@ pub struct RestartAppRequest {} #[allow(unreachable_code)] pub async fn restart_app(app: AppHandle, request: RestartAppRequest) -> Result<(), String> { let _ = request; - crate::crash_diagnostics::mark_clean_shutdown("restart_app"); crate::save_main_window_state(&app); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("restart_app"); + log::info!("Desktop restart authorized after graceful shutdown"); app.restart(); - Ok(()) } #[derive(Debug, Serialize, Deserialize)] @@ -654,9 +654,10 @@ pub async fn set_main_window_transient_geometry( #[tauri::command] pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("Quit requested via quit_app command"); - crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); crate::save_main_window_state(&app); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); + log::info!("Desktop exit authorized after graceful shutdown: reason=quit_app_command"); app.exit(0); Ok(()) } @@ -726,9 +727,12 @@ pub async fn startup_window_control( if behavior == "quit" { log::info!("Quit requested from startup window control"); - crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); crate::save_main_window_state(&app); - crate::perform_process_exit_cleanup(); + crate::perform_process_exit_cleanup().await; + crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); + log::info!( + "Desktop exit authorized after graceful shutdown: reason=startup_window_control" + ); app.exit(0); } else { if let Err(error) = crate::tray::setup_tray(&app, &startup_trace) { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index fc30fc1d7..99b18f3d0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -45,7 +45,7 @@ use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; use std::sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, OnceLock, }; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; @@ -88,6 +88,9 @@ use api::system_api::*; use api::tool_api::*; use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; +pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = + bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + /// Agentic Coordinator state #[derive(Clone)] pub struct CoordinatorState { @@ -525,6 +528,22 @@ pub async fn run() { startup_timings.record_elapsed("initialize_global_config", step_started); startup_trace.record_elapsed_step("native_pre_tauri", "initialize_global_config", step_started); + let step_started = Instant::now(); + match bitfun_core::plugin_host::initialize_configured_plugin_host_with_log_file( + PLUGIN_HOST_LAUNCH_POLICY, + Some(session_log_dir.join("plugin-host.log")), + ) + .await + { + Ok(bitfun_core::plugin_host::PluginHostStartup::Disabled) => {} + Ok(status) => log::info!("Plugin host initialization completed: {:?}", status), + Err(error) => { + log::error!("Failed to initialize configured plugin host: {}", error); + } + } + startup_timings.record_elapsed("initialize_plugin_host", step_started); + startup_trace.record_elapsed_step("native_pre_tauri", "initialize_plugin_host", step_started); + // The three steps below only depend on the global config service (initialized // above) and write to disjoint global singletons, so they can run concurrently: // - initialize_global_i18n_service: reads config, sets the global i18n singleton @@ -1896,11 +1915,15 @@ pub async fn run() { match app { Ok(app) => { - app.run(|_app_handle, event| match event { - tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { - crash_diagnostics::mark_clean_shutdown("tauri_run_exit"); - save_main_window_state(_app_handle); - perform_process_exit_cleanup(); + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { api, code, .. } => { + if !PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + api.prevent_exit(); + request_desktop_exit(app_handle, code.unwrap_or(0), "tauri_exit_requested"); + } + } + tauri::RunEvent::Exit => { + perform_process_exit_cleanup_emergency(); } #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { @@ -2195,21 +2218,75 @@ fn setup_panic_hook() { return; } - perform_process_exit_cleanup(); + perform_process_exit_cleanup_emergency(); std::process::exit(1); })); } -pub(crate) fn perform_process_exit_cleanup() -> bool { - static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_STARTED: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_COMPLETE: AtomicBool = AtomicBool::new(false); +static DESKTOP_EXIT_REQUESTED: AtomicBool = AtomicBool::new(false); +static PROCESS_EXIT_CLEANUP_NOTIFY: OnceLock = OnceLock::new(); - if CLEANUP_DONE - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return false; +pub(crate) async fn perform_process_exit_cleanup() -> bool { + let notify = PROCESS_EXIT_CLEANUP_NOTIFY.get_or_init(tokio::sync::Notify::new); + if PROCESS_EXIT_CLEANUP_STARTED.swap(true, Ordering::AcqRel) { + loop { + let notified = notify.notified(); + if PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + return false; + } + notified.await; + } } + log::info!("Desktop process graceful shutdown started"); + match bitfun_core::plugin_host::shutdown_configured_plugin_host().await { + Ok(Some(report)) => log::info!( + "Desktop plugin host shutdown completed: generation={}, disposition={:?}, rpc_completed={}, exit_code={:?}, duration_ms={}", + report.generation, + report.disposition, + report.rpc_completed, + report.exit_code, + report.duration_ms + ), + Ok(None) => log::debug!("Desktop plugin host shutdown skipped: host not started"), + Err(error) => log::warn!("Desktop plugin host shutdown failed: {}", error), + } + if let Some(search_service) = get_global_workspace_search_service() { + search_service.shutdown_blocking(); + } + bitfun_core::util::process_manager::cleanup_all_processes(); + api::remote_connect_api::cleanup_on_exit(); + PROCESS_EXIT_CLEANUP_COMPLETE.store(true, Ordering::Release); + notify.notify_waiters(); + log::info!("Desktop process graceful shutdown completed"); + true +} + +pub(crate) fn request_desktop_exit(app: &tauri::AppHandle, exit_code: i32, reason: &'static str) { + if DESKTOP_EXIT_REQUESTED.swap(true, Ordering::AcqRel) { + return; + } + save_main_window_state(app); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + perform_process_exit_cleanup().await; + crash_diagnostics::mark_clean_shutdown(reason); + log::info!( + "Desktop exit authorized after graceful shutdown: reason={}, exit_code={}", + reason, + exit_code + ); + app.exit(exit_code); + }); +} + +pub(crate) fn perform_process_exit_cleanup_emergency() -> bool { + if PROCESS_EXIT_CLEANUP_COMPLETE.load(Ordering::Acquire) { + return false; + } + log::warn!("Desktop emergency process cleanup started"); if let Some(search_service) = get_global_workspace_search_service() { search_service.shutdown_blocking(); } @@ -2521,6 +2598,14 @@ fn spawn_runtime_log_level_listener(default_level: log::LevelFilter) { Ok(ConfigUpdateEvent::LogLevelUpdated { new_level }) => { if let Some(level) = logging::parse_log_level(&new_level) { logging::apply_runtime_log_level(level, "config_update_event"); + if let Err(error) = + bitfun_core::plugin_host::set_configured_plugin_host_log_level( + logging::level_to_str(level), + ) + .await + { + log::warn!("Failed to update plugin host log level: {}", error); + } } else { log::warn!( "Received invalid log level from config update event: {}", @@ -2531,6 +2616,14 @@ fn spawn_runtime_log_level_listener(default_level: log::LevelFilter) { Ok(ConfigUpdateEvent::ConfigReloaded) => { let level = resolve_runtime_log_level(default_level).await; logging::apply_runtime_log_level(level, "config_reloaded"); + if let Err(error) = + bitfun_core::plugin_host::set_configured_plugin_host_log_level( + logging::level_to_str(level), + ) + .await + { + log::warn!("Failed to update plugin host log level: {}", error); + } } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => { diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 671bbc3ad..24d6b6dc1 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -377,6 +377,32 @@ impl DesktopSessionApplication { self.ensure_runtime_ownership(&scope) } + pub(crate) async fn ensure_configured_plugin_instance( + &self, + request: DesktopSessionScopeRequest, + project_id: Option, + ) -> DesktopSessionApplicationResult> { + let scope = self.resolved_scope(request).await; + self.ensure_runtime_ownership(&scope)?; + if scope.remote_connection_id.is_some() { + log::debug!( + "Configured plugin host activation skipped for remote workspace: workspace_path={}", + scope.workspace_path + ); + return Ok(None); + } + let workspace_path = PathBuf::from(&scope.workspace_path); + bitfun_core::plugin_host::ensure_configured_plugin_instance( + crate::PLUGIN_HOST_LAUNCH_POLICY, + workspace_path.clone(), + workspace_path, + project_id, + serde_json::Map::new(), + ) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + pub(crate) async fn list_persisted_sessions( &self, request: DesktopSessionScopeRequest, diff --git a/src/apps/desktop/src/sleep_prevention.rs b/src/apps/desktop/src/sleep_prevention.rs index b5a08957f..64bc969b5 100644 --- a/src/apps/desktop/src/sleep_prevention.rs +++ b/src/apps/desktop/src/sleep_prevention.rs @@ -212,7 +212,10 @@ where error, rollback_error )); } - Err(format!("Failed to save prevent-sleep preference: {}", error)) + Err(format!( + "Failed to save prevent-sleep preference: {}", + error + )) } /// Applies the saved preference at startup and after config imports/reloads. @@ -412,7 +415,9 @@ mod tests { #[test] fn config_reload_re_reads_the_preference() { - assert!(config_event_requires_sync(&ConfigUpdateEvent::ConfigReloaded)); + assert!(config_event_requires_sync( + &ConfigUpdateEvent::ConfigReloaded + )); assert!(config_event_requires_sync(&ConfigUpdateEvent::AppUpdated)); assert!(!config_event_requires_sync( &ConfigUpdateEvent::ModelConfigurationUpdated diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 730b60820..b5ca83afc 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -219,10 +219,7 @@ pub fn setup_tray( show_main_window(app); } else if id == "quit" { log::info!("Quit requested from tray menu"); - crate::crash_diagnostics::mark_clean_shutdown("tray_quit"); - crate::save_main_window_state(app); - crate::perform_process_exit_cleanup(); - app.exit(0); + crate::request_desktop_exit(app, 0, "tray_quit"); } else if id == "toggle_desktop_pet" { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { diff --git a/src/apps/extension-host/.gitattributes b/src/apps/extension-host/.gitattributes new file mode 100644 index 000000000..dfe077042 --- /dev/null +++ b/src/apps/extension-host/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/src/apps/extension-host/.gitignore b/src/apps/extension-host/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/src/apps/extension-host/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/src/apps/extension-host/PROTOCOL.md b/src/apps/extension-host/PROTOCOL.md new file mode 100644 index 000000000..44f5b2e1c --- /dev/null +++ b/src/apps/extension-host/PROTOCOL.md @@ -0,0 +1,514 @@ +# Extension host protocol + +This document defines protocol version `1` for the standalone Bun extension host. The names and casing shown here are wire-level names: the Zod schemas in `src/protocol.ts`, generated `protocol.schema.json`, this document, and the future Rust implementation must stay identical. + +The compatibility target is the established Server plugin API published as `@opencode-ai/plugin@1.17.18`. OpenCode values below are JSON projections of public package types, not imports from OpenCode Core, Protocol, or Server. + +## Roles and connection + +- **Backend** is the Rust process. It owns the TCP listener, application state, HTTP behavior, auth persistence, lifecycle timing, supervision, and hard timeouts. +- **Host** is the Bun child process. It connects to Rust, loads plugins, retains JavaScript functions, and owns per-instance HTTP gateways. +- **Plugin** is trusted JavaScript or TypeScript loaded into the host. + +Rust binds a loopback TCP address before spawning the host and sets: + +- `OPENCODE_EXTENSION_HOST_RPC_ADDRESS`, conventionally `127.0.0.1:`. +- `OPENCODE_EXTENSION_HOST_RPC_TOKEN`, a fresh high-entropy secret for this child. + +The host makes one TCP connection and immediately calls `backend.handshake`. Rust must not issue a `host.*` call until that handshake succeeds. A failed handshake closes the connection and the host exits. + +### Framing and JSON-RPC + +Every message is a four-byte unsigned big-endian payload length followed by exactly that many bytes of UTF-8 JSON. The payload is one JSON-RPC 2.0 request, notification, success response, or error response; batches are not supported. + +The initial receive limit is 16 MiB (`16_777_216` bytes). The handshake negotiates the limit for later frames. The effective value may never exceed 64 MiB (`67_108_864` bytes), and an oversized length is rejected before allocating its payload. + +Requests can travel in both directions. Each peer must keep reading and dispatching incoming calls while awaiting a response, because plugin operations can make reentrant backend calls. Request IDs are directional strings unique for the TCP connection: + +- Host-originated: `host:` +- Backend-originated: `backend:` + +Responses echo the ID unchanged. Notifications omit `id`. Plugin stdout and stderr are ordinary process output and are never protocol channels. + +### Handshake + +The host sends: + +```json +{ + "jsonrpc": "2.0", + "id": "host:1", + "method": "backend.handshake", + "params": { + "token": "value from OPENCODE_EXTENSION_HOST_RPC_TOKEN", + "protocolVersion": 1, + "opencodeVersion": "1.17.18", + "maxFrameBytes": 16777216 + } +} +``` + +Rust returns: + +```json +{ + "jsonrpc": "2.0", + "id": "host:1", + "result": { + "protocolVersion": 1, + "maxFrameBytes": 16777216, + "cacheDirectory": "/absolute/path/to/plugin-cache" + } +} +``` + +`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. + +## Common wire types + +### JSON values + +An ordinary wire value is `null`, a boolean, a finite number, a string, an array of wire values, or a plain object with string keys and wire values. Cycles, functions, `BigInt`, symbols, `undefined`, non-finite numbers, and non-plain object instances are rejected. Optional properties are omitted rather than encoded as `undefined`. + +A serialization compatibility error identifies the path of the rejected value. The only function-valued capability projection is an `auth.loader` result's `fetch` property, described below. + +### Headers, HTTP, and diagnostics + +Headers always cross as an array of string pairs so repeated values can be preserved. + +```ts +type Headers = Array<[string, string]> + +type StreamDescriptor = { + streamID: string + length?: number +} + +type GatewayHttpRequest = { + instanceID: string + requestID: string + method: string + path: string // path and query + headers: Headers + body?: StreamDescriptor +} + +type AuthFetchRequest = { + url: string + method?: string + headers?: Headers + body?: StreamDescriptor +} + +type HttpResponse = { + status: number // 100 through 599 + statusText?: string + headers: Headers + body?: StreamDescriptor +} + +type Diagnostic = { + severity: "debug" | "info" | "warning" | "error" + code: string + message: string + plugin?: string + method?: string + data?: JsonValue +} +``` + +`requestID` identifies one HTTP or auth-fetch invocation. `length`, when present, is a non-negative byte-length hint; EOF remains authoritative. URL and `Headers` objects returned by plugins are normalized before crossing the wire. + +### Pull streams + +The process that creates a `ReadableStream` registers it and sends a `StreamDescriptor`. The receiver pulls from the owner: + +- Rust calls `host.stream.read` for a host-owned descriptor. +- Bun calls `backend.stream.read` for a backend-owned descriptor. + +Read params and results are: + +```ts +type StreamReadParams = { + instanceID: string + streamID: string + maxBytes?: number // 1 through 65_536 +} + +type StreamReadResult = { + data: string // base64 + eof: boolean +} +``` + +One read returns no more than `maxBytes`, with a 64 KiB maximum. `eof: true` releases the stream. A receiver that stops before EOF calls the owner's `*.stream.cancel` with `{ instanceID, streamID, reason? }`; cancellation is idempotent. + +### Process-local identity + +`instanceID`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. + +## Rust-to-host methods + +### Instance lifecycle + +#### `host.instance.open` + +Params: + +```ts +{ + instanceID: string + project: JsonValue + config: Record + directory: string + worktree: string + plugins: Array<{ + spec: string + options?: Record + baseDirectory?: string + }> +} +``` + +Result: + +```ts +{ + instanceID: string + config: Record + diagnostics: Diagnostic[] + hooks: string[] + tools: Array<{ + registrationID: string + id: string + plugin?: Record + description: string + parameters: JsonValue // JSON Schema + }> + auth: AuthRegistration[] + providers: Array<{ + provider: string + plugin?: Record + hasModels: boolean + }> + workspaces: Array<{ + registrationID: string + type: string + plugin?: Record + name: string + description: string + }> + gatewayURL: string +} +``` + +The gateway is listening before plugin entrypoints execute, so SDK calls during initialization work. Config hooks run sequentially before the result is sent. Failed plugins are omitted and represented in `diagnostics`; successful registrations remain available. + +Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. + +`hooks` may contain: + +- `chat.message` +- `chat.params` +- `chat.headers` +- `permission.ask` +- `command.execute.before` +- `tool.execute.before` +- `shell.env` +- `tool.execute.after` +- `experimental.chat.messages.transform` +- `experimental.chat.system.transform` +- `experimental.provider.small_model` +- `experimental.session.compacting` +- `experimental.compaction.autocontinue` +- `experimental.text.complete` +- `tool.definition` + +`config`, `event`, `dispose`, `tool`, `auth`, and `provider` are lifecycle hooks or registrations, not operational names. + +#### `host.instance.close` + +Params: `{ instanceID }`. Result: `{ closed: boolean }`. + +The host rejects new operations, aborts active tools and fetches, releases auth flows and streams, closes the gateway, and invokes every disposer once. Dispose failures are diagnostics and do not stop remaining cleanup. Repeated close is idempotent. + +#### `host.shutdown` + +Params: `{}`. Result: `{ closed: boolean }`. + +The host closes all instances, responds, closes the RPC connection, and exits normally. RPC EOF performs the same best-effort global cleanup before exit. + +### Hooks and events + +#### `host.hook.call` + +Params: `{ instanceID, hook, input, output }`. Result: `{ input, output }`. + +`input` and `output` are JSON values. Matching hooks run sequentially in plugin order on the same live objects for this invocation. The first hook error stops the invocation; earlier mutations are not rolled back. Different hook requests may overlap. + +For `tool.definition`, `output.parameters` crosses the process boundary as JSON Schema rather than an Effect schema object. + +#### `host.event.emit` + +Params: `{ instanceID, event }`. Result: `{ accepted: true }`. + +The host schedules event hooks in plugin order and responds without awaiting completion. Later failures are sent through `backend.diagnostic.publish`. + +### Tools + +#### `host.tool.execute` + +Params: + +```ts +{ + instanceID: string + executionID: string + registrationID: string + args: JsonValue + context: { + sessionID: string + messageID: string + agent: string + callID?: string + } +} +``` + +Result is the public plugin `ToolResult`: + +```ts +type ToolResult = + | string + | { + title?: string + output: string + metadata?: Record + attachments?: Array<{ + type: "file" + mime: string + url: string + filename?: string + }> + } +``` + +The host reconstructs a per-execution `AbortSignal` and fills the public tool context's `directory` and `worktree` from the instance. `context.metadata(...)` sends the `backend.tool.metadata` notification and returns synchronously. `context.ask(...)` awaits `backend.tool.ask`. + +Tool registration parameters and later `tool.definition` parameters use their JSON Schema projection. Rust sends arguments; Bun validates them through the retained plugin schema before execution. + +Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. + +#### `host.tool.cancel` + +Params: `{ instanceID, executionID }`. Result: `{ cancelled: boolean }`. + +The host aborts the retained signal. Cancellation is idempotent and does not hard-kill subprocesses created by a plugin. + +### Auth + +Auth registrations in `host.instance.open` use: + +```ts +type AuthRegistration = { + provider: string + plugin?: Record + hasLoader: boolean + methods: Array<{ + type: "oauth" | "api" + label: string + methodIndex: number + hasAuthorize: boolean + prompts: Array<{ + type: "text" | "select" + promptIndex: number + key: string + message: string + placeholder?: string + options?: Array<{ label: string; value: string; hint?: string }> + when?: { key: string; op: "eq" | "neq"; value: string } + hasValidate: boolean + hasCondition: boolean + }> + }> +} +``` + +The `has*` booleans advertise retained JavaScript capabilities. `when` remains ordinary data; validators and deprecated `condition` functions remain inside Bun. + +#### `host.auth.prompt.evaluate` + +Params: + +```ts +{ + instanceID: string + provider: string + methodIndex: number + promptIndex: number + operation: "validate" | "condition" + value?: string + inputs: Record +} +``` + +Result is `{ operation: "validate", error?: string }` or `{ operation: "condition", active: boolean }`. Rust calls only capabilities advertised by `hasValidate` or `hasCondition`; it can evaluate the serializable `when` rule itself. + +#### `host.auth.authorize` + +Params: `{ instanceID, provider, methodIndex, inputs? }`. + +An API method returns `{ type: "api", result? }`, where `result` is its public success/failed value. An OAuth method returns: + +```ts +{ + type: "oauth" + flowID: string + url: string + instructions: string + method: "auto" | "code" +} +``` + +The callback remains in Bun under `flowID`. Rust must not call an API method whose registration has `hasAuthorize: false`. + +#### `host.auth.callback` + +Params: `{ instanceID, flowID, code? }`. Result is the public OAuth success/failed union. `code` is required for a `code` flow and omitted for an `auto` flow. A flow survives until success, explicit cancellation, instance close, or process exit. + +#### `host.auth.flow.cancel` + +Params: `{ instanceID, flowID, reason? }`. Result: `{ cancelled: boolean }`. + +#### `host.auth.loader` + +Params: `{ instanceID, provider, providerInfo }`. `providerInfo` is the public SDK provider JSON value. + +Result: `{ value: Record, fetchID?: string }`. + +The loader receives a live auth getter. Every call to that getter makes a reentrant `backend.auth.get` request; the host does not cache auth state. Ordinary loader fields appear in `value`. A function-valued property named exactly `fetch` is retained in Bun and represented by `fetchID`; all other function-valued results are rejected. + +#### `host.auth.fetch` + +Params: `{ instanceID, fetchID, requestID, request: AuthFetchRequest }`. Result is `HttpResponse`. + +The host reconstructs a Fetch API request, invokes the retained fetch function, and exposes the response body as a host-owned stream. Bun pulls a backend-owned request body with `backend.stream.read`; Rust pulls the host-owned response body with `host.stream.read`. This capability is limited to provider SDK fetch overrides returned by `auth.loader`. + +#### `host.auth.fetch.cancel` + +Params: `{ instanceID, requestID, reason? }`. Result: `{ cancelled: boolean }`. + +#### `host.auth.fetch.release` + +Params: `{ instanceID, fetchID }`. Result: `{ released: boolean }`. This releases the retained function for future calls; repeated release is idempotent. + +### Providers + +#### `host.provider.models` + +Params: `{ instanceID, providerID, provider, auth? }`. Result: `{ models }`. + +`provider` is the public SDK v2 provider JSON value, `auth` is the optional public auth value, and `models` is a JSON object keyed by model ID. Rust calls this only when the corresponding registration has `hasModels: true`. + +### Workspaces + +Workspace adapters are invoked through their opaque `registrationID`; the open result also retains their descriptive `type`. Later registration of the same type replaces the earlier adapter. + +- `host.workspace.configure`: params `{ instanceID, registrationID, config }`; result `{ config }`. +- `host.workspace.create`: params `{ instanceID, registrationID, config, env, from? }`; result `{}`. +- `host.workspace.remove`: params `{ instanceID, registrationID, config }`; result `{}`. +- `host.workspace.target`: params `{ instanceID, registrationID, config }`; result is `{ target }`, where `target` is `{ type: "local", directory }` or `{ type: "remote", url, headers? }`. + +`config` and `from` use the public `WorkspaceInfo` JSON shape. `env` is a record of strings or null; null reconstructs `undefined` for the plugin. Remote `URL` and `HeadersInit` values are normalized to a URL string and header-pair list. + +### Host-owned streams + +- `host.stream.read`: params `StreamReadParams`; result `StreamReadResult`. +- `host.stream.cancel`: params `{ instanceID, streamID, reason? }`; result `{ cancelled: boolean }`. + +These methods accept only streams created by Bun, including gateway request bodies and auth-fetch response bodies. + +## Host-to-Rust methods + +### `backend.handshake` + +Authenticates and negotiates the connection as described in [Handshake](#handshake). It is the only method valid before the connection is ready. + +### `backend.http.request` + +Params are `GatewayHttpRequest`. Result is `HttpResponse`. + +`path` contains the gateway request's path and query, without its loopback origin. Bun creates the request-body descriptor, so Rust pulls it with `host.stream.read`. Rust creates the response-body descriptor, so Bun pulls it with `backend.stream.read`. WebSocket upgrades are rejected at the gateway and never forwarded. + +### `backend.auth.get` + +Params: `{ instanceID, providerID }`. Result: `{ auth: JsonValue | null }`. + +This request can arrive while Rust is awaiting `host.auth.loader` or an active auth fetch. Rust must service it reentrantly. The host makes a fresh request for every plugin getter call. + +### `backend.tool.ask` + +Params: + +```ts +{ + instanceID: string + executionID: string + permission: string + patterns: string[] + always: string[] + metadata: Record +} +``` + +Result: `{}` on approval, or a JSON-RPC error on denial/failure. The host awaits this request before resuming the tool. + +### `backend.tool.metadata` + +Notification params: `{ instanceID, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. + +### `backend.diagnostic.publish` + +Notification params: `{ instanceID?, diagnostic }`. This reports isolated plugin load, lifecycle, event, serialization, or gateway failures. A diagnostic does not replace the error response for a directly failed request. + +### Backend-owned streams + +- `backend.stream.read`: params `StreamReadParams`; result `StreamReadResult`. +- `backend.stream.cancel`: params `{ instanceID, streamID, reason? }`; result `{ cancelled: boolean }`. + +These methods accept only streams created by Rust, including backend HTTP responses and auth-fetch request bodies. + +## Loading, ordering, and ownership + +- A declaration is `{ spec, options?, baseDirectory? }`. `baseDirectory` anchors a relative local spec. +- npm plugins install in the handshake cache with lifecycle scripts disabled. Local plugins import in place and must already resolve their runtime dependencies. +- Server entrypoint discovery supports `exports["./server"]`, `main`, direct Bun-loadable files, and index files while enforcing package-boundary containment. +- Declarations are deduplicated by npm package identity or canonical local file URL, retaining the last. +- Retained declarations resolve and import concurrently. Successful entrypoints execute sequentially in declaration order. +- Config hooks execute sequentially and isolate errors. Operational hooks execute sequentially and propagate the first error. +- Later duplicate tool IDs, auth providers, provider IDs, and workspace types replace earlier registrations. +- Event dispatch is fire-and-forget in plugin order. +- Different invocations and different instances may overlap; there is no global call lock. +- Rust owns correct hook timing and must stop using an instance's registrations after close. + +## Error model + +A JSON-RPC error is `{ code: integer, message: string, data?: JsonValue }`. Use the standard codes when applicable: + +- `-32700`: invalid JSON. +- `-32600`: invalid JSON-RPC envelope. +- `-32601`: unknown method. +- `-32602`: invalid params. +- `-32603`: unexpected internal failure. + +Application failures use the reserved server-error range `-32000` through `-32099` and put machine-readable details in `data`. Unknown instances/handles, invalid instance state, cancellation, plugin exceptions, and serialization failures must be distinguishable in that data. A serialization failure includes its offending value path. + +A frame with an invalid or oversized length is a connection-level failure. The detecting peer closes the TCP connection; all outstanding requests fail and the host performs global cleanup. Rust treats every process-local handle as lost and does not automatically replay plugin or provider work. + +## Security and recovery + +The Rust listener and every instance gateway bind to loopback only. Use a high-entropy, single-spawn handshake token. The token authenticates the expected child to Rust; it does not sandbox plugins. + +Plugin code can access the host user's files, environment variables, network, and Bun subprocess APIs. Only load trusted plugin specs. Disabling npm lifecycle scripts narrows install-time behavior, but imported plugin code itself remains fully privileged. + +The process boundary contains JavaScript crashes and keeps plugin code out of Rust. It does not provide durable execution identity or crash continuation. After EOF or process death, Rust opens fresh instances and explicitly decides what interrupted application work, if any, is safe to retry. diff --git a/src/apps/extension-host/README.md b/src/apps/extension-host/README.md new file mode 100644 index 000000000..680c245f9 --- /dev/null +++ b/src/apps/extension-host/README.md @@ -0,0 +1,123 @@ +# OpenCode extension host + +This directory is BitFun's standalone Bun process for running established OpenCode Server plugins outside the OpenCode server. It targets the public `@opencode-ai/plugin` and `@opencode-ai/sdk` contract at version `1.17.18` and is supervised by the Rust backend. + +The host is a compatibility process, not an OpenCode server. Rust owns application state, persistence, HTTP behavior, lifecycle timing, and process supervision. The Bun process owns plugin resolution, JavaScript execution, and the function-valued capabilities that cannot cross a JSON boundary. + +## Compatibility boundary + +The host supports: + +- Public OpenCode Server plugin entrypoints and hook types from `@opencode-ai/plugin@1.17.18`. +- npm package specs and local files or directories. +- Package `exports["./server"]`, package `main`, and Bun-loadable `index.ts`, `index.tsx`, `index.js`, `index.mjs`, or `index.cjs` files. +- Object-form `{ id, server }` modules and legacy function exports. +- Plugin tools, auth providers and OAuth callbacks, provider model callbacks, and experimental workspace adapters. +- Multiple independent directory-scoped instances in one host process. +- The official OpenCode SDK, raw HTTP, and SSE through a per-instance loopback gateway. + +It deliberately does **not** support: + +- TUI plugins or the v2 plugin API. +- OpenCode's built-in plugins. +- Discovery or loading of `opencode.json` and other OpenCode configuration files. +- WebSocket proxying. A gateway request that attempts a WebSocket upgrade is rejected. +- Isolation from a malicious plugin. Plugins execute as trusted native extensions with the host user's filesystem, environment, network, and subprocess authority. + +Local plugins are imported in place and must already be able to resolve their runtime dependencies. npm plugins are installed into the cache directory supplied by Rust during the handshake, with lifecycle scripts disabled. The host never installs into or edits a local plugin project. + +## Architecture + +Rust first binds a loopback TCP listener and then launches the host. The host connects to the address in `OPENCODE_EXTENSION_HOST_RPC_ADDRESS` and authenticates its first request with `OPENCODE_EXTENSION_HOST_RPC_TOKEN`. + +```text +OpenCode plugin + |-- hooks, tools, auth, provider, workspace --> Bun extension host + |-- official SDK / raw HTTP / SSE ----------> per-instance 127.0.0.1 gateway + | +Rust backend <====== framed bidirectional JSON-RPC ==+ +``` + +Control traffic uses JSON-RPC 2.0 messages framed by a four-byte big-endian length. Requests can travel in either direction and may be reentrant; plugin stdout and stderr are never used as protocol channels. HTTP and fetch bodies use pull-based stream handles so the receiver controls backpressure instead of embedding unbounded bodies in JSON. + +Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. + +Closing an instance rejects new work, cancels active tools and fetches, closes its gateway, and invokes every registered disposer once. Losing the RPC connection applies the same cleanup to all instances and terminates the host. Rust is responsible for restarting the process and deciding whether any application work should be retried. + +See [PROTOCOL.md](./PROTOCOL.md) for the complete method and wire contract. For a step-by-step Chinese guide to calling an existing method or adding a new bidirectional RPC, see [BitFun 与 Extension Host 的 RPC 编写范例](../../../docs/architecture/extensions/plugin-host-rpc-example.zh-CN.md). `protocol.schema.json` is the generated machine-readable form consumed by a future Rust client; the Zod schemas in the implementation are canonical. + +## Build and launch + +The directory has its own dependency lock and does not rely on workspace-internal OpenCode packages. + +```sh +cd src/apps/extension-host +bun install --frozen-lockfile +bun typecheck +bun test +bun run build +``` + +The Rust supervisor should bind its listener before spawning the built host and inherit or redirect stdout and stderr normally: + +```sh +OPENCODE_EXTENSION_HOST_RPC_ADDRESS=127.0.0.1:48731 \ +OPENCODE_EXTENSION_HOST_RPC_TOKEN="$ONE_TIME_RANDOM_TOKEN" \ +bun ./dist/extension-host.js +``` + +The first host-to-Rust call is `backend.handshake`. Rust verifies the token, negotiates a frame limit, and supplies the npm plugin cache directory. Do not send instance requests until that handshake succeeds. The default negotiated frame limit is 16 MiB and neither peer may negotiate more than 64 MiB. + +At startup the host appends `127.0.0.1`, `localhost`, and `::1` to both `NO_PROXY` and `no_proxy`. This keeps the injected SDK and raw `serverUrl` traffic on the per-instance loopback gateway even when the supervisor environment defines an HTTP proxy; all other proxy settings remain visible to plugins. + +A normal supervisor sequence is: + +1. Bind the Rust-owned loopback listener, generate a fresh token, and spawn the host with the two environment variables. +2. Accept the connection and complete `backend.handshake`. +3. Open one or more instances with explicit project/config values and ordered plugin declarations. +4. Invoke hooks, tools, auth flows, provider callbacks, workspace adapters, and HTTP forwarding as application state requires. +5. Close individual instances when their directories are released. +6. Call `host.shutdown` for an orderly process shutdown, or close the TCP connection to force global cleanup. + +Rust should impose its own startup and request deadlines. The host intentionally does not provide durable recovery: instance IDs, registrations, active executions, stream handles, and auth-flow handles are process-local. + +## Loading plugins + +Rust passes declarations directly to `host.instance.open`: + +```ts +type PluginDeclaration = { + spec: string + options?: Record + baseDirectory?: string +} +``` + +`baseDirectory` anchors a relative local `spec`; it does not change the plugin's process working directory. A declaration without `baseDirectory` resolves a relative path from the instance directory. + +Declarations are deduplicated by npm package identity or canonical local file URL, retaining the last declaration. Resolution and import happen concurrently, but successful entrypoints execute sequentially in retained order. Later registrations replace earlier tools with the same tool ID, auth hooks for the same provider, provider hooks for the same provider ID, and workspace adapters for the same type. + +For npm packages, `engines.opencode` is checked against `1.17.18`. Install, entrypoint, compatibility, import, and entrypoint-execution failures are isolated to that plugin and returned as structured diagnostics. Config-hook and dispose-hook failures are also isolated; mutations completed before a config-hook failure remain visible. + +[`examples/example-plugin.ts`](./examples/example-plugin.ts) demonstrates the public plugin shape and the injected SDK, raw gateway URL, Bun shell, tool context, hook, and workspace APIs. + +## Gateway and streams + +Every instance gets a distinct `127.0.0.1` HTTP URL before its plugin entrypoints run. The injected SDK client uses that URL, and plugins may also use the injected `serverUrl` directly. The gateway forwards method, path and query, headers, and a streaming request body through `backend.http.request`, then reconstructs the backend's status, headers, and streaming response body. SSE therefore remains an ordinary streamed HTTP response. + +The side that creates a stream owns it. The other side repeatedly calls that owner's `*.stream.read` method, which returns at most 64 KiB encoded as base64, and then cancels or consumes the stream to EOF. Stream IDs, like every other opaque handle, are scoped to an instance and become invalid when that instance closes. + +## Serialization and diagnostics + +Only JSON-compatible data crosses the control channel. Cycles, functions, `BigInt`, and non-finite numbers produce a compatibility error that identifies the failing value path. Two deliberate projections handle public plugin values that are not natively serializable: + +- Tool parameter schemas cross as JSON Schema, including the `tool.definition` hook's `output.parameters` value. +- A function-valued `auth.loader` result named `fetch` remains in Bun and crosses as an opaque fetch handle. Other function-valued loader results are rejected. + +Plugin load failures, fire-and-forget event failures, and isolated lifecycle failures are published with `backend.diagnostic.publish`. Request failures use structured JSON-RPC errors; see [PROTOCOL.md](./PROTOCOL.md#error-model) for the stable error envelope. + +## Keeping the host self-contained + +Keep this entire directory together rather than moving only `dist/extension-host.js`. `package.json`, `bun.lock`, `protocol.schema.json`, and the protocol documentation must stay versioned as one unit with the JavaScript and Rust sides. After dependency or protocol changes, run the validation commands above and a subprocess handshake smoke test. + +Do not replace the pinned public packages with imports from this monorepo's Core, Protocol, Server, or generated internal modules. The dependency boundary is intentional: the extracted host must remain runnable without an OpenCode source checkout. diff --git a/src/apps/extension-host/bun.lock b/src/apps/extension-host/bun.lock new file mode 100644 index 000000000..11906cf75 --- /dev/null +++ b/src/apps/extension-host/bun.lock @@ -0,0 +1,112 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@opencode-ai/extension-host", + "dependencies": { + "@opencode-ai/plugin": "1.17.18", + "@opencode-ai/sdk": "1.17.18", + "npm-package-arg": "13.0.2", + "zod": "4.1.8", + }, + "devDependencies": { + "@tsconfig/bun": "1.0.9", + "@types/bun": "1.3.13", + "@types/npm-package-arg": "6.1.4", + "typescript": "5.8.2", + }, + }, + }, + "packages": { + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.17.18", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.17.18", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.3", "@opentui/keymap": ">=0.4.3", "@opentui/solid": ">=0.4.3" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-tqVBzhTHYUzO0laAmcQeBtT56tXYM5VGUk9V60O+cMx4kkSfac4qEfPVguCGUMJnfcU+u+EPvpME6xmHWoQE8w=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/npm-package-arg": ["@types/npm-package-arg@6.1.4", "", {}, "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], + + "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], + + "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "npm-package-arg": ["npm-package-arg@13.0.2", "", { "dependencies": { "hosted-git-info": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "validate-npm-package-name": "^7.0.0" } }, "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + + "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], + + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/src/apps/extension-host/examples/example-plugin.ts b/src/apps/extension-host/examples/example-plugin.ts new file mode 100644 index 000000000..8c2e90fdd --- /dev/null +++ b/src/apps/extension-host/examples/example-plugin.ts @@ -0,0 +1,84 @@ +import type { Plugin, PluginModule } from "@opencode-ai/plugin" +import { tool } from "@opencode-ai/plugin" + +const ExamplePlugin: Plugin = async (input) => { + input.experimental_workspace.register("example-local", { + name: "Example local workspace", + description: "Use the plugin instance directory as a local workspace", + configure(workspace) { + return { + ...workspace, + directory: workspace.directory ?? input.directory, + } + }, + async create() {}, + async remove() {}, + target(workspace) { + if (!workspace.directory) throw new Error("Example workspace has no directory") + return { + type: "local", + directory: workspace.directory, + } + }, + }) + + return { + tool: { + extension_host_info: tool({ + description: "Exercise the injected OpenCode SDK client and raw HTTP gateway", + args: {}, + async execute(_args, context) { + context.metadata({ + title: "Inspect extension host", + metadata: { projectID: input.project.id }, + }) + + await input.client.project.current() + const response = await fetch(new URL("/project/current", input.serverUrl), { + signal: context.abort, + }) + await response.body?.cancel() + + return { + title: "Extension host", + output: JSON.stringify( + { + projectID: input.project.id, + directory: input.directory, + worktree: input.worktree, + serverUrl: input.serverUrl.href, + rawGatewayStatus: response.status, + }, + null, + 2, + ), + } + }, + }), + extension_host_bun_version: tool({ + description: "Exercise the injected Bun shell after requesting permission", + args: {}, + async execute(_args, context) { + await context.ask({ + permission: "example_shell", + patterns: ["bun --version"], + always: [], + metadata: { command: "bun --version" }, + }) + context.abort.throwIfAborted() + const version = (await input.$`bun --version`.text()).trim() + context.abort.throwIfAborted() + return `Bun ${version}` + }, + }), + }, + async "chat.headers"(_hookInput, output) { + output.headers["x-example-extension-host"] = "1" + }, + } +} + +export default { + id: "example-extension-host", + server: ExamplePlugin, +} satisfies PluginModule diff --git a/src/apps/extension-host/package.json b/src/apps/extension-host/package.json new file mode 100644 index 000000000..5bf85af17 --- /dev/null +++ b/src/apps/extension-host/package.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/extension-host", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "bun@1.3.14", + "scripts": { + "build": "bun build ./src/main.ts --target=bun --outfile=dist/extension-host.js", + "generate": "bun run ./script/generate-protocol.ts", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@opencode-ai/plugin": "1.17.18", + "@opencode-ai/sdk": "1.17.18", + "npm-package-arg": "13.0.2", + "semver": "7.7.4", + "zod": "4.1.8" + }, + "devDependencies": { + "@tsconfig/bun": "1.0.9", + "@types/bun": "1.3.13", + "@types/npm-package-arg": "6.1.4", + "@types/node": "26.1.1", + "typescript": "5.8.2" + } +} diff --git a/src/apps/extension-host/protocol.schema.json b/src/apps/extension-host/protocol.schema.json new file mode 100644 index 000000000..b9f30def0 --- /dev/null +++ b/src/apps/extension-host/protocol.schema.json @@ -0,0 +1,3354 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencode.ai/schemas/extension-host/protocol-v1.json", + "title": "OpenCode extension host protocol", + "description": "JSON-RPC 2.0 envelopes and method schemas for the standalone OpenCode 1.17.18 Bun extension host.", + "oneOf": [ + { + "$ref": "#/$defs/RpcRequest" + }, + { + "$ref": "#/$defs/RpcNotification" + }, + { + "$ref": "#/$defs/RpcSuccessResponse" + }, + { + "$ref": "#/$defs/RpcErrorResponse" + } + ], + "$defs": { + "RpcRequest": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "minLength": 1 + }, + "params": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "id", + "method" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcRequest/$defs/__schema0" + } + } + ] + } + } + }, + "RpcNotification": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "method": { + "type": "string", + "minLength": 1 + }, + "params": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "method" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcNotification/$defs/__schema0" + } + } + ] + } + } + }, + "RpcSuccessResponse": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "result": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + }, + "required": [ + "jsonrpc", + "id", + "result" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcSuccessResponse/$defs/__schema0" + } + } + ] + } + } + }, + "RpcErrorResponse": { + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "const": "2.0" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + }, + "required": [ + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "jsonrpc", + "id", + "error" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/RpcErrorResponse/$defs/__schema0" + } + } + ] + } + } + }, + "HostPluginsPrepareParams": { + "type": "object", + "properties": { + "plugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + }, + "baseDirectory": { + "type": "string" + } + }, + "required": [ + "spec" + ], + "additionalProperties": false + } + }, + "configurationFingerprint": { + "type": "string", + "minLength": 1 + }, + "defaultBaseDirectory": { + "type": "string" + } + }, + "required": [ + "plugins" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostPluginsPrepareResult": { + "type": "object", + "properties": { + "configurationFingerprint": { + "type": "string", + "minLength": 1 + }, + "prepared": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "file", + "npm" + ] + }, + "target": { + "type": "string" + }, + "entry": { + "type": "string" + }, + "cache": { + "type": "string", + "enum": [ + "hit", + "installed", + "validated" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "spec", + "source", + "target", + "entry", + "cache" + ], + "additionalProperties": false + } + }, + "failed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string" + }, + "stage": { + "type": "string", + "enum": [ + "declaration", + "resolve", + "install", + "entry", + "compatibility", + "load", + "shape" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "spec", + "stage", + "message" + ], + "additionalProperties": false + } + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + } + }, + "required": [ + "prepared", + "failed", + "diagnostics" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostPluginsPrepareResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceOpenParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "project": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + "directory": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "plugins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "minLength": 1 + }, + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + "baseDirectory": { + "type": "string" + } + }, + "required": [ + "spec" + ], + "additionalProperties": false + } + }, + "configurationFingerprint": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "project", + "config", + "directory", + "worktree", + "plugins" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceOpenResult": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + }, + "hooks": { + "type": "array", + "items": { + "type": "string" + } + }, + "tools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "registrationID": { + "type": "string", + "minLength": 1 + }, + "id": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "description": { + "type": "string" + }, + "parameters": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "required": [ + "registrationID", + "id", + "description", + "parameters" + ], + "additionalProperties": false + } + }, + "auth": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "hasLoader": { + "type": "boolean" + }, + "methods": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth", + "api" + ] + }, + "label": { + "type": "string" + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hasAuthorize": { + "type": "boolean" + }, + "prompts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "select" + ] + }, + "promptIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "eq", + "neq" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "op", + "value" + ], + "additionalProperties": false + }, + "hasValidate": { + "type": "boolean" + }, + "hasCondition": { + "type": "boolean" + } + }, + "required": [ + "type", + "promptIndex", + "key", + "message", + "hasValidate", + "hasCondition" + ], + "additionalProperties": false + } + } + }, + "required": [ + "type", + "label", + "methodIndex", + "hasAuthorize", + "prompts" + ], + "additionalProperties": false + } + } + }, + "required": [ + "provider", + "hasLoader", + "methods" + ], + "additionalProperties": false + } + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "hasModels": { + "type": "boolean" + } + }, + "required": [ + "provider", + "hasModels" + ], + "additionalProperties": false + } + }, + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "registrationID": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "plugin": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "registrationID", + "type", + "name", + "description" + ], + "additionalProperties": false + } + }, + "gatewayURL": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "instanceID", + "config", + "diagnostics", + "hooks", + "tools", + "auth", + "providers", + "workspaces", + "gatewayURL" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostInstanceCloseParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID" + ], + "additionalProperties": false + }, + "HostInstanceCloseResult": { + "type": "object", + "properties": { + "closed": { + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "additionalProperties": false + }, + "HostLogSetLevelParams": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "off" + ] + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "HostLogSetLevelResult": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "trace", + "debug", + "info", + "warn", + "error", + "off" + ] + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "HostShutdownParams": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostShutdownResult": { + "type": "object", + "properties": { + "closed": { + "type": "boolean" + } + }, + "required": [ + "closed" + ], + "additionalProperties": false + }, + "HostHookCallParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "hook": { + "type": "string", + "minLength": 1 + }, + "input": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + }, + "output": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "hook", + "input", + "output" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostHookCallParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostHookCallResult": { + "type": "object", + "properties": { + "input": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + }, + "output": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + }, + "required": [ + "input", + "output" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostEventEmitParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "event": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "event" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostEventEmitParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostEventEmitResult": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean", + "const": true + } + }, + "required": [ + "accepted" + ], + "additionalProperties": false + }, + "HostToolExecuteParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "args": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + }, + "context": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "callID": { + "type": "string" + } + }, + "required": [ + "sessionID", + "messageID", + "agent" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "executionID", + "registrationID", + "args", + "context" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostToolExecuteResult": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "output": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "type", + "mime", + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "output" + ], + "additionalProperties": false + } + ], + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostToolCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "executionID" + ], + "additionalProperties": false + }, + "HostToolCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthPromptEvaluateParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "promptIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "operation": { + "type": "string", + "enum": [ + "validate", + "condition" + ] + }, + "value": { + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "instanceID", + "provider", + "methodIndex", + "promptIndex", + "operation", + "inputs" + ], + "additionalProperties": false + }, + "HostAuthPromptEvaluateResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "validate" + }, + "error": { + "type": "string" + } + }, + "required": [ + "operation" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "condition" + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "operation", + "active" + ], + "additionalProperties": false + } + ] + }, + "HostAuthAuthorizeParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "methodIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "instanceID", + "provider", + "methodIndex" + ], + "additionalProperties": false + }, + "HostAuthAuthorizeResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth" + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "url": { + "type": "string", + "format": "uri" + }, + "instructions": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "auto", + "code" + ] + } + }, + "required": [ + "type", + "flowID", + "url", + "instructions", + "method" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "api" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "key" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "HostAuthCallbackParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string" + } + }, + "required": [ + "instanceID", + "flowID" + ], + "additionalProperties": false + }, + "HostAuthCallbackResult": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "number" + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" + } + }, + "required": [ + "type", + "refresh", + "access", + "expires" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "success" + }, + "provider": { + "type": "string" + }, + "key": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "type", + "key" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "failed" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + ] + }, + "HostAuthFlowCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "flowID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "flowID" + ], + "additionalProperties": false + }, + "HostAuthFlowCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthLoaderParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "type": "string", + "minLength": 1 + }, + "providerInfo": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "provider", + "providerInfo" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostAuthLoaderResult": { + "type": "object", + "properties": { + "value": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + }, + "fetchID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "value" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostAuthLoaderResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostAuthFetchParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "fetchID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "request": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "method": { + "type": "string", + "minLength": 1 + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "url" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "fetchID", + "requestID", + "request" + ], + "additionalProperties": false + }, + "HostAuthFetchResult": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "statusText": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "headers" + ], + "additionalProperties": false + }, + "HostAuthFetchCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "requestID" + ], + "additionalProperties": false + }, + "HostAuthFetchCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "HostAuthFetchReleaseParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "fetchID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "fetchID" + ], + "additionalProperties": false + }, + "HostAuthFetchReleaseResult": { + "type": "object", + "properties": { + "released": { + "type": "boolean" + } + }, + "required": [ + "released" + ], + "additionalProperties": false + }, + "HostProviderModelsParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "providerID": { + "type": "string", + "minLength": 1 + }, + "provider": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + }, + "auth": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "providerID", + "provider" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostProviderModelsResult": { + "type": "object", + "properties": { + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + } + }, + "required": [ + "models" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostProviderModelsResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceConfigureParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceConfigureParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceConfigureResult": { + "type": "object", + "properties": { + "config": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + }, + "required": [ + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceConfigureResult/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceCreateParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + }, + "env": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "from": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config", + "env" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceCreateParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceCreateResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostWorkspaceRemoveParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceRemoveParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceRemoveResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "HostWorkspaceTargetParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "registrationID": { + "type": "string", + "minLength": 1 + }, + "config": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + }, + "required": [ + "instanceID", + "registrationID", + "config" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostWorkspaceTargetParams/$defs/__schema0" + } + } + ] + } + } + }, + "HostWorkspaceTargetResult": { + "type": "object", + "properties": { + "target": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "local" + }, + "directory": { + "type": "string" + } + }, + "required": [ + "type", + "directory" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "remote" + }, + "url": { + "type": "string", + "format": "uri" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "HostStreamReadParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "maxBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 65536 + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "HostStreamReadResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "eof": { + "type": "boolean" + } + }, + "required": [ + "data", + "eof" + ], + "additionalProperties": false + }, + "HostStreamCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "HostStreamCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + }, + "BackendHandshakeParams": { + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "protocolVersion": { + "type": "number", + "const": 1 + }, + "opencodeVersion": { + "type": "string", + "const": "1.17.18" + }, + "maxFrameBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 67108864 + } + }, + "required": [ + "token", + "protocolVersion", + "opencodeVersion", + "maxFrameBytes" + ], + "additionalProperties": false + }, + "BackendHandshakeResult": { + "type": "object", + "properties": { + "protocolVersion": { + "type": "number", + "const": 1 + }, + "maxFrameBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 67108864 + }, + "cacheDirectory": { + "type": "string" + } + }, + "required": [ + "protocolVersion", + "maxFrameBytes", + "cacheDirectory" + ], + "additionalProperties": false + }, + "BackendHttpRequestParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "requestID": { + "type": "string", + "minLength": 1 + }, + "method": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "instanceID", + "requestID", + "method", + "path", + "headers" + ], + "additionalProperties": false + }, + "BackendHttpRequestResult": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "minimum": 100, + "maximum": 599 + }, + "statusText": { + "type": "string" + }, + "headers": { + "type": "array", + "items": { + "minItems": 2, + "maxItems": 2, + "type": "array", + "items": { + "type": "string" + } + } + }, + "body": { + "type": "object", + "properties": { + "streamID": { + "type": "string", + "minLength": 1 + }, + "length": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "streamID" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "headers" + ], + "additionalProperties": false + }, + "BackendAuthGetParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "providerID": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "instanceID", + "providerID" + ], + "additionalProperties": false + }, + "BackendAuthGetResult": { + "type": "object", + "properties": { + "auth": { + "anyOf": [ + { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "auth" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendAuthGetResult/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolAskParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "permission": { + "type": "string" + }, + "patterns": { + "type": "array", + "items": { + "type": "string" + } + }, + "always": { + "type": "array", + "items": { + "type": "string" + } + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + } + }, + "required": [ + "instanceID", + "executionID", + "permission", + "patterns", + "always", + "metadata" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolAskParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolAskResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendToolMetadataParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + } + }, + "required": [ + "instanceID", + "executionID" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendToolMetadataParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendToolMetadataResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendDiagnosticPublishParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "diagnostic": { + "type": "object", + "properties": { + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "plugin": { + "type": "string" + }, + "method": { + "type": "string" + }, + "data": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "diagnostic" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/BackendDiagnosticPublishParams/$defs/__schema0" + } + } + ] + } + } + }, + "BackendDiagnosticPublishResult": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "BackendStreamReadParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "maxBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 65536 + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "BackendStreamReadResult": { + "type": "object", + "properties": { + "data": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "eof": { + "type": "boolean" + } + }, + "required": [ + "data", + "eof" + ], + "additionalProperties": false + }, + "BackendStreamCancelParams": { + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "streamID": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string" + } + }, + "required": [ + "instanceID", + "streamID" + ], + "additionalProperties": false + }, + "BackendStreamCancelResult": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": false + } + }, + "x-protocol-version": 1, + "x-methods": { + "host.plugins.prepare": { + "direction": "rust-to-host", + "params": "#/$defs/HostPluginsPrepareParams", + "result": "#/$defs/HostPluginsPrepareResult" + }, + "host.instance.open": { + "direction": "rust-to-host", + "params": "#/$defs/HostInstanceOpenParams", + "result": "#/$defs/HostInstanceOpenResult" + }, + "host.instance.close": { + "direction": "rust-to-host", + "params": "#/$defs/HostInstanceCloseParams", + "result": "#/$defs/HostInstanceCloseResult" + }, + "host.log.setLevel": { + "direction": "rust-to-host", + "params": "#/$defs/HostLogSetLevelParams", + "result": "#/$defs/HostLogSetLevelResult" + }, + "host.shutdown": { + "direction": "rust-to-host", + "params": "#/$defs/HostShutdownParams", + "result": "#/$defs/HostShutdownResult" + }, + "host.hook.call": { + "direction": "rust-to-host", + "params": "#/$defs/HostHookCallParams", + "result": "#/$defs/HostHookCallResult" + }, + "host.event.emit": { + "direction": "rust-to-host", + "params": "#/$defs/HostEventEmitParams", + "result": "#/$defs/HostEventEmitResult" + }, + "host.tool.execute": { + "direction": "rust-to-host", + "params": "#/$defs/HostToolExecuteParams", + "result": "#/$defs/HostToolExecuteResult" + }, + "host.tool.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostToolCancelParams", + "result": "#/$defs/HostToolCancelResult" + }, + "host.auth.prompt.evaluate": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthPromptEvaluateParams", + "result": "#/$defs/HostAuthPromptEvaluateResult" + }, + "host.auth.authorize": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthAuthorizeParams", + "result": "#/$defs/HostAuthAuthorizeResult" + }, + "host.auth.callback": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthCallbackParams", + "result": "#/$defs/HostAuthCallbackResult" + }, + "host.auth.flow.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFlowCancelParams", + "result": "#/$defs/HostAuthFlowCancelResult" + }, + "host.auth.loader": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthLoaderParams", + "result": "#/$defs/HostAuthLoaderResult" + }, + "host.auth.fetch": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchParams", + "result": "#/$defs/HostAuthFetchResult" + }, + "host.auth.fetch.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchCancelParams", + "result": "#/$defs/HostAuthFetchCancelResult" + }, + "host.auth.fetch.release": { + "direction": "rust-to-host", + "params": "#/$defs/HostAuthFetchReleaseParams", + "result": "#/$defs/HostAuthFetchReleaseResult" + }, + "host.provider.models": { + "direction": "rust-to-host", + "params": "#/$defs/HostProviderModelsParams", + "result": "#/$defs/HostProviderModelsResult" + }, + "host.workspace.configure": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceConfigureParams", + "result": "#/$defs/HostWorkspaceConfigureResult" + }, + "host.workspace.create": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceCreateParams", + "result": "#/$defs/HostWorkspaceCreateResult" + }, + "host.workspace.remove": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceRemoveParams", + "result": "#/$defs/HostWorkspaceRemoveResult" + }, + "host.workspace.target": { + "direction": "rust-to-host", + "params": "#/$defs/HostWorkspaceTargetParams", + "result": "#/$defs/HostWorkspaceTargetResult" + }, + "host.stream.read": { + "direction": "rust-to-host", + "params": "#/$defs/HostStreamReadParams", + "result": "#/$defs/HostStreamReadResult" + }, + "host.stream.cancel": { + "direction": "rust-to-host", + "params": "#/$defs/HostStreamCancelParams", + "result": "#/$defs/HostStreamCancelResult" + }, + "backend.handshake": { + "direction": "host-to-rust", + "params": "#/$defs/BackendHandshakeParams", + "result": "#/$defs/BackendHandshakeResult" + }, + "backend.http.request": { + "direction": "host-to-rust", + "params": "#/$defs/BackendHttpRequestParams", + "result": "#/$defs/BackendHttpRequestResult" + }, + "backend.auth.get": { + "direction": "host-to-rust", + "params": "#/$defs/BackendAuthGetParams", + "result": "#/$defs/BackendAuthGetResult" + }, + "backend.tool.ask": { + "direction": "host-to-rust", + "params": "#/$defs/BackendToolAskParams", + "result": "#/$defs/BackendToolAskResult" + }, + "backend.tool.metadata": { + "direction": "host-to-rust", + "params": "#/$defs/BackendToolMetadataParams", + "result": "#/$defs/BackendToolMetadataResult" + }, + "backend.diagnostic.publish": { + "direction": "host-to-rust", + "params": "#/$defs/BackendDiagnosticPublishParams", + "result": "#/$defs/BackendDiagnosticPublishResult" + }, + "backend.stream.read": { + "direction": "host-to-rust", + "params": "#/$defs/BackendStreamReadParams", + "result": "#/$defs/BackendStreamReadResult" + }, + "backend.stream.cancel": { + "direction": "host-to-rust", + "params": "#/$defs/BackendStreamCancelParams", + "result": "#/$defs/BackendStreamCancelResult" + } + } +} diff --git a/src/apps/extension-host/script/generate-protocol.ts b/src/apps/extension-host/script/generate-protocol.ts new file mode 100644 index 000000000..93c701ed8 --- /dev/null +++ b/src/apps/extension-host/script/generate-protocol.ts @@ -0,0 +1,90 @@ +import { z } from "zod" +import { + BackendMethodSchemas, + HostMethodSchemas, + PROTOCOL_VERSION, + RpcErrorResponseSchema, + RpcNotificationSchema, + RpcRequestSchema, + RpcSuccessResponseSchema, +} from "../src/protocol" + +const definitions: Record = {} +const methods: Record = {} + +addDefinition("RpcRequest", RpcRequestSchema) +addDefinition("RpcNotification", RpcNotificationSchema) +addDefinition("RpcSuccessResponse", RpcSuccessResponseSchema) +addDefinition("RpcErrorResponse", RpcErrorResponseSchema) + +for (const [method, definition] of Object.entries(HostMethodSchemas)) addMethod("rust-to-host", method, definition) +for (const [method, definition] of Object.entries(BackendMethodSchemas)) addMethod("host-to-rust", method, definition) + +const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://opencode.ai/schemas/extension-host/protocol-v1.json", + title: "OpenCode extension host protocol", + description: "JSON-RPC 2.0 envelopes and method schemas for the standalone OpenCode 1.17.18 Bun extension host.", + oneOf: [ + { $ref: "#/$defs/RpcRequest" }, + { $ref: "#/$defs/RpcNotification" }, + { $ref: "#/$defs/RpcSuccessResponse" }, + { $ref: "#/$defs/RpcErrorResponse" }, + ], + $defs: definitions, + "x-protocol-version": PROTOCOL_VERSION, + "x-methods": methods, +} + +const output = `${JSON.stringify(schema, null, 2)}\n` +const path = new URL("../protocol.schema.json", import.meta.url) +if (process.argv.includes("--check")) { + const current = await Bun.file(path) + .text() + .catch(() => "") + if (current !== output) { + console.error("protocol.schema.json is out of date; run bun run generate") + process.exit(1) + } + process.exit(0) +} +await Bun.write(path, output) + +function addMethod( + direction: "rust-to-host" | "host-to-rust", + method: string, + definition: { params: z.ZodType; result: z.ZodType }, +) { + const name = method + .split(".") + .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`) + .join("") + const params = `${name}Params` + const result = `${name}Result` + addDefinition(params, definition.params) + addDefinition(result, definition.result) + methods[method] = { + direction, + params: `#/$defs/${params}`, + result: `#/$defs/${result}`, + } +} + +function addDefinition(name: string, value: z.ZodType) { + definitions[name] = scopeReferences(z.toJSONSchema(value, { target: "draft-2020-12" }), `#/$defs/${name}`) +} + +function scopeReferences(value: unknown, scope: string): unknown { + if (Array.isArray(value)) return value.map((item) => scopeReferences(item, scope)) + if (typeof value !== "object" || value === null) return value + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "$schema") + .map(([key, item]) => { + if (key !== "$ref" || typeof item !== "string" || !item.startsWith("#")) { + return [key, scopeReferences(item, scope)] + } + return [key, `${scope}${item.slice(1)}`] + }), + ) +} diff --git a/src/apps/extension-host/src/backend.ts b/src/apps/extension-host/src/backend.ts new file mode 100644 index 000000000..e1558f10d --- /dev/null +++ b/src/apps/extension-host/src/backend.ts @@ -0,0 +1,56 @@ +export type RpcConnection = { + request(method: string, params: unknown, options?: { signal?: AbortSignal }): Promise + notify(method: string, params: unknown): Promise | void +} + +export type StreamDescriptor = { + streamID: string + length?: number +} + +export type StreamBridge = { + register(instanceID: string, stream: ReadableStream, length?: number): StreamDescriptor + remote(methodPrefix: "backend" | "host", instanceID: string, descriptor: StreamDescriptor): ReadableStream + cancel(instanceID: string, descriptor: StreamDescriptor): Promise + cancelAll(instanceID: string): Promise + cancelRemote?(instanceID: string, descriptor: StreamDescriptor, reason?: string): Promise +} + +export type Diagnostic = { + level: "debug" | "info" | "warn" | "error" + message: string + instanceID?: string + plugin?: { + id?: string + spec: string + } + operation?: string + error?: { + name?: string + message: string + stack?: string + cause?: unknown + } +} + +export async function publishDiagnostic(rpc: RpcConnection, diagnostic: Diagnostic) { + const { instanceID, ...value } = diagnostic + await rpc.notify("backend.diagnostic.publish", { + ...(instanceID ? { instanceID } : {}), + diagnostic: { + severity: value.level === "warn" ? "warning" : value.level, + code: value.operation ?? "extension_host", + message: value.message, + plugin: value.plugin?.id ?? value.plugin?.spec, + method: value.operation, + data: value.error + ? { + ...(value.error.name ? { name: value.error.name } : {}), + message: value.error.message, + ...(value.error.stack ? { stack: value.error.stack } : {}), + ...(value.error.cause === undefined ? {} : { cause: String(value.error.cause) }), + } + : undefined, + }, + }) +} diff --git a/src/apps/extension-host/src/bun-loader.ts b/src/apps/extension-host/src/bun-loader.ts new file mode 100644 index 000000000..49311bdaa --- /dev/null +++ b/src/apps/extension-host/src/bun-loader.ts @@ -0,0 +1,91 @@ +import { mkdir, realpath, stat } from "node:fs/promises" +import path from "node:path" +import { + loadPlugins, + preparePlugins, + type LoadPluginsInput, + type NpmInstaller, + type PluginCacheStatus, +} from "./loader" +import { logEvent } from "./log" + +type InstallResult = { target: string; cache: Exclude } + +const installs = new Map>() + +export function loadBunPlugins(input: Omit) { + return loadPlugins({ ...input, install: installNpmPlugin }) +} + +export function prepareBunPlugins(input: Omit) { + return preparePlugins({ ...input, install: installNpmPlugin }) +} + +export function installNpmPlugin(input: Parameters[0]): Promise { + const directory = path.join( + path.resolve(input.cacheDirectory), + "plugins", + `${packageSlug(input.packageName ?? "plugin")}-${Bun.hash(input.spec).toString(16)}`, + ) + const pending = installs.get(directory) + if (pending) return pending + const operation = installNpmPluginAt(input, directory).finally(() => installs.delete(directory)) + installs.set(directory, operation) + return operation +} + +async function installNpmPluginAt(input: Parameters[0], directory: string) { + await mkdir(directory, { recursive: true }) + const manifestPath = path.join(directory, "package.json") + if (!(await Bun.file(manifestPath).exists())) { + await Bun.write(manifestPath, `${JSON.stringify({ private: true }, null, 2)}\n`) + } + const existing = await installedPackage(directory, input.packageName) + if (existing) { + logEvent("plugin.prepare.cache_hit", { plugin: input.spec, target: existing }, "debug") + return { target: existing, cache: "hit" as const } + } + const startedAt = performance.now() + logEvent("plugin.prepare.install.begin", { plugin: input.spec, cache_directory: directory }) + const child = Bun.spawn({ + cmd: [process.execPath, "add", "--ignore-scripts", "--exact", input.spec], + cwd: directory, + stdin: "ignore", + 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(`Failed to install plugin ${input.spec}: ${stderr.trim() || stdout.trim() || `bun add exited with status ${code}`}`) + const installed = await installedPackage(directory, input.packageName) + if (installed) { + logEvent("plugin.prepare.install.completed", { + plugin: input.spec, + target: installed, + cache_directory: directory, + duration_ms: Math.round(performance.now() - startedAt), + }) + return { target: installed, cache: "installed" as const } + } + throw new Error(`Plugin ${input.spec} was installed but its package directory could not be found`) +} + +async function installedPackage(directory: string, preferred?: string) { + const manifestPath = path.join(directory, "package.json") + if (!(await Bun.file(manifestPath).exists())) return + const manifest = (await Bun.file(manifestPath).json()) as Record + const dependencies = isRecord(manifest.dependencies) ? Object.keys(manifest.dependencies) : [] + const name = preferred && dependencies.includes(preferred) ? preferred : dependencies.length === 1 ? dependencies[0] : undefined + if (!name) return + const target = path.join(directory, "node_modules", name) + const metadata = await stat(target).catch(() => undefined) + return metadata?.isDirectory() ? realpath(target) : undefined +} + +function packageSlug(name: string) { + const slug = name.replaceAll(/[^A-Za-z0-9._-]/g, "-").replaceAll(/^-+|-+$/g, "") + return slug || "plugin" +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/apps/extension-host/src/errors.ts b/src/apps/extension-host/src/errors.ts new file mode 100644 index 000000000..3f4386910 --- /dev/null +++ b/src/apps/extension-host/src/errors.ts @@ -0,0 +1,30 @@ +export class ExtensionHostError extends Error { + readonly code: number + readonly data?: unknown + + constructor(code: number, message: string, data?: unknown) { + super(message) + this.name = "ExtensionHostError" + this.code = code + this.data = data + } +} + +export type SerializedError = { + name?: string + message: string + stack?: string + cause?: SerializedError | string +} + +export function errorData(error: unknown): SerializedError { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + ...(error.cause === undefined + ? {} + : { cause: error.cause instanceof Error ? errorData(error.cause) : String(error.cause) }), + } +} diff --git a/src/apps/extension-host/src/gateway.ts b/src/apps/extension-host/src/gateway.ts new file mode 100644 index 000000000..59e1b14cc --- /dev/null +++ b/src/apps/extension-host/src/gateway.ts @@ -0,0 +1,113 @@ +import type { RpcConnection, StreamBridge, StreamDescriptor } from "./backend" +import { publishDiagnostic } from "./backend" + +type GatewayResponse = { + status: number + statusText?: string + headers?: Array<[string, string]> | Record + body?: StreamDescriptor +} + +export type Gateway = { + url: URL + close(): Promise +} + +export type GatewayFactory = ( + input: { instanceID: string; rpc: RpcConnection; streams: StreamBridge }, +) => Gateway | Promise + +export function createGateway(input: { instanceID: string; rpc: RpcConnection; streams: StreamBridge }): Gateway { + const active = new Set() + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.headers.get("upgrade")) { + return Response.json( + { error: "WebSocket forwarding is not supported by this extension host" }, + { status: 426, headers: { upgrade: "close" } }, + ) + } + + const length = request.headers.get("content-length") + const body = request.body + ? input.streams.register( + input.instanceID, + request.body, + length && Number.isSafeInteger(Number(length)) ? Number(length) : undefined, + ) + : undefined + if (body) active.add(body) + + try { + const url = new URL(request.url) + const result = await input.rpc.request( + "backend.http.request", + { + instanceID: input.instanceID, + requestID: crypto.randomUUID(), + method: request.method, + path: `${url.pathname}${url.search}`, + headers: Array.from(request.headers.entries()), + body, + }, + { signal: request.signal }, + ) + validateResponse(result) + + const headers = new Headers(result.headers) + if (!result.body) { + return new Response(null, { status: result.status, statusText: result.statusText, headers }) + } + + const stream = input.streams.remote("backend", input.instanceID, result.body) + return new Response(stream, { status: result.status, statusText: result.statusText, headers }) + } catch (error) { + if (body) await input.streams.cancel(input.instanceID, body).catch(() => {}) + await publishDiagnostic(input.rpc, { + level: "error", + message: "Failed to forward plugin HTTP request", + instanceID: input.instanceID, + operation: "backend.http.request", + error: errorInfo(error), + }).catch(() => {}) + return Response.json({ error: "Extension backend request failed" }, { status: 502 }) + } finally { + if (body) active.delete(body) + } + }, + }) + + return { + url: server.url, + async close() { + await Promise.all( + Array.from(active, (descriptor) => input.streams.cancel(input.instanceID, descriptor).catch(() => {})), + ) + active.clear() + await server.stop(true) + }, + } +} + +function validateResponse(value: GatewayResponse) { + if (!value || typeof value !== "object") throw new TypeError("backend.http.request returned a non-object") + if (!Number.isInteger(value.status) || value.status < 100 || value.status > 599) { + throw new TypeError("backend.http.request returned an invalid status") + } + if (!value.body) return + if (typeof value.body.streamID !== "string" || !value.body.streamID) { + throw new TypeError("backend.http.request returned an invalid body stream") + } +} + +function errorInfo(error: unknown) { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + } +} diff --git a/src/apps/extension-host/src/host.ts b/src/apps/extension-host/src/host.ts new file mode 100644 index 000000000..53ba25cbe --- /dev/null +++ b/src/apps/extension-host/src/host.ts @@ -0,0 +1,1136 @@ +import path from "node:path" +import { realpath } from "node:fs/promises" +import type { + AuthHook, + AuthOAuthResult, + Hooks, + PluginInput, + PluginOptions, + ProviderHook, + ToolDefinition, + WorkspaceAdapter, + WorkspaceInfo, + WorkspaceTarget, +} from "@opencode-ai/plugin" +import { createOpencodeClient } from "@opencode-ai/sdk" +import type { Auth, Provider } from "@opencode-ai/sdk/v2" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "./backend" +import { publishDiagnostic } from "./backend" +import { ExtensionHostError, errorData } from "./errors" +import type { Gateway, GatewayFactory } from "./gateway" +import { logError, logEvent } from "./log" +import { + type LoadPluginsInput, + type LoadedPlugin, + type LoaderDiagnostic, + type PluginDeclaration, + loadPreparedPlugins, + type PreparePluginsResult, +} from "./loader" +import { HostMethodSchemas } from "./protocol" +import { toolParametersToJsonSchema, validateToolArguments } from "./tool-schema" +import { cloneWireValue, type WireValue } from "./wire" + +const OPENCODE_VERSION = "1.17.18" +const GENERIC_HOOKS = [ + "chat.message", + "chat.params", + "chat.headers", + "permission.ask", + "command.execute.before", + "tool.execute.before", + "shell.env", + "tool.execute.after", + "experimental.chat.messages.transform", + "experimental.chat.system.transform", + "experimental.provider.small_model", + "experimental.session.compacting", + "experimental.compaction.autocontinue", + "experimental.text.complete", + "tool.definition", +] as const + +type PluginMeta = { + id?: string + spec: string + entry: string + index: number +} + +type RuntimeDiagnostic = { + level: "error" + stage: "runtime" + spec: string + pluginID?: string + message: string + error?: { + name?: string + message: string + stack?: string + cause?: unknown + } +} + +type HostDiagnostic = LoaderDiagnostic | RuntimeDiagnostic + +type RetainedHooks = { + plugin: PluginMeta + hooks: Hooks +} + +type ToolRegistration = { + registrationID: string + plugin: PluginMeta + id: string + definition: ToolDefinition + parameters: WireValue +} + +type AuthRegistration = { + plugin: PluginMeta + hook: AuthHook +} + +type ProviderRegistration = { + plugin: PluginMeta + hook: ProviderHook +} + +type WorkspaceRegistration = { + registrationID: string + plugin: PluginMeta + type: string + adapter: WorkspaceAdapter +} + +type OAuthFlow = { + plugin: PluginMeta + method: AuthOAuthResult["method"] + callback: AuthOAuthResult["callback"] +} + +type AuthFetch = { + plugin: PluginMeta + provider: string + fetch: typeof fetch +} + +type ActiveAuthFetch = { + controller: AbortController + body?: ReadableStream + descriptor?: StreamDescriptor +} + +type Instance = { + id: string + canonicalDirectory: string + directory: string + worktree: string + status: "opening" | "open" | "closing" + gateway: Gateway + hooks: RetainedHooks[] + tools: Map + auth: Map + providers: Map + workspaces: Map + flows: Map + fetches: Map + activeTools: Map + activeFetches: Map + openDone: Promise + finishOpen(): void + closePromise?: Promise + disposed: Set + counter: number +} + +export type InstanceOpenInput = { + instanceID: string + project: WireValue + directory: string + worktree: string + config: WireValue + plugins: PluginDeclaration[] + configurationFingerprint?: string +} + +export type PluginsPrepareInput = { + plugins: PluginDeclaration[] + configurationFingerprint?: string + defaultBaseDirectory?: string +} + +export class ExtensionHost { + readonly #rpc: RpcConnection + readonly #streams: StreamBridge + readonly #cacheDirectory: string + readonly #gatewayFactory: GatewayFactory + readonly #preparePlugins: (input: LoadPluginsInput) => Promise + readonly #shell: PluginInput["$"] + readonly #instances = new Map() + readonly #directories = new Map() + readonly #opening = new Map>() + readonly #preparations = new Map>() + readonly #cancelledOpenings = new Set() + #status: "running" | "closing" | "closed" = "running" + #shutdownPromise?: Promise + + constructor(input: { + rpc: RpcConnection + streams: StreamBridge + cacheDirectory: string + gatewayFactory: GatewayFactory + preparePlugins: (input: LoadPluginsInput) => Promise + shell: PluginInput["$"] + }) { + this.#rpc = input.rpc + this.#streams = input.streams + this.#cacheDirectory = input.cacheDirectory + this.#gatewayFactory = input.gatewayFactory + this.#preparePlugins = input.preparePlugins + this.#shell = input.shell + } + + async prepare(input: PluginsPrepareInput) { + this.#assertAccepting() + const prepared = await this.#prepare({ + declarations: input.plugins, + defaultBaseDirectory: input.defaultBaseDirectory, + configurationFingerprint: input.configurationFingerprint, + }) + return HostMethodSchemas["host.plugins.prepare"].result.parse({ + ...(input.configurationFingerprint + ? { configurationFingerprint: input.configurationFingerprint } + : {}), + prepared: prepared.prepared.map((plugin) => ({ + spec: plugin.spec, + source: plugin.source, + target: plugin.target, + entry: plugin.entry, + cache: plugin.cache, + ...(typeof plugin.package?.manifest.version === "string" + ? { version: plugin.package.manifest.version } + : {}), + })), + failed: prepared.diagnostics.map((diagnostic) => ({ + spec: diagnostic.spec, + stage: diagnostic.stage, + message: diagnostic.message, + })), + diagnostics: prepared.diagnostics.map(protocolDiagnostic), + }) + } + + async open(input: InstanceOpenInput) { + this.#assertAccepting() + if (this.#instances.has(input.instanceID) || this.#opening.has(input.instanceID)) { + throw new ExtensionHostError(-32002, `Instance ${input.instanceID} already exists`, { + kind: "instance_exists", + instanceID: input.instanceID, + }) + } + const operation = Promise.withResolvers() + this.#opening.set(input.instanceID, operation.promise) + try { + const canonicalDirectory = await realpath(path.resolve(input.directory)).catch(() => + path.resolve(input.directory), + ) + this.#assertAccepting() + if (this.#cancelledOpenings.has(input.instanceID)) { + throw new ExtensionHostError(-32004, `Instance ${input.instanceID} was closed while opening`, { + kind: "instance_closing", + instanceID: input.instanceID, + }) + } + const owner = this.#directories.get(canonicalDirectory) + if (owner) { + throw new ExtensionHostError(-32002, `Directory ${canonicalDirectory} is already owned by ${owner}`, { + kind: "directory_exists", + instanceID: owner, + directory: canonicalDirectory, + }) + } + + const config = cloneWireValue(input.config, "config") + const gateway = await this.#gatewayFactory({ + instanceID: input.instanceID, + rpc: this.#rpc, + streams: this.#streams, + }) + const opened = Promise.withResolvers() + const instance: Instance = { + id: input.instanceID, + canonicalDirectory, + directory: input.directory, + worktree: input.worktree, + status: "opening", + gateway, + hooks: [], + tools: new Map(), + auth: new Map(), + providers: new Map(), + workspaces: new Map(), + flows: new Map(), + fetches: new Map(), + activeTools: new Map(), + activeFetches: new Map(), + openDone: opened.promise, + finishOpen: opened.resolve, + disposed: new Set(), + counter: 0, + } + this.#instances.set(instance.id, instance) + this.#directories.set(canonicalDirectory, instance.id) + + let failure: unknown + const diagnostics: HostDiagnostic[] = [] + try { + logEvent("plugin.activation.begin", { + instance_id: instance.id, + plugin_count: input.plugins.length, + plugins: input.plugins.map(pluginDeclarationSpec), + }) + const prepared = await this.#prepare({ + declarations: input.plugins, + defaultBaseDirectory: input.directory, + configurationFingerprint: input.configurationFingerprint, + }) + const loaded = await loadPreparedPlugins(prepared) + this.#assertOpening(instance) + diagnostics.push(...loaded.diagnostics) + + const client = createOpencodeClient({ baseUrl: gateway.url.toString(), directory: input.directory }) + for (const plugin of loaded.loaded) { + this.#assertOpening(instance) + await this.#startPlugin(instance, plugin, input.project, client, diagnostics) + } + + const activatedPlugins = instance.hooks.map(({ plugin }) => plugin.spec) + logEvent("plugin.activation.complete", { + instance_id: instance.id, + configured_plugin_count: input.plugins.length, + loaded_plugin_count: loaded.loaded.length, + activated_plugin_count: activatedPlugins.length, + plugins: activatedPlugins, + diagnostic_count: diagnostics.length, + }) + + for (const retained of instance.hooks) { + this.#assertOpening(instance) + if (!retained.hooks.config) continue + try { + await Promise.resolve(retained.hooks.config(config as never)) + } catch (error) { + const diagnostic = runtimeDiagnostic(retained.plugin, "config", error) + diagnostics.push(diagnostic) + await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) + } + this.#assertOpening(instance) + } + + this.#assertOpening(instance) + this.#indexRegistrations(instance, diagnostics) + this.#assertOpening(instance) + const result = HostMethodSchemas["host.instance.open"].result.parse(openResult(instance, config, diagnostics)) + instance.status = "open" + return result + } catch (error) { + logError("plugin.activation.failed", error, { + instance_id: instance.id, + configured_plugin_count: input.plugins.length, + activated_plugin_count: instance.hooks.length, + plugins: instance.hooks.map(({ plugin }) => plugin.spec), + }) + failure = error + } finally { + instance.finishOpen() + } + + await this.#beginClose(instance) + throw failure + } finally { + if (this.#opening.get(input.instanceID) === operation.promise) this.#opening.delete(input.instanceID) + this.#cancelledOpenings.delete(input.instanceID) + operation.resolve() + } + } + + async close(input: { instanceID: string }): Promise<{ closed: boolean }> { + const pending = this.#opening.get(input.instanceID) + const instance = this.#instances.get(input.instanceID) + if (!instance && pending) { + this.#cancelledOpenings.add(input.instanceID) + await pending + return { closed: true } + } + if (!instance) return { closed: false } + const first = !instance.closePromise + await this.#beginClose(instance) + return { closed: first } + } + + async shutdown() { + if (!this.#shutdownPromise) { + this.#status = "closing" + this.#shutdownPromise = (async () => { + await Promise.all([ + ...Array.from(this.#instances.values(), (instance) => this.#beginClose(instance)), + ...this.#opening.values(), + ...this.#preparations.values(), + ]) + await Promise.all(Array.from(this.#instances.values(), (instance) => this.#beginClose(instance))) + this.#status = "closed" + })() + } + await this.#shutdownPromise + return { closed: true } + } + + #prepare(input: { + declarations: readonly PluginDeclaration[] + defaultBaseDirectory?: string + configurationFingerprint?: string + }) { + const key = preparationKey(input) + const pending = this.#preparations.get(key) + if (pending) { + logEvent("plugin.prepare.waiting_existing", { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + }, "debug") + return pending + } + + const startedAt = performance.now() + logEvent("plugin.prepare.begin", { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + }) + const operation = this.#preparePlugins({ + declarations: input.declarations, + cacheDirectory: this.#cacheDirectory, + defaultBaseDirectory: input.defaultBaseDirectory, + compatibilityVersion: OPENCODE_VERSION, + }) + .then((result) => { + for (const diagnostic of result.diagnostics) { + logEvent("plugin.prepare.failed", { + configuration_fingerprint: input.configurationFingerprint, + plugin: diagnostic.spec, + stage: diagnostic.stage, + error_message: diagnostic.message, + }, "error") + } + logEvent("plugin.prepare.completed", { + configuration_fingerprint: input.configurationFingerprint, + configured_plugin_count: input.declarations.length, + prepared_plugin_count: result.prepared.length, + failed_plugin_count: result.diagnostics.length, + plugins: result.prepared.map((plugin) => plugin.spec), + duration_ms: Math.round(performance.now() - startedAt), + }) + return result + }) + .catch((error) => { + logError("plugin.prepare.failed", error, { + configuration_fingerprint: input.configurationFingerprint, + plugin_count: input.declarations.length, + plugins: input.declarations.map(pluginDeclarationSpec), + duration_ms: Math.round(performance.now() - startedAt), + }) + throw error + }) + this.#preparations.set(key, operation) + return operation + } + + async callHook(input: { instanceID: string; name: string; input: WireValue; output: WireValue }) { + const instance = this.#instance(input.instanceID) + const hookInput = cloneWireValue(input.input, "input") + const hookOutput = cloneWireValue(input.output, "output") + + for (const retained of instance.hooks) { + const hook = Reflect.get(retained.hooks, input.name) + if (typeof hook !== "function") continue + try { + await Promise.resolve(hook(hookInput, hookOutput)) + } catch (error) { + throw pluginError(retained.plugin, input.name, error) + } + } + + return { + input: cloneWireValue(hookInput, "input"), + output: cloneWireValue(hookOutput, "output"), + } + } + + emitEvent(input: { instanceID: string; event: WireValue }) { + const instance = this.#instance(input.instanceID) + const event = cloneWireValue(input.event, "event") + void (async () => { + for (const retained of instance.hooks) { + if (!retained.hooks.event) continue + try { + await retained.hooks.event({ event } as never) + } catch (error) { + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Plugin ${retained.plugin.spec} event hook failed`, + instanceID: instance.id, + plugin: retained.plugin, + operation: "event", + error: errorData(error), + }).catch(() => {}) + } + } + })() + return { accepted: true } + } + + async executeTool(input: { + instanceID: string + registrationID: string + executionID: string + args: WireValue + context: { + sessionID: string + messageID: string + agent: string + callID?: string + } + }) { + const instance = this.#instance(input.instanceID) + const registration = findRegistration(instance.tools, input.registrationID) + if (!registration) throw missingHandle("tool", input.registrationID) + if (instance.activeTools.has(input.executionID)) { + throw new ExtensionHostError(-32002, `Tool execution ${input.executionID} already exists`) + } + + const controller = new AbortController() + instance.activeTools.set(input.executionID, controller) + try { + const args = validateToolArguments(registration.definition.args, cloneWireValue(input.args, "args")) + const result = await registration.definition.execute(args as never, { + ...input.context, + directory: instance.directory, + worktree: instance.worktree, + abort: controller.signal, + metadata: (metadata) => { + const pending = this.#rpc.notify("backend.tool.metadata", { + instanceID: instance.id, + executionID: input.executionID, + ...(cloneWireValue(metadata, "metadata") as Record), + }) + if (pending) void pending.catch(() => {}) + }, + ask: async (request) => { + await this.#rpc.request( + "backend.tool.ask", + { + instanceID: instance.id, + executionID: input.executionID, + ...(cloneWireValue(request, "request") as Record), + }, + { signal: controller.signal }, + ) + }, + }) + return cloneWireValue(result, "result") + } catch (error) { + throw pluginError(registration.plugin, `tool:${registration.id}`, error) + } finally { + instance.activeTools.delete(input.executionID) + } + } + + cancelTool(input: { instanceID: string; executionID: string; reason?: string }) { + const controller = this.#instance(input.instanceID).activeTools.get(input.executionID) + if (!controller) return { cancelled: false } + controller.abort(input.reason) + return { cancelled: true } + } + + evaluateAuthPrompt(input: { + instanceID: string + provider: string + methodIndex: number + promptIndex: number + operation: "validate" | "condition" + value?: string + inputs: Record + }) { + const registration = this.#auth(input.instanceID, input.provider) + const method = registration.hook.methods[input.methodIndex] + const prompt = method?.prompts?.[input.promptIndex] + if (!method || !prompt) { + throw missingHandle("auth prompt", `${input.provider}:${input.methodIndex}:${input.promptIndex}`) + } + + if (input.operation === "validate") { + const error = + prompt.type === "text" && prompt.validate + ? prompt.validate(input.value ?? input.inputs[prompt.key] ?? "") + : undefined + return { operation: "validate" as const, ...(error ? { error } : {}) } + } + + const active = prompt.when + ? prompt.when.op === "eq" + ? input.inputs[prompt.when.key] === prompt.when.value + : input.inputs[prompt.when.key] !== prompt.when.value + : prompt.condition + ? prompt.condition(input.inputs) + : true + return { operation: "condition" as const, active } + } + + async authorize(input: { + instanceID: string + provider: string + methodIndex: number + inputs?: Record + }) { + const instance = this.#instance(input.instanceID) + const registration = this.#auth(input.instanceID, input.provider) + const method = registration.hook.methods[input.methodIndex] + if (!method) throw missingHandle("auth method", `${input.provider}:${input.methodIndex}`) + if (method.type === "api") { + const result = method.authorize ? await method.authorize(input.inputs) : undefined + return { type: "api", ...(result === undefined ? {} : { result: cloneWireValue(result, "result") }) } + } + + const result = await method.authorize(input.inputs) + const flowID = handleID(instance, "flow") + instance.flows.set(flowID, { + plugin: registration.plugin, + method: result.method, + callback: result.callback, + } as OAuthFlow) + return { + type: "oauth", + flowID, + url: result.url, + instructions: result.instructions, + method: result.method, + } + } + + async authCallback(input: { instanceID: string; flowID: string; code?: string }) { + const instance = this.#instance(input.instanceID) + const flow = instance.flows.get(input.flowID) + if (!flow) throw missingHandle("auth flow", input.flowID) + if (flow.method === "code" && input.code === undefined) { + throw new ExtensionHostError(-32602, `Auth flow ${input.flowID} requires a code`) + } + + try { + const result = await (flow.method === "code" + ? (flow.callback as (code: string) => Promise)(input.code!) + : (flow.callback as () => Promise)()) + if (result && typeof result === "object" && Reflect.get(result, "type") === "success") { + instance.flows.delete(input.flowID) + } + return cloneWireValue(result, "result") + } catch (error) { + throw pluginError(flow.plugin, "auth.callback", error) + } + } + + cancelAuthFlow(input: { instanceID: string; flowID: string }) { + return { cancelled: this.#instance(input.instanceID).flows.delete(input.flowID) } + } + + async loadAuth(input: { instanceID: string; provider: string; providerInfo: WireValue }) { + const instance = this.#instance(input.instanceID) + const registration = this.#auth(input.instanceID, input.provider) + if (!registration.hook.loader) return { value: {} } + + try { + const result = await registration.hook.loader( + async () => { + const result = await this.#rpc.request<{ auth: Auth | null }>("backend.auth.get", { + instanceID: instance.id, + providerID: input.provider, + }) + if (!result.auth) throw new Error(`No auth is available for ${input.provider}`) + return result.auth + }, + cloneWireValue(input.providerInfo, "providerInfo") as never, + ) + if (!result || typeof result !== "object" || Array.isArray(result)) { + throw new TypeError("auth.loader must return an object") + } + + const options = { ...result } + const candidate = Reflect.get(options, "fetch") + if (candidate !== undefined && typeof candidate !== "function") { + throw new TypeError("auth.loader options.fetch must be a function") + } + Reflect.deleteProperty(options, "fetch") + const plain = cloneWireValue(options, "options") + if (!candidate) return { value: plain } + + const fetchID = handleID(instance, "fetch") + instance.fetches.set(fetchID, { + plugin: registration.plugin, + provider: input.provider, + fetch: candidate as typeof fetch, + }) + return { value: plain, fetchID } + } catch (error) { + throw pluginError(registration.plugin, "auth.loader", error) + } + } + + async authFetch(input: { + instanceID: string + fetchID: string + requestID: string + request: { + url: string + method?: string + headers?: Array<[string, string]> | Record + body?: StreamDescriptor + } + }) { + const instance = this.#instance(input.instanceID) + const registration = instance.fetches.get(input.fetchID) + if (!registration) throw missingHandle("auth fetch", input.fetchID) + if (instance.activeFetches.has(input.requestID)) { + throw new ExtensionHostError(-32002, `Auth fetch ${input.requestID} already exists`) + } + + const controller = new AbortController() + const body = input.request.body ? this.#streams.remote("backend", instance.id, input.request.body) : undefined + instance.activeFetches.set(input.requestID, { controller, body, descriptor: input.request.body }) + let returnedResponse = false + try { + const init = { + method: input.request.method, + headers: input.request.headers, + body, + signal: controller.signal, + ...(body ? { duplex: "half" as const } : {}), + } as RequestInit & { duplex?: "half" } + const response = await registration.fetch(input.request.url, init) + if (!(response instanceof Response)) throw new TypeError("auth loader fetch did not return a Response") + returnedResponse = true + return { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers.entries()), + body: response.body + ? this.#streams.register(instance.id, response.body, contentLength(response.headers)) + : undefined, + } + } catch (error) { + throw pluginError(registration.plugin, "auth.fetch", error) + } finally { + instance.activeFetches.delete(input.requestID) + if (body && !body.locked) await body.cancel("Auth fetch completed").catch(() => {}) + if (input.request.body && !returnedResponse) { + await this.#streams.cancelRemote?.(instance.id, input.request.body, "Auth fetch failed").catch(() => {}) + } + } + } + + cancelAuthFetch(input: { instanceID: string; requestID: string; reason?: string }) { + const active = this.#instance(input.instanceID).activeFetches.get(input.requestID) + if (!active) return { cancelled: false } + active.controller.abort(input.reason) + void active.body?.cancel(input.reason).catch(() => {}) + if (active.descriptor) { + void this.#streams.cancelRemote?.(input.instanceID, active.descriptor, input.reason).catch(() => {}) + } + return { cancelled: true } + } + + releaseAuthFetch(input: { instanceID: string; fetchID: string }) { + return { released: this.#instance(input.instanceID).fetches.delete(input.fetchID) } + } + + async providerModels(input: { instanceID: string; providerID: string; provider: WireValue; auth?: WireValue }) { + const instance = this.#instance(input.instanceID) + const registration = instance.providers.get(input.providerID) + if (!registration?.hook.models) throw missingHandle("provider models", input.providerID) + try { + const result = await registration.hook.models(cloneWireValue(input.provider, "provider") as Provider, { + auth: input.auth === undefined ? undefined : (cloneWireValue(input.auth, "auth") as Auth), + }) + return { models: cloneWireValue(result, "models") } + } catch (error) { + throw pluginError(registration.plugin, "provider.models", error) + } + } + + async workspaceConfigure(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + return { + config: cloneWireValue( + await registration.adapter.configure(cloneWireValue(input.config, "config") as WorkspaceInfo), + "config", + ), + } + } + + async workspaceCreate(input: { + instanceID: string + registrationID: string + config: WireValue + env: Record + from?: WireValue + }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + await registration.adapter.create( + cloneWireValue(input.config, "config") as WorkspaceInfo, + Object.fromEntries(Object.entries(input.env).map(([key, value]) => [key, value ?? undefined])), + input.from === undefined ? undefined : (cloneWireValue(input.from, "from") as WorkspaceInfo), + ) + return {} + } + + async workspaceRemove(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + await registration.adapter.remove(cloneWireValue(input.config, "config") as WorkspaceInfo) + return {} + } + + async workspaceTarget(input: { instanceID: string; registrationID: string; config: WireValue }) { + const registration = this.#workspace(input.instanceID, input.registrationID) + return { + target: normalizeWorkspaceTarget( + await registration.adapter.target(cloneWireValue(input.config, "config") as WorkspaceInfo), + ), + } + } + + #assertAccepting() { + if (this.#status === "running") return + throw new ExtensionHostError(-32004, "Extension host is shutting down", { kind: "host_shutting_down" }) + } + + #assertOpening(instance: Instance) { + if (this.#status === "running" && instance.status === "opening") return + throw new ExtensionHostError(-32004, `Instance ${instance.id} is closing`, { + kind: "instance_closing", + instanceID: instance.id, + }) + } + + #beginClose(instance: Instance) { + if (instance.closePromise) return instance.closePromise + instance.status = "closing" + instance.closePromise = this.#disposeInstance(instance) + return instance.closePromise + } + + async #disposeInstance(instance: Instance) { + for (const controller of instance.activeTools.values()) controller.abort("Instance closed") + for (const active of instance.activeFetches.values()) { + active.controller.abort("Instance closed") + void active.body?.cancel("Instance closed").catch(() => {}) + if (active.descriptor) { + void this.#streams.cancelRemote?.(instance.id, active.descriptor, "Instance closed").catch(() => {}) + } + } + instance.activeTools.clear() + instance.activeFetches.clear() + await this.#streams.cancelAll(instance.id) + await instance.gateway.close().catch(() => {}) + await instance.openDone + instance.flows.clear() + instance.fetches.clear() + + for (const retained of instance.hooks) { + if (instance.disposed.has(retained)) continue + instance.disposed.add(retained) + if (!retained.hooks.dispose) continue + try { + await Promise.resolve(retained.hooks.dispose()) + } catch (error) { + await publishDiagnostic(this.#rpc, { + level: "error", + message: `Plugin ${retained.plugin.spec} dispose hook failed`, + instanceID: instance.id, + plugin: retained.plugin, + operation: "dispose", + error: errorData(error), + }).catch(() => {}) + } + } + + if (this.#instances.get(instance.id) === instance) this.#instances.delete(instance.id) + if (this.#directories.get(instance.canonicalDirectory) === instance.id) { + this.#directories.delete(instance.canonicalDirectory) + } + } + + #instance(instanceID: string) { + const instance = this.#instances.get(instanceID) + if (!instance || instance.status !== "open") throw missingHandle("instance", instanceID) + return instance + } + + #auth(instanceID: string, provider: string) { + const registration = this.#instance(instanceID).auth.get(provider) + if (!registration) throw missingHandle("auth provider", provider) + return registration + } + + #workspace(instanceID: string, registrationID: string) { + const registration = findRegistration(this.#instance(instanceID).workspaces, registrationID) + if (!registration) throw missingHandle("workspace", registrationID) + return registration + } + + async #startPlugin( + instance: Instance, + loaded: LoadedPlugin, + project: WireValue, + client: ReturnType, + diagnostics: HostDiagnostic[], + ) { + for (const entrypoint of loaded.entrypoints) { + this.#assertOpening(instance) + const plugin: PluginMeta = { + ...(entrypoint.id ? { id: entrypoint.id } : {}), + spec: loaded.spec, + entry: loaded.entry, + index: entrypoint.index, + } + const workspaces = new Map() + const pluginInput: PluginInput = { + client, + project: cloneWireValue(project, "project") as never, + directory: instance.directory, + worktree: instance.worktree, + serverUrl: instance.gateway.url, + $: this.#shell, + experimental_workspace: { + register: (type, adapter) => { + workspaces.set(type, { + registrationID: handleID(instance, "workspace"), + plugin, + type, + adapter, + }) + }, + }, + } + + try { + const hooks = await entrypoint.server(pluginInput, loaded.options as PluginOptions | undefined) + if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) { + throw new TypeError("Plugin entrypoint did not return a Hooks object") + } + instance.hooks.push({ plugin, hooks }) + logEvent("plugin.activation.completed", { + instance_id: instance.id, + plugin: plugin.spec, + plugin_id: plugin.id, + entrypoint_index: plugin.index, + }) + this.#assertOpening(instance) + for (const [type, registration] of workspaces) instance.workspaces.set(type, registration) + } catch (error) { + if (instance.status === "closing" || this.#status !== "running") throw error + const diagnostic = runtimeDiagnostic(plugin, "entrypoint", error) + diagnostics.push(diagnostic) + await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) + } + } + } + + #indexRegistrations(instance: Instance, diagnostics: HostDiagnostic[]) { + for (const retained of instance.hooks) { + for (const [id, definition] of Object.entries(retained.hooks.tool ?? {})) { + try { + instance.tools.set(id, { + registrationID: handleID(instance, "tool"), + plugin: retained.plugin, + id, + definition, + parameters: cloneWireValue(toolParametersToJsonSchema(definition.args), `tool.${id}.parameters`), + }) + } catch (error) { + diagnostics.push(runtimeDiagnostic(retained.plugin, `tool:${id}`, error)) + } + } + if (retained.hooks.auth) { + instance.auth.set(retained.hooks.auth.provider, { plugin: retained.plugin, hook: retained.hooks.auth }) + } + if (retained.hooks.provider) { + instance.providers.set(retained.hooks.provider.id, { plugin: retained.plugin, hook: retained.hooks.provider }) + } + } + } +} + +function openResult(instance: Instance, config: WireValue, diagnostics: HostDiagnostic[]) { + return { + instanceID: instance.id, + config: cloneWireValue(config, "config"), + diagnostics: diagnostics.map(protocolDiagnostic), + gatewayURL: instance.gateway.url.toString(), + hooks: GENERIC_HOOKS.filter((name) => + instance.hooks.some((retained) => typeof retained.hooks[name] === "function"), + ), + tools: Array.from(instance.tools.values(), ({ registrationID, id, plugin, definition, parameters }) => ({ + registrationID, + id, + plugin, + description: definition.description, + parameters, + })), + auth: Array.from(instance.auth.entries(), ([provider, registration]) => authDescriptor(provider, registration)), + providers: Array.from(instance.providers.entries(), ([provider, registration]) => ({ + provider, + plugin: registration.plugin, + hasModels: typeof registration.hook.models === "function", + })), + workspaces: Array.from(instance.workspaces.values(), ({ registrationID, type, plugin, adapter }) => ({ + registrationID, + type, + plugin, + name: adapter.name, + description: adapter.description, + })), + } +} + +function authDescriptor(provider: string, registration: AuthRegistration) { + return { + provider, + plugin: registration.plugin, + hasLoader: typeof registration.hook.loader === "function", + methods: registration.hook.methods.map((method, methodIndex) => ({ + type: method.type, + label: method.label, + methodIndex, + hasAuthorize: typeof method.authorize === "function", + prompts: + method.prompts?.map((prompt, promptIndex) => ({ + type: prompt.type, + key: prompt.key, + message: prompt.message, + promptIndex, + placeholder: prompt.type === "text" ? prompt.placeholder : undefined, + options: prompt.type === "select" ? prompt.options : undefined, + when: prompt.when, + hasValidate: prompt.type === "text" && typeof prompt.validate === "function", + hasCondition: typeof prompt.condition === "function", + })) ?? [], + })), + } +} + +function normalizeWorkspaceTarget(target: WorkspaceTarget) { + if (target.type === "local") return { type: "local", directory: target.directory } + return { + type: "remote", + url: target.url.toString(), + headers: target.headers ? Array.from(new Headers(target.headers).entries()) : undefined, + } +} + +function handleID(instance: Instance, type: string) { + instance.counter += 1 + return `${instance.id}:${type}:${instance.counter}` +} + +function pluginDeclarationSpec(declaration: PluginDeclaration) { + if (typeof declaration === "string") return declaration + if (Array.isArray(declaration)) return declaration[0] + return declaration.spec +} + +function preparationKey(input: { + declarations: readonly PluginDeclaration[] + defaultBaseDirectory?: string + configurationFingerprint?: string +}) { + const needsDefaultBaseDirectory = input.declarations.some( + ({ spec, baseDirectory }) => !baseDirectory && (spec.startsWith(".") || spec.startsWith("file:")), + ) + return JSON.stringify({ + configurationFingerprint: input.configurationFingerprint, + declarations: input.declarations, + ...(needsDefaultBaseDirectory ? { defaultBaseDirectory: input.defaultBaseDirectory } : {}), + }) +} + +function findRegistration(map: Map, registrationID: string) { + return Array.from(map.values()).find((registration) => registration.registrationID === registrationID) +} + +function missingHandle(type: string, id: string) { + return new ExtensionHostError(-32002, `Unknown ${type} ${id}`, { kind: "missing_handle", type, id }) +} + +function pluginError(plugin: PluginMeta, operation: string, error: unknown) { + return new ExtensionHostError(-32003, `Plugin ${plugin.spec} failed during ${operation}: ${errorMessage(error)}`, { + kind: "plugin_error", + plugin, + operation, + error: errorData(error), + }) +} + +function runtimeDiagnostic(plugin: PluginMeta, operation: string, error: unknown): RuntimeDiagnostic { + return { + level: "error", + stage: "runtime", + spec: plugin.spec, + pluginID: plugin.id, + message: `Plugin ${plugin.spec} failed during ${operation}: ${errorMessage(error)}`, + error: errorData(error), + } +} + +function toPublishedDiagnostic(instanceID: string, diagnostic: HostDiagnostic) { + return { + level: diagnostic.level, + message: diagnostic.message, + instanceID, + plugin: { id: "pluginID" in diagnostic ? diagnostic.pluginID : undefined, spec: diagnostic.spec }, + operation: diagnostic.stage, + error: diagnostic.error, + } +} + +function protocolDiagnostic(diagnostic: HostDiagnostic) { + const pluginID = "pluginID" in diagnostic ? diagnostic.pluginID : undefined + return { + severity: "error" as const, + code: diagnostic.stage, + message: diagnostic.message, + ...(pluginID ? { plugin: pluginID } : {}), + method: diagnostic.stage, + data: { + spec: diagnostic.spec, + ...(diagnostic.error ? { error: serializableDiagnosticError(diagnostic.error) } : {}), + ...(diagnostic.stage === "runtime" ? {} : { declarationIndex: diagnostic.declarationIndex }), + }, + } +} + +function serializableDiagnosticError(error: NonNullable) { + return { + ...(error.name ? { name: error.name } : {}), + message: error.message, + ...(error.stack ? { stack: error.stack } : {}), + ...(error.cause === undefined ? {} : { cause: String(error.cause) }), + } +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +function contentLength(headers: Headers) { + const value = Number(headers.get("content-length")) + return Number.isSafeInteger(value) && value >= 0 ? value : undefined +} diff --git a/src/apps/extension-host/src/loader.ts b/src/apps/extension-host/src/loader.ts new file mode 100644 index 000000000..0a92ae456 --- /dev/null +++ b/src/apps/extension-host/src/loader.ts @@ -0,0 +1,620 @@ +import { readFile, realpath, stat } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" +import type { Plugin, PluginOptions } from "@opencode-ai/plugin" +import npmPackageArg from "npm-package-arg" +import semver from "semver" + +export const OPENCODE_COMPATIBILITY_VERSION = "1.17.18" + +const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"] +export type PluginDeclaration = { + spec: string + options?: PluginOptions + baseDirectory?: string +} + +export type PluginDeclarationInput = PluginDeclaration | string | readonly [string, PluginOptions?] + +export type PluginSource = "file" | "npm" +export type PluginCacheStatus = "hit" | "installed" | "validated" + +export type NormalizedPluginDeclaration = { + declarationIndex: number + spec: string + resolvedSpec: string + identity: string + source: PluginSource + packageName?: string + options?: PluginOptions + baseDirectory: string +} + +export type PluginPackage = { + directory: string + manifestPath: string + manifest: Record +} + +export type LoadedServerEntrypoint = { + id?: string + server: Plugin + index: number +} + +export type LoadedPlugin = NormalizedPluginDeclaration & { + target: string + entry: string + package?: PluginPackage + module: Record + entrypoints: LoadedServerEntrypoint[] +} + +export type PreparedPlugin = NormalizedPluginDeclaration & { + target: string + entry: string + cache: PluginCacheStatus + package?: PluginPackage +} + +export type LoaderDiagnosticStage = "declaration" | "resolve" | "install" | "entry" | "compatibility" | "load" | "shape" + +export type LoaderError = { + name?: string + message: string + stack?: string + cause?: string | LoaderError +} + +export type LoaderDiagnostic = { + level: "error" + declarationIndex: number + spec: string + stage: LoaderDiagnosticStage + message: string + error?: LoaderError +} + +export type NpmInstaller = (input: { + spec: string + packageName?: string + cacheDirectory: string +}) => Promise }> + +export type LoadPluginsInput = { + declarations: readonly PluginDeclarationInput[] + cacheDirectory: string + defaultBaseDirectory?: string + compatibilityVersion?: string + install?: NpmInstaller + readJson?: (file: string) => Promise> + satisfies?: (version: string, range: string) => boolean +} + +export type LoadPluginsResult = { + loaded: LoadedPlugin[] + diagnostics: LoaderDiagnostic[] +} + +export type PreparePluginsResult = { + prepared: PreparedPlugin[] + diagnostics: LoaderDiagnostic[] +} + +type PrepareCandidateResult = { prepared: PreparedPlugin } | { diagnostic: LoaderDiagnostic } +type LoadCandidateResult = { loaded: LoadedPlugin } | { diagnostic: LoaderDiagnostic } + +/** + * Resolve and import all surviving declarations concurrently. The returned + * entrypoints are intentionally not invoked here; callers execute them in the + * returned order to keep plugin initialization deterministic. + */ +export async function loadPlugins(input: LoadPluginsInput): Promise { + return loadPreparedPlugins(await preparePlugins(input)) +} + +export async function preparePlugins(input: LoadPluginsInput): Promise { + const normalized = await normalizePluginDeclarations(input.declarations, input.defaultBaseDirectory) + const readJson = input.readJson ?? readNodeJson + const satisfies = input.satisfies ?? ((version, range) => semver.satisfies(version, range)) + const install = input.install ?? unavailableInstaller + const results = await Promise.all( + normalized.declarations.map((declaration) => + prepareCandidate( + declaration, + input.cacheDirectory, + input.compatibilityVersion ?? OPENCODE_COMPATIBILITY_VERSION, + install, + readJson, + satisfies, + ), + ), + ) + + return { + prepared: results.flatMap((result) => ("prepared" in result ? [result.prepared] : [])), + diagnostics: [ + ...normalized.diagnostics, + ...results.flatMap((result) => ("diagnostic" in result ? [result.diagnostic] : [])), + ].sort((a, b) => a.declarationIndex - b.declarationIndex), + } +} + +export async function loadPreparedPlugins(input: PreparePluginsResult): Promise { + const results = await Promise.all(input.prepared.map(loadPreparedCandidate)) + return { + loaded: results.flatMap((result) => ("loaded" in result ? [result.loaded] : [])), + diagnostics: [ + ...input.diagnostics, + ...results.flatMap((result) => ("diagnostic" in result ? [result.diagnostic] : [])), + ].sort((a, b) => a.declarationIndex - b.declarationIndex), + } +} + +export const loadServerPlugins = loadPlugins + +export async function normalizePluginDeclarations( + declarations: readonly PluginDeclarationInput[], + defaultBaseDirectory = process.cwd(), +) { + const results = await Promise.all( + declarations.map(async (declaration, declarationIndex) => { + try { + return { + declaration: await normalizeDeclaration(declaration, declarationIndex, defaultBaseDirectory), + } + } catch (error) { + return { + diagnostic: makeDiagnostic(declarationIndex, declarationSpec(declaration), "declaration", error), + } + } + }), + ) + const seen = new Set() + const deduplicated: NormalizedPluginDeclaration[] = [] + + for (const result of results.toReversed()) { + if (!result.declaration) continue + if (seen.has(result.declaration.identity)) continue + seen.add(result.declaration.identity) + deduplicated.push(result.declaration) + } + + return { + declarations: deduplicated.toReversed(), + diagnostics: results.flatMap((result) => (result.diagnostic ? [result.diagnostic] : [])), + } +} + +export function extractServerEntrypoints(input: { + module: Record + spec: string + source: PluginSource + package?: PluginPackage +}): LoadedServerEntrypoint[] { + const preferred = preferredServerEntrypoint(input) + if (preferred) return [{ ...preferred, index: 0 }] + + const seen = new Set() + const result: LoadedServerEntrypoint[] = [] + + for (const value of Object.values(input.module)) { + if (seen.has(value)) continue + seen.add(value) + const server = serverFunction(value) + if (!server) throw new TypeError(`Plugin ${input.spec} export is not a function`) + result.push({ + ...(legacyPluginID(value) ? { id: legacyPluginID(value) } : {}), + server, + index: result.length, + }) + } + + if (!result.length) throw new TypeError(`Plugin ${input.spec} module is empty`) + return result +} + +export function parseNpmPluginSpecifier(spec: string, baseDirectory = process.cwd()) { + const parsed = npmPackageArg(spec, baseDirectory) + const packageName = parsed.name ?? undefined + const canonical = parsed.saveSpec ?? parsed.fetchSpec ?? parsed.raw + const installSpec = + parsed.type === "directory" || parsed.type === "file" + ? `file:${parsed.fetchSpec}` + : parsed.registry && parsed.raw === parsed.name + ? `${parsed.name}@latest` + : spec + return { + packageName, + identity: packageName ?? String(canonical), + installSpec, + type: parsed.type, + } +} + +async function prepareCandidate( + declaration: NormalizedPluginDeclaration, + cacheDirectory: string, + compatibilityVersion: string, + install: NpmInstaller, + readJson: (file: string) => Promise>, + satisfies: (version: string, range: string) => boolean, +): Promise { + let target: string + let cache: PluginCacheStatus + try { + if (declaration.source === "file") { + target = await resolveFileTarget(declaration.resolvedSpec) + cache = "validated" + } else { + const installed = await install({ + spec: parseNpmPluginSpecifier(declaration.spec, declaration.baseDirectory).installSpec, + packageName: declaration.packageName, + cacheDirectory, + }) + target = typeof installed === "string" ? installed : installed.target + cache = typeof installed === "string" ? "installed" : installed.cache + } + } catch (error) { + return { + diagnostic: makeDiagnostic( + declaration.declarationIndex, + declaration.spec, + declaration.source === "file" ? "resolve" : "install", + error, + ), + } + } + + let pkg: PluginPackage | undefined + let entry: string | undefined + try { + pkg = await readPluginPackage(target, declaration.source === "npm", readJson) + entry = await resolveServerEntrypoint(declaration.spec, declaration.source, target, pkg) + if (!entry) throw new Error(`Plugin ${declaration.spec} does not expose a server entrypoint`) + } catch (error) { + return { + diagnostic: makeDiagnostic(declaration.declarationIndex, declaration.spec, "entry", error), + } + } + + if (declaration.source === "npm" && pkg) { + try { + checkCompatibility(declaration.spec, pkg, compatibilityVersion, satisfies) + } catch (error) { + return { + diagnostic: makeDiagnostic(declaration.declarationIndex, declaration.spec, "compatibility", error), + } + } + } + + return { + prepared: { + ...declaration, + target, + entry, + cache, + package: pkg, + }, + } +} + +async function loadPreparedCandidate(plugin: PreparedPlugin): Promise { + let module: Record + try { + const imported = await import(plugin.entry) + if (!isRecord(imported)) throw new Error(`Plugin ${plugin.spec} module is empty`) + module = imported + } catch (error) { + return { + diagnostic: makeDiagnostic(plugin.declarationIndex, plugin.spec, "load", error), + } + } + + try { + return { + loaded: { + ...plugin, + module, + entrypoints: extractServerEntrypoints({ + module, + spec: plugin.spec, + source: plugin.source, + package: plugin.package, + }), + }, + } + } catch (error) { + return { + diagnostic: makeDiagnostic(plugin.declarationIndex, plugin.spec, "shape", error), + } + } +} + +async function normalizeDeclaration( + input: PluginDeclarationInput, + declarationIndex: number, + defaultBaseDirectory: string, +): Promise { + const declaration = declarationObject(input) + if (typeof declaration.spec !== "string" || !declaration.spec.trim()) { + throw new TypeError("Plugin declaration spec must be a non-empty string") + } + if (declaration.options !== undefined && !isRecord(declaration.options)) { + throw new TypeError("Plugin declaration options must be an object") + } + if (declaration.baseDirectory !== undefined && typeof declaration.baseDirectory !== "string") { + throw new TypeError("Plugin declaration baseDirectory must be a string") + } + + const spec = declaration.spec.trim() + const baseDirectory = path.resolve(declaration.baseDirectory ?? defaultBaseDirectory) + const source = pluginSource(spec) + if (source === "npm") { + const parsed = parseNpmPluginSpecifier(spec, baseDirectory) + return { + declarationIndex, + spec, + resolvedSpec: spec, + identity: `npm:${parsed.identity}`, + source, + packageName: parsed.packageName, + options: declaration.options, + baseDirectory, + } + } + + const file = spec.startsWith("file://") + ? fileURLToPath(spec) + : path.isAbsolute(spec) || isWindowsAbsolutePath(spec) + ? spec + : path.resolve(baseDirectory, spec) + const canonical = await realpath(file).catch(() => path.resolve(file)) + const resolvedSpec = pathToFileURL(canonical).href + return { + declarationIndex, + spec, + resolvedSpec, + identity: `file:${resolvedSpec}`, + source, + options: declaration.options, + baseDirectory, + } +} + +function declarationObject(input: PluginDeclarationInput): PluginDeclaration { + if (typeof input === "string") return { spec: input } + if (Array.isArray(input)) return { spec: input[0], options: input[1] } + if (isRecord(input)) return input as PluginDeclaration + throw new TypeError("Plugin declaration must be a string, tuple, or object") +} + +function declarationSpec(input: PluginDeclarationInput) { + if (typeof input === "string") return input + if (Array.isArray(input)) return typeof input[0] === "string" ? input[0] : "" + if (isRecord(input) && typeof input.spec === "string") return input.spec + return "" +} + +function pluginSource(spec: string): PluginSource { + if (spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || isWindowsAbsolutePath(spec)) { + return "file" + } + return "npm" +} + +async function resolveFileTarget(spec: string) { + const file = fileURLToPath(spec) + const info = await stat(file) + if (!info.isDirectory()) return realpath(file) + if (await exists(path.join(file, "package.json"))) return realpath(file) + + const index = await resolveDirectoryIndex(file) + if (index) return index + throw new Error(`Plugin directory ${file} is missing package.json or index file`) +} + +async function readPluginPackage( + target: string, + required: boolean, + readJson: (file: string) => Promise>, +): Promise { + const info = await stat(target) + const directory = info.isDirectory() ? target : path.dirname(target) + const manifestPath = path.join(directory, "package.json") + if (!(await exists(manifestPath))) { + if (required) throw new Error(`Plugin package ${directory} is missing package.json`) + return + } + + return { + directory: await realpath(directory), + manifestPath, + manifest: await readJson(manifestPath), + } +} + +async function resolveServerEntrypoint(spec: string, source: PluginSource, target: string, pkg?: PluginPackage) { + if (pkg) { + const exports = pkg.manifest.exports + if (isRecord(exports)) { + const server = extractExportValue(exports["./server"]) + if (server) return resolvePackageEntry(spec, server, "server", pkg) + } + + const main = typeof pkg.manifest.main === "string" ? pkg.manifest.main.trim() : "" + if (main) return resolvePackageEntry(spec, main, "main", pkg) + } + + const info = await stat(target) + if (!info.isDirectory()) return pathToFileURL(await realpath(target)).href + if (source === "npm") return + + const index = await resolveDirectoryIndex(target) + return index ? pathToFileURL(index).href : undefined +} + +async function resolvePackageEntry(spec: string, raw: string, kind: string, pkg: PluginPackage) { + const file = raw.startsWith("file://") + ? fileURLToPath(raw) + : path.isAbsolute(raw) || isWindowsAbsolutePath(raw) + ? raw + : path.resolve(pkg.directory, raw) + if (!contains(pkg.directory, path.resolve(file))) { + throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + } + const [root, entry] = await Promise.all([realpath(pkg.directory), realpath(file)]) + if (!contains(root, entry)) throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) + return pathToFileURL(entry).href +} + +function extractExportValue(value: unknown): string | undefined { + if (typeof value === "string") return value + if (!isRecord(value)) return + if (typeof value.import === "string") return value.import + if (typeof value.default === "string") return value.default +} + +async function resolveDirectoryIndex(directory: string) { + for (const name of INDEX_FILES) { + const file = path.join(directory, name) + if (await exists(file)) return realpath(file) + } +} + +function checkCompatibility( + spec: string, + pkg: PluginPackage, + version: string, + satisfies: (version: string, range: string) => boolean, +) { + const engines = pkg.manifest.engines + if (!isRecord(engines) || typeof engines.opencode !== "string") return + if (satisfies(version, engines.opencode)) return + throw new Error(`Plugin ${spec} requires opencode ${engines.opencode} but running ${version}`) +} + +function preferredServerEntrypoint(input: { + module: Record + spec: string + source: PluginSource + package?: PluginPackage +}) { + const value = input.module.default + if (!isRecord(value)) return + if (!("id" in value) && !("server" in value) && !("tui" in value)) return + + if (value.server !== undefined && typeof value.server !== "function") { + throw new TypeError(`Plugin ${input.spec} has invalid server export`) + } + if (value.tui !== undefined && typeof value.tui !== "function") { + throw new TypeError(`Plugin ${input.spec} has invalid tui export`) + } + if (value.server !== undefined && value.tui !== undefined) { + throw new TypeError(`Plugin ${input.spec} must default export either server() or tui(), not both`) + } + if (value.server === undefined) { + throw new TypeError(`Plugin ${input.spec} must default export an object with server()`) + } + + const declaredID = readPluginID(value.id, input.spec) + if (input.source === "file" && !declaredID) { + throw new TypeError(`Path plugin ${input.spec} must export id`) + } + const packageID = input.source === "npm" && !declaredID ? packageName(input.package, input.spec) : undefined + return { + id: declaredID ?? packageID, + server: value.server as Plugin, + } +} + +function readPluginID(value: unknown, spec: string) { + if (value === undefined) return + if (typeof value !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof value}`) + const id = value.trim() + if (!id) throw new TypeError(`Plugin ${spec} has an empty id`) + return id +} + +function packageName(pkg: PluginPackage | undefined, spec: string) { + const name = pkg?.manifest.name + if (typeof name !== "string" || !name.trim()) { + throw new TypeError(`Plugin package for ${spec} is missing name`) + } + return name.trim() +} + +function serverFunction(value: unknown): Plugin | undefined { + if (typeof value === "function") return value as Plugin + if (!isRecord(value) || typeof value.server !== "function") return + return value.server as Plugin +} + +function legacyPluginID(value: unknown) { + if (!isRecord(value) || typeof value.id !== "string") return + const id = value.id.trim() + return id || undefined +} + +function packageSlug(name: string) { + const slug = name.replaceAll(/[^A-Za-z0-9._-]/g, "-").replaceAll(/^-+|-+$/g, "") + return slug || "plugin" +} + +function contains(root: string, file: string) { + const relative = path.relative(root, file) + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) +} + +function isWindowsAbsolutePath(value: string) { + return /^[A-Za-z]:[\\/]/.test(value) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +async function exists(file: string) { + return stat(file) + .then(() => true) + .catch(() => false) +} + +async function readNodeJson(file: string) { + const value: unknown = JSON.parse(await readFile(file, "utf8")) + if (!isRecord(value)) throw new TypeError(`${file} must contain a JSON object`) + return value +} + +function unavailableInstaller(input: Parameters[0]): Promise { + return Promise.reject(new Error(`No installer is configured for plugin ${input.spec}`)) +} + +function makeDiagnostic( + declarationIndex: number, + spec: string, + stage: LoaderDiagnosticStage, + error: unknown, +): LoaderDiagnostic { + const detail = errorInfo(error) + return { + level: "error", + declarationIndex, + spec, + stage, + message: detail.message, + error: detail, + } +} + +function errorInfo(error: unknown): LoaderError { + if (!(error instanceof Error)) return { message: String(error) } + return { + name: error.name, + message: error.message, + stack: error.stack, + ...(error.cause === undefined + ? {} + : { cause: error.cause instanceof Error ? errorInfo(error.cause) : String(error.cause) }), + } +} diff --git a/src/apps/extension-host/src/log.ts b/src/apps/extension-host/src/log.ts new file mode 100644 index 000000000..a96d5051d --- /dev/null +++ b/src/apps/extension-host/src/log.ts @@ -0,0 +1,75 @@ +export const LOG_LEVELS = ["trace", "debug", "info", "warn", "error", "off"] as const +export type LogLevel = (typeof LOG_LEVELS)[number] + +const DEFAULT_LOG_LEVEL: LogLevel = "debug" +const LOG_LEVEL_RANK: Readonly> = { + trace: 0, + debug: 1, + info: 2, + warn: 3, + error: 4, + off: 5, +} + +let currentLogLevel = parseLogLevel(process.env.OPENCODE_EXTENSION_HOST_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL + +export function setLogLevel(level: string): LogLevel { + const parsed = parseLogLevel(level) + if (!parsed) throw new TypeError(`Invalid extension host log level: ${level}`) + currentLogLevel = parsed + return currentLogLevel +} + +export function getLogLevel(): LogLevel { + return currentLogLevel +} + +export function logEvent(event: string, fields: Record = {}, level: LogLevel = "info") { + if (!shouldLog(level)) return + const record = { + timestamp: new Date().toISOString(), + level, + event, + ...fields, + } + console.error(`[extension-host] ${JSON.stringify(record)}`) +} + +function parseLogLevel(value: string | undefined): LogLevel | undefined { + if (!value) return undefined + return LOG_LEVELS.find((candidate) => candidate === value.trim().toLowerCase()) +} + +function shouldLog(level: LogLevel): boolean { + return currentLogLevel !== "off" && LOG_LEVEL_RANK[level] >= LOG_LEVEL_RANK[currentLogLevel] +} + +export function logError(event: string, error: unknown, fields: Record = {}) { + const value = error instanceof Error ? error : new Error(String(error)) + logEvent( + event, + { + ...fields, + error_name: value.name, + error_message: value.message, + }, + "error", + ) +} + +export function rpcMessageSummary(message: unknown) { + if (!message || typeof message !== "object") return { kind: "invalid" } + const value = message as Record + const id = typeof value.id === "string" ? { request_id: value.id } : {} + if (typeof value.method === "string") { + return { + ...id, + kind: value.id === undefined ? "notification" : "request", + method: value.method, + } + } + return { + ...id, + kind: "error" in value ? "error_response" : "response", + } +} diff --git a/src/apps/extension-host/src/main.ts b/src/apps/extension-host/src/main.ts new file mode 100644 index 000000000..8a30d4b6d --- /dev/null +++ b/src/apps/extension-host/src/main.ts @@ -0,0 +1,210 @@ +import path from "node:path" +import type { RpcConnection, StreamBridge } from "./backend" +import { ExtensionHostError } from "./errors" +import { ExtensionHost } from "./host" +import { logError, logEvent } from "./log" +import { createGateway } from "./gateway" +import { prepareBunPlugins } from "./bun-loader" +import { + BackendMethodSchemas, + DEFAULT_MAX_FRAME_BYTES, + OPENCODE_VERSION, + PROTOCOL_VERSION, + HostMethodSchemas, + type BackendMethod, +} from "./protocol" +import { connectRpcPeer, parseRpcAddress } from "./rpc" +import { registerHostMethods } from "./service" +import { remoteReadable, StreamRegistry } from "./streams" + +await main() + +async function main() { + logEvent("startup.begin", { runtime: "bun" }) + configureLoopbackProxyBypass() + const address = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_ADDRESS") + const token = requiredEnvironment("OPENCODE_EXTENSION_HOST_RPC_TOKEN") + requireLoopbackAddress(address) + const peer = await connectRpcPeer(address, { + idPrefix: "host", + onError(error) { + logError("rpc.failure", error, { runtime: "bun" }) + }, + }) + logEvent("startup.rpc_connected", { runtime: "bun", address }) + const backend = protocolConnection(peer) + const registry = new StreamRegistry("host") + const owners = new Map() + const deferred = Promise.withResolvers() + void deferred.promise.catch(() => {}) + const streams: StreamBridge = { + register(instanceID, stream, length) { + const descriptor = registry.register(stream, length) + owners.set(descriptor.streamID, instanceID) + return descriptor + }, + remote(methodPrefix, instanceID, descriptor) { + return remoteReadable(backend, methodPrefix, descriptor, { instanceID }) + }, + async cancel(instanceID, descriptor) { + if (owners.get(descriptor.streamID) !== instanceID) return + owners.delete(descriptor.streamID) + await registry.cancel({ streamID: descriptor.streamID, reason: "Stream owner released it" }) + }, + async cancelAll(instanceID) { + await Promise.all( + Array.from(owners, ([streamID, owner]) => { + if (owner !== instanceID) return Promise.resolve() + owners.delete(streamID) + return registry.cancel({ streamID, reason: `Instance ${instanceID} closed` }).then(() => {}) + }), + ) + }, + async cancelRemote(instanceID, descriptor, reason) { + await backend.request("backend.stream.cancel", { + instanceID, + streamID: descriptor.streamID, + ...(reason ? { reason } : {}), + }) + }, + } + + registerStreamMethods(peer, registry, owners) + registerHostMethods({ + peer, + host: deferred.promise, + shutdown() { + void peer.flushAndClose().catch((error) => logError("shutdown.rpc_close_failed", error, { runtime: "bun" })) + }, + }) + + let host: ExtensionHost | undefined + try { + const handshake = BackendMethodSchemas["backend.handshake"].result.parse( + await backend.request("backend.handshake", { + token, + protocolVersion: PROTOCOL_VERSION, + opencodeVersion: OPENCODE_VERSION, + maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + }), + ) + if (!path.isAbsolute(handshake.cacheDirectory)) { + throw new ExtensionHostError(-32001, "backend.handshake returned a relative cacheDirectory", { + kind: "invalid_handshake", + cacheDirectory: handshake.cacheDirectory, + }) + } + peer.setMaxFrameBytes(handshake.maxFrameBytes) + logEvent("startup.handshake_complete", { + runtime: "bun", + max_frame_bytes: handshake.maxFrameBytes, + }) + host = new ExtensionHost({ + rpc: backend, + streams, + cacheDirectory: handshake.cacheDirectory, + gatewayFactory: createGateway, + preparePlugins: prepareBunPlugins, + shell: Bun.$, + }) + deferred.resolve(host) + logEvent("startup.ready", { runtime: "bun" }) + await peer.closed + logEvent("rpc.closed", { runtime: "bun", failed: peer.closeError !== undefined }) + if (peer.closeError) throw peer.closeError + } catch (error) { + logError("startup.failed", error, { runtime: "bun" }) + deferred.reject(error) + peer.close(error instanceof Error ? error : new Error(String(error))) + throw error + } finally { + await host?.shutdown().catch((error) => logError("shutdown.failed", error, { runtime: "bun" })) + await registry.cancelAll("Extension host connection closed") + owners.clear() + logEvent("shutdown.complete", { runtime: "bun" }) + } +} + +function protocolConnection(peer: Awaited>): RpcConnection { + return { + async request(method: string, params: unknown, options?: { signal?: AbortSignal }) { + const definition = BackendMethodSchemas[method as BackendMethod] + if (!definition) throw new TypeError(`Unknown backend method ${method}`) + const validated = definition.params.parse(params) + return definition.result.parse(await peer.request(method, validated, options)) as Result + }, + notify(method: string, params: unknown) { + const definition = BackendMethodSchemas[method as BackendMethod] + if (!definition) throw new TypeError(`Unknown backend method ${method}`) + return peer.notify(method, definition.params.parse(params)) + }, + } +} + +function registerStreamMethods( + peer: Awaited>, + registry: StreamRegistry, + owners: Map, +) { + peer.handle("host.stream.read", async (value) => { + const input = parseStreamParams("host.stream.read", value) + requireStreamOwner(owners, input.instanceID, input.streamID) + const result = await registry.read(input) + if (result.eof) owners.delete(input.streamID) + return HostMethodSchemas["host.stream.read"].result.parse(result) + }) + peer.handle("host.stream.cancel", async (value) => { + const input = parseStreamParams("host.stream.cancel", value) + if (!owners.has(input.streamID)) return { cancelled: false } + requireStreamOwner(owners, input.instanceID, input.streamID) + owners.delete(input.streamID) + return HostMethodSchemas["host.stream.cancel"].result.parse(await registry.cancel(input)) + }) +} + +function parseStreamParams(method: "host.stream.read" | "host.stream.cancel", value: unknown) { + const parsed = HostMethodSchemas[method].params.safeParse(value) + if (parsed.success) return parsed.data + throw new ExtensionHostError(-32602, `Invalid parameters for ${method}`, { + kind: "invalid_params", + method, + issues: parsed.error.issues, + }) +} + +function requireStreamOwner(owners: Map, instanceID: string, streamID: string) { + if (owners.get(streamID) === instanceID) return + throw new ExtensionHostError(-32002, `Unknown stream ${streamID} for instance ${instanceID}`, { + kind: "missing_handle", + type: "stream", + id: streamID, + instanceID, + }) +} + +function requiredEnvironment(name: string) { + const value = Bun.env[name] + if (value) return value + throw new Error(`Missing required environment variable ${name}`) +} + +function requireLoopbackAddress(address: string) { + const hostname = parseRpcAddress(address).hostname.toLowerCase() + if (hostname.startsWith("127.") || hostname === "localhost" || hostname === "[::1]") return + throw new Error(`OPENCODE_EXTENSION_HOST_RPC_ADDRESS must be loopback, received ${hostname}`) +} + +function configureLoopbackProxyBypass() { + for (const name of ["NO_PROXY", "no_proxy"] as const) { + const values = new Set( + (Bun.env[name] ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + ) + values.add("127.0.0.1") + values.add("localhost") + values.add("::1") + Bun.env[name] = Array.from(values).join(",") + } +} diff --git a/src/apps/extension-host/src/protocol.ts b/src/apps/extension-host/src/protocol.ts new file mode 100644 index 000000000..735540c71 --- /dev/null +++ b/src/apps/extension-host/src/protocol.ts @@ -0,0 +1,437 @@ +import { z } from "zod" + +export const PROTOCOL_VERSION = 1 +export const OPENCODE_VERSION = "1.17.18" +export const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024 +export const MAX_MAX_FRAME_BYTES = 64 * 1024 * 1024 +export const MAX_STREAM_CHUNK_BYTES = 64 * 1024 + +export const JsonValueSchema = z.json() +export const JsonObjectSchema = z.record(z.string(), JsonValueSchema) +export const EmptyResultSchema = z.object({}).strict() +export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "off"]) +export const InstanceParamsSchema = z.object({ instanceID: z.string().min(1) }) +export const HeaderSchema = z.array(z.string()).length(2) +export const HeadersSchema = z.array(HeaderSchema) +export const StreamDescriptorSchema = z.object({ + streamID: z.string().min(1), + length: z.number().int().nonnegative().optional(), +}) +export const StreamReadParamsSchema = z.object({ + instanceID: z.string().min(1), + streamID: z.string().min(1), + maxBytes: z.number().int().positive().max(MAX_STREAM_CHUNK_BYTES).optional(), +}) +export const StreamReadResultSchema = z.object({ + data: z.string().regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/), + eof: z.boolean(), +}) +export const StreamCancelParamsSchema = StreamReadParamsSchema.omit({ maxBytes: true }).extend({ + reason: z.string().optional(), +}) +export const CancelResultSchema = z.object({ cancelled: z.boolean() }) +export const CloseResultSchema = z.object({ closed: z.boolean() }) +export const ReleaseResultSchema = z.object({ released: z.boolean() }) + +export const RpcErrorObjectSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: JsonValueSchema.optional(), +}) +export const RpcRequestSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + method: z.string().min(1), + params: JsonValueSchema.optional(), +}) +export const RpcNotificationSchema = RpcRequestSchema.omit({ id: true }) +export const RpcSuccessResponseSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + result: JsonValueSchema, +}) +export const RpcErrorResponseSchema = z.object({ + jsonrpc: z.literal("2.0"), + id: z.string().min(1), + error: RpcErrorObjectSchema, +}) +export const RpcMessageSchema = z.union([ + RpcRequestSchema, + RpcNotificationSchema, + RpcSuccessResponseSchema, + RpcErrorResponseSchema, +]) + +export const DiagnosticSchema = z.object({ + severity: z.enum(["debug", "info", "warning", "error"]), + code: z.string(), + message: z.string(), + plugin: z.string().optional(), + method: z.string().optional(), + data: JsonValueSchema.optional(), +}) +export const PluginDeclarationSchema = z.object({ + spec: z.string().min(1), + options: JsonObjectSchema.optional(), + baseDirectory: z.string().optional(), +}) +export const PluginPrepareFailureSchema = z.object({ + spec: z.string(), + stage: z.enum(["declaration", "resolve", "install", "entry", "compatibility", "load", "shape"]), + message: z.string(), +}) +export const ToolAttachmentSchema = z.object({ + type: z.literal("file"), + mime: z.string(), + url: z.string(), + filename: z.string().optional(), +}) +export const ToolResultSchema = z.union([ + z.string(), + z.object({ + title: z.string().optional(), + output: z.string(), + metadata: JsonObjectSchema.optional(), + attachments: z.array(ToolAttachmentSchema).optional(), + }), +]) +export const ToolRegistrationSchema = z.object({ + registrationID: z.string().min(1), + id: z.string().min(1), + plugin: JsonObjectSchema.optional(), + description: z.string(), + parameters: JsonValueSchema, +}) +export const AuthRuleSchema = z.object({ + key: z.string(), + op: z.enum(["eq", "neq"]), + value: z.string(), +}) +export const AuthPromptSchema = z.object({ + type: z.enum(["text", "select"]), + promptIndex: z.number().int().nonnegative(), + key: z.string(), + message: z.string(), + placeholder: z.string().optional(), + options: z.array(z.object({ label: z.string(), value: z.string(), hint: z.string().optional() })).optional(), + when: AuthRuleSchema.optional(), + hasValidate: z.boolean(), + hasCondition: z.boolean(), +}) +export const AuthRegistrationSchema = z.object({ + provider: z.string().min(1), + plugin: JsonObjectSchema.optional(), + hasLoader: z.boolean(), + methods: z.array( + z.object({ + type: z.enum(["oauth", "api"]), + label: z.string(), + methodIndex: z.number().int().nonnegative(), + hasAuthorize: z.boolean(), + prompts: z.array(AuthPromptSchema), + }), + ), +}) +export const ProviderRegistrationSchema = z.object({ + provider: z.string().min(1), + plugin: JsonObjectSchema.optional(), + hasModels: z.boolean(), +}) +export const WorkspaceRegistrationSchema = z.object({ + registrationID: z.string().min(1), + type: z.string().min(1), + plugin: JsonObjectSchema.optional(), + name: z.string(), + description: z.string(), +}) +export const AuthSuccessSchema = z.union([ + z.object({ + type: z.literal("success"), + provider: z.string().optional(), + refresh: z.string(), + access: z.string(), + expires: z.number(), + accountId: z.string().optional(), + enterpriseUrl: z.string().optional(), + }), + z.object({ + type: z.literal("success"), + provider: z.string().optional(), + key: z.string(), + metadata: z.record(z.string(), z.string()).optional(), + }), +]) +export const AuthFailedSchema = z.object({ type: z.literal("failed") }) +export const AuthFetchRequestSchema = z.object({ + url: z.string().url(), + method: z.string().min(1).optional(), + headers: HeadersSchema.optional(), + body: StreamDescriptorSchema.optional(), +}) +export const HttpResponseSchema = z.object({ + status: z.number().int().min(100).max(599), + statusText: z.string().optional(), + headers: HeadersSchema, + body: StreamDescriptorSchema.optional(), +}) + +type MethodDefinition = { params: z.ZodType; result: z.ZodType } +type MethodDefinitions = Record + +export const HostMethodSchemas = { + "host.plugins.prepare": { + params: z.object({ + plugins: z.array(PluginDeclarationSchema), + configurationFingerprint: z.string().min(1).optional(), + defaultBaseDirectory: z.string().optional(), + }), + result: z.object({ + configurationFingerprint: z.string().min(1).optional(), + prepared: z.array(z.object({ + spec: z.string(), + source: z.enum(["file", "npm"]), + target: z.string(), + entry: z.string(), + cache: z.enum(["hit", "installed", "validated"]), + version: z.string().optional(), + })), + failed: z.array(PluginPrepareFailureSchema), + diagnostics: z.array(DiagnosticSchema), + }), + }, + "host.instance.open": { + params: z.object({ + instanceID: z.string().min(1), + project: JsonValueSchema, + config: JsonObjectSchema, + directory: z.string(), + worktree: z.string(), + plugins: z.array(PluginDeclarationSchema), + configurationFingerprint: z.string().min(1).optional(), + }), + result: z.object({ + instanceID: z.string().min(1), + config: JsonObjectSchema, + diagnostics: z.array(DiagnosticSchema), + hooks: z.array(z.string()), + tools: z.array(ToolRegistrationSchema), + auth: z.array(AuthRegistrationSchema), + providers: z.array(ProviderRegistrationSchema), + workspaces: z.array(WorkspaceRegistrationSchema), + gatewayURL: z.string().url(), + }), + }, + "host.instance.close": { params: InstanceParamsSchema, result: CloseResultSchema }, + "host.log.setLevel": { params: z.object({ level: LogLevelSchema }), result: z.object({ level: LogLevelSchema }) }, + "host.shutdown": { params: EmptyResultSchema, result: CloseResultSchema }, + "host.hook.call": { + params: z.object({ + instanceID: z.string().min(1), + hook: z.string().min(1), + input: JsonValueSchema, + output: JsonValueSchema, + }), + result: z.object({ input: JsonValueSchema, output: JsonValueSchema }), + }, + "host.event.emit": { + params: z.object({ instanceID: z.string().min(1), event: JsonValueSchema }), + result: z.object({ accepted: z.literal(true) }), + }, + "host.tool.execute": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + registrationID: z.string().min(1), + args: JsonValueSchema, + context: z.object({ + sessionID: z.string(), + messageID: z.string(), + agent: z.string(), + callID: z.string().optional(), + }), + }), + result: ToolResultSchema, + }, + "host.tool.cancel": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + reason: z.string().optional(), + }), + result: CancelResultSchema, + }, + "host.auth.prompt.evaluate": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + methodIndex: z.number().int().nonnegative(), + promptIndex: z.number().int().nonnegative(), + operation: z.enum(["validate", "condition"]), + value: z.string().optional(), + inputs: z.record(z.string(), z.string()), + }), + result: z.union([ + z.object({ operation: z.literal("validate"), error: z.string().optional() }), + z.object({ operation: z.literal("condition"), active: z.boolean() }), + ]), + }, + "host.auth.authorize": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + methodIndex: z.number().int().nonnegative(), + inputs: z.record(z.string(), z.string()).optional(), + }), + result: z.union([ + z.object({ + type: z.literal("oauth"), + flowID: z.string().min(1), + url: z.string().url(), + instructions: z.string(), + method: z.enum(["auto", "code"]), + }), + z.object({ type: z.literal("api"), result: z.union([AuthSuccessSchema, AuthFailedSchema]).optional() }), + ]), + }, + "host.auth.callback": { + params: z.object({ instanceID: z.string().min(1), flowID: z.string().min(1), code: z.string().optional() }), + result: z.union([AuthSuccessSchema, AuthFailedSchema]), + }, + "host.auth.flow.cancel": { + params: z.object({ instanceID: z.string().min(1), flowID: z.string().min(1), reason: z.string().optional() }), + result: CancelResultSchema, + }, + "host.auth.loader": { + params: z.object({ + instanceID: z.string().min(1), + provider: z.string().min(1), + providerInfo: JsonValueSchema, + }), + result: z.object({ value: JsonObjectSchema, fetchID: z.string().min(1).optional() }), + }, + "host.auth.fetch": { + params: z.object({ + instanceID: z.string().min(1), + fetchID: z.string().min(1), + requestID: z.string().min(1), + request: AuthFetchRequestSchema, + }), + result: HttpResponseSchema, + }, + "host.auth.fetch.cancel": { + params: z.object({ instanceID: z.string().min(1), requestID: z.string().min(1), reason: z.string().optional() }), + result: CancelResultSchema, + }, + "host.auth.fetch.release": { + params: z.object({ instanceID: z.string().min(1), fetchID: z.string().min(1) }), + result: ReleaseResultSchema, + }, + "host.provider.models": { + params: z.object({ + instanceID: z.string().min(1), + providerID: z.string().min(1), + provider: JsonValueSchema, + auth: JsonValueSchema.optional(), + }), + result: z.object({ models: JsonObjectSchema }), + }, + "host.workspace.configure": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: z.object({ config: JsonValueSchema }), + }, + "host.workspace.create": { + params: z.object({ + instanceID: z.string().min(1), + registrationID: z.string().min(1), + config: JsonValueSchema, + env: z.record(z.string(), z.string().nullable()), + from: JsonValueSchema.optional(), + }), + result: EmptyResultSchema, + }, + "host.workspace.remove": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: EmptyResultSchema, + }, + "host.workspace.target": { + params: z.object({ instanceID: z.string().min(1), registrationID: z.string().min(1), config: JsonValueSchema }), + result: z.object({ + target: z.union([ + z.object({ type: z.literal("local"), directory: z.string() }), + z.object({ type: z.literal("remote"), url: z.string().url(), headers: HeadersSchema.optional() }), + ]), + }), + }, + "host.stream.read": { params: StreamReadParamsSchema, result: StreamReadResultSchema }, + "host.stream.cancel": { params: StreamCancelParamsSchema, result: CancelResultSchema }, +} satisfies MethodDefinitions + +export const BackendMethodSchemas = { + "backend.handshake": { + params: z.object({ + token: z.string().min(1), + protocolVersion: z.literal(PROTOCOL_VERSION), + opencodeVersion: z.literal(OPENCODE_VERSION), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), + }), + result: z.object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), + cacheDirectory: z.string(), + }), + }, + "backend.http.request": { + params: z.object({ + instanceID: z.string().min(1), + requestID: z.string().min(1), + method: z.string().min(1), + path: z.string(), + headers: HeadersSchema, + body: StreamDescriptorSchema.optional(), + }), + result: HttpResponseSchema, + }, + "backend.auth.get": { + params: z.object({ instanceID: z.string().min(1), providerID: z.string().min(1) }), + result: z.object({ auth: JsonValueSchema.nullable() }), + }, + "backend.tool.ask": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + permission: z.string(), + patterns: z.array(z.string()), + always: z.array(z.string()), + metadata: JsonObjectSchema, + }), + result: EmptyResultSchema, + }, + "backend.tool.metadata": { + params: z.object({ + instanceID: z.string().min(1), + executionID: z.string().min(1), + title: z.string().optional(), + metadata: JsonObjectSchema.optional(), + }), + result: EmptyResultSchema, + }, + "backend.diagnostic.publish": { + params: z.object({ instanceID: z.string().min(1).optional(), diagnostic: DiagnosticSchema }), + result: EmptyResultSchema, + }, + "backend.stream.read": { params: StreamReadParamsSchema, result: StreamReadResultSchema }, + "backend.stream.cancel": { params: StreamCancelParamsSchema, result: CancelResultSchema }, +} satisfies MethodDefinitions + +export type HostMethod = keyof typeof HostMethodSchemas +export type BackendMethod = keyof typeof BackendMethodSchemas +export type MethodParams = z.input +export type MethodResult = z.output +export type HostMethodParams = MethodParams +export type HostMethodResult = MethodResult +export type BackendMethodParams = MethodParams +export type BackendMethodResult = MethodResult +export type StreamDescriptor = z.infer +export type StreamReadParams = z.infer +export type StreamReadResult = z.infer +export type RpcErrorObject = z.infer diff --git a/src/apps/extension-host/src/rpc.ts b/src/apps/extension-host/src/rpc.ts new file mode 100644 index 000000000..e9ef1cd4c --- /dev/null +++ b/src/apps/extension-host/src/rpc.ts @@ -0,0 +1,518 @@ +import { + DEFAULT_MAX_FRAME_BYTES, + JsonValueSchema, + MAX_MAX_FRAME_BYTES, + RpcMessageSchema, + type RpcErrorObject, +} from "./protocol" +import { logEvent, logError, rpcMessageSummary } from "./log" +import net from "node:net" + +export type RpcTransport = { + write(data: Uint8Array): number | void | Promise + end?(): void + terminate?(): void +} + +export type RpcHandler = (params: unknown) => unknown | Promise + +export type RpcPeerOptions = { + idPrefix: string + maxFrameBytes?: number + onEof?: (error?: Error) => void | Promise + onError?: (error: Error) => void +} + +export type RpcRequestOptions = { + signal?: AbortSignal +} + +type PendingRequest = { + resolve(value: unknown): void + reject(error: Error): void + cleanup(): void +} + +export class RpcError extends Error { + readonly code: number + readonly data?: unknown + + constructor(code: number, message: string, data?: unknown) { + super(message) + this.name = "RpcError" + this.code = code + this.data = data + } +} + +export class RpcConnectionClosedError extends Error { + constructor(message = "JSON-RPC connection is closed", options?: ErrorOptions) { + super(message, options) + this.name = "RpcConnectionClosedError" + } +} + +export class RpcProtocolError extends RpcError { + constructor(code: -32700 | -32600, message: string, data?: unknown) { + super(code, message, data) + this.name = "RpcProtocolError" + } +} + +export class RpcPeer { + readonly closed: Promise + readonly #transport: RpcTransport + readonly #idPrefix: string + readonly #handlers = new Map() + readonly #pending = new Map() + readonly #onEof?: RpcPeerOptions["onEof"] + readonly #onError?: RpcPeerOptions["onError"] + readonly #resolveClosed: () => void + #buffer = new Uint8Array() + #sequence = 0 + #ended = false + #closeError?: Error + #maxFrameBytes: number + #writeTail = Promise.resolve() + + constructor(transport: RpcTransport, options: RpcPeerOptions) { + if (!options.idPrefix) throw new TypeError("JSON-RPC ID prefix must not be empty") + this.#transport = transport + this.#idPrefix = options.idPrefix + this.#maxFrameBytes = validateMaxFrameBytes(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES) + this.#onEof = options.onEof + this.#onError = options.onError + const deferred = Promise.withResolvers() + this.closed = deferred.promise + this.#resolveClosed = deferred.resolve + } + + get maxFrameBytes() { + return this.#maxFrameBytes + } + + get closeError() { + return this.#closeError + } + + setMaxFrameBytes(value: number) { + this.#maxFrameBytes = validateMaxFrameBytes(value) + } + + handle(method: string, handler: (params: Params) => Result | Promise) { + if (!method) throw new TypeError("JSON-RPC method must not be empty") + if (this.#handlers.has(method)) throw new Error(`JSON-RPC handler already registered for ${method}`) + this.#handlers.set(method, handler as RpcHandler) + return () => { + if (this.#handlers.get(method) === handler) this.#handlers.delete(method) + } + } + + async request(method: string, params: unknown = {}, options: RpcRequestOptions = {}) { + this.#assertOpen() + if (options.signal?.aborted) throw abortError(options.signal.reason) + const id = `${this.#idPrefix}:${++this.#sequence}` + const deferred = Promise.withResolvers() + const abort = () => { + this.#pending.delete(id) + deferred.reject(abortError(options.signal?.reason)) + } + const cleanup = () => options.signal?.removeEventListener("abort", abort) + this.#pending.set(id, { resolve: deferred.resolve, reject: deferred.reject, cleanup }) + options.signal?.addEventListener("abort", abort, { once: true }) + + try { + await this.#send({ jsonrpc: "2.0", id, method, params }) + } catch (error) { + const pending = this.#pending.get(id) + if (pending) { + this.#pending.delete(id) + pending.cleanup() + pending.reject(asError(error)) + } + } + return (await deferred.promise) as Result + } + + notify(method: string, params: unknown = {}) { + this.#assertOpen() + return this.#send({ jsonrpc: "2.0", method, params }) + } + + receive(data: Uint8Array) { + if (this.#ended || data.byteLength === 0) return + this.#buffer = concatBytes(this.#buffer, data) + + while (this.#buffer.byteLength >= 4) { + const length = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 4).getUint32(0, false) + if (length === 0) { + this.#fail(new RpcProtocolError(-32600, "JSON-RPC frame must not be empty")) + return + } + if (length > this.#maxFrameBytes) { + this.#fail( + new RpcProtocolError(-32600, `JSON-RPC frame length ${length} exceeds limit ${this.#maxFrameBytes}`, { + length, + maxFrameBytes: this.#maxFrameBytes, + }), + ) + return + } + if (this.#buffer.byteLength < length + 4) return + const payload = this.#buffer.slice(4, length + 4) + this.#buffer = this.#buffer.slice(length + 4) + this.#receivePayload(payload) + if (this.#ended) return + } + } + + end(error?: Error) { + this.#finish(error) + } + + close(error?: Error) { + if (this.#ended) return + try { + this.#transport.end?.() + } catch (cause) { + error ??= asError(cause) + } + this.#finish(error) + } + + async flushAndClose(error?: Error) { + await this.#writeTail + this.close(error) + } + + #receivePayload(payload: Uint8Array) { + let value: unknown + try { + value = JSON.parse(new TextDecoder().decode(payload)) + } catch (error) { + this.#fail(new RpcProtocolError(-32700, "Invalid JSON-RPC JSON payload", errorDetails(error))) + return + } + + const parsed = RpcMessageSchema.safeParse(value) + if (!parsed.success) { + const id = responseID(value) + if (!id) { + this.#fail(new RpcProtocolError(-32600, "Invalid JSON-RPC message", { issues: parsed.error.issues })) + return + } + void this.#sendError(id, { + code: -32600, + message: "Invalid JSON-RPC message", + data: safeErrorData({ issues: parsed.error.issues }), + }) + return + } + + const message = parsed.data + logEvent("rpc.receive", { ...rpcMessageSummary(message), frame_bytes: payload.byteLength }, "debug") + if ("method" in message) { + void this.#dispatch(message.method, message.params, "id" in message ? message.id : undefined) + return + } + + const pending = this.#pending.get(message.id) + if (!pending) return + this.#pending.delete(message.id) + pending.cleanup() + if ("error" in message) { + pending.reject(new RpcError(message.error.code, message.error.message, message.error.data)) + return + } + pending.resolve(message.result) + } + + async #dispatch(method: string, params: unknown, id?: string) { + const handler = this.#handlers.get(method) + if (!handler) { + if (id) await this.#sendError(id, { code: -32601, message: `Method not found: ${method}` }) + return + } + + try { + const result = await handler(params) + if (id) await this.#send({ jsonrpc: "2.0", id, result: result === undefined ? null : result }) + } catch (error) { + if (id) { + await this.#sendError(id, rpcErrorObject(error)) + return + } + this.#reportError(asError(error)) + } + } + + async #sendError(id: string, error: RpcErrorObject) { + try { + await this.#send({ jsonrpc: "2.0", id, error }) + } catch (cause) { + this.#reportError(asError(cause)) + } + } + + async #send(message: unknown) { + this.#assertOpen() + const frame = encodeFrame(message, this.#maxFrameBytes) + logEvent("rpc.send", { ...rpcMessageSummary(message), frame_bytes: frame.byteLength - 4 }, "debug") + const write = this.#writeTail.then(async () => { + this.#assertOpen() + const written = await this.#transport.write(frame) + if (typeof written === "number" && written < frame.byteLength) { + throw new Error(`JSON-RPC transport accepted ${written} of ${frame.byteLength} bytes`) + } + }) + this.#writeTail = write.catch(() => {}) + try { + await write + } catch (error) { + this.#fail(asError(error)) + throw error + } + } + + #assertOpen() { + if (this.#ended) throw new RpcConnectionClosedError(undefined, { cause: this.#closeError }) + } + + #fail(error: Error) { + if (this.#ended) return + this.#finish(error) + try { + if (this.#transport.terminate) this.#transport.terminate() + if (!this.#transport.terminate) this.#transport.end?.() + } catch { + // The original protocol or transport failure remains authoritative. + } + this.#reportError(error) + } + + #finish(error?: Error) { + if (this.#ended) return + this.#ended = true + this.#closeError = error + this.#buffer = new Uint8Array() + const reason = new RpcConnectionClosedError(undefined, { cause: error }) + for (const pending of this.#pending.values()) { + pending.cleanup() + pending.reject(reason) + } + this.#pending.clear() + this.#resolveClosed() + if (this.#onEof) void Promise.resolve(this.#onEof(error)).catch((cause) => this.#reportError(asError(cause))) + } + + #reportError(error: Error) { + if (this.#onError) { + this.#onError(error) + return + } + logError("rpc.failure", error) + } +} + +export function encodeFrame(message: unknown, maxFrameBytes = DEFAULT_MAX_FRAME_BYTES) { + const limit = validateMaxFrameBytes(maxFrameBytes) + const text = JSON.stringify(message, (_key, value: unknown) => { + if (typeof value === "bigint") throw new TypeError("JSON-RPC values cannot contain BigInt") + if (typeof value === "function" || typeof value === "symbol") { + throw new TypeError(`JSON-RPC values cannot contain ${typeof value}`) + } + if (typeof value === "number" && !Number.isFinite(value)) { + throw new TypeError("JSON-RPC values cannot contain non-finite numbers") + } + return value + }) + if (text === undefined) throw new TypeError("JSON-RPC message is not serializable") + const payload = new TextEncoder().encode(text) + if (payload.byteLength === 0 || payload.byteLength > limit) { + throw new RangeError(`JSON-RPC payload length ${payload.byteLength} exceeds limit ${limit}`) + } + const frame = new Uint8Array(payload.byteLength + 4) + new DataView(frame.buffer).setUint32(0, payload.byteLength, false) + frame.set(payload, 4) + return frame +} + +export async function connectRpcPeer( + address: string, + options: Omit & { idPrefix?: string } = {}, +) { + const target = parseRpcAddress(address) + let socket: Bun.Socket + let drain: ReturnType> | undefined + const peer = new RpcPeer( + { + async write(data) { + let offset = 0 + while (offset < data.byteLength) { + const written = socket.write(data, offset, data.byteLength - offset) + if (written < 0) throw new RpcConnectionClosedError("JSON-RPC socket closed while writing") + offset += written + if (offset === data.byteLength) return offset + drain ??= Promise.withResolvers() + await drain.promise + } + return offset + }, + end: () => socket.end(), + terminate: () => socket.terminate(), + }, + { ...options, idPrefix: options.idPrefix ?? "host" }, + ) + socket = await Bun.connect({ + hostname: target.hostname, + port: target.port, + socket: { + data(_socket, data) { + peer.receive(data) + }, + drain() { + drain?.resolve() + drain = undefined + }, + close() { + drain?.reject(new RpcConnectionClosedError()) + drain = undefined + peer.end() + }, + error(_socket, error) { + drain?.reject(error) + drain = undefined + peer.end(error) + }, + }, + }) + return peer +} + +export async function connectNodeRpcPeer( + address: string, + options: Omit & { idPrefix?: string } = {}, +) { + const target = parseRpcAddress(address) + const socket = await connectNodeSocket(target.hostname, target.port) + const peer = new RpcPeer( + { + write(data) { + return writeNodeSocket(socket, data) + }, + end: () => socket.end(), + terminate: () => socket.destroy(), + }, + { ...options, idPrefix: options.idPrefix ?? "host" }, + ) + + socket.on("data", (chunk: Buffer) => peer.receive(chunk)) + socket.on("close", () => peer.end()) + socket.on("error", (error: Error) => peer.end(error)) + return peer +} + +function connectNodeSocket(hostname: string, port: number) { + return new Promise((resolve, reject) => { + const socket = net.connect({ host: hostname, port }) + const handleError = (error: Error) => { + socket.destroy() + reject(error) + } + socket.once("connect", () => { + socket.removeListener("error", handleError) + resolve(socket) + }) + socket.once("error", handleError) + }) +} + +async function writeNodeSocket(socket: net.Socket, data: Uint8Array) { + if (socket.destroyed) throw new RpcConnectionClosedError("JSON-RPC socket is closed") + await new Promise((resolve, reject) => { + const accepted = socket.write(data, (error?: Error | null) => (error ? reject(error) : resolve())) + if (accepted) return + socket.once("drain", resolve) + socket.once("error", reject) + }) + return data.byteLength +} + +export function parseRpcAddress(address: string) { + const url = new URL(address.includes("://") ? address : `tcp://${address}`) + if (url.protocol !== "tcp:") throw new TypeError(`Unsupported RPC address protocol: ${url.protocol}`) + if (!url.hostname || !url.port) throw new TypeError(`RPC address must include a host and port: ${address}`) + const port = Number(url.port) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TypeError(`Invalid RPC port: ${url.port}`) + return { hostname: url.hostname, port } +} + +function validateMaxFrameBytes(value: number) { + if (!Number.isInteger(value) || value < 1 || value > MAX_MAX_FRAME_BYTES) { + throw new RangeError(`maxFrameBytes must be an integer between 1 and ${MAX_MAX_FRAME_BYTES}`) + } + return value +} + +function rpcErrorObject(error: unknown): RpcErrorObject { + if (hasNumericCode(error)) { + return { + code: error.code, + message: error instanceof Error ? error.message : String(Reflect.get(error, "message") ?? "JSON-RPC error"), + ...(error.data === undefined ? {} : { data: safeErrorData(error.data) }), + } + } + const value = asError(error) + return { + code: -32603, + message: value.message || "Internal error", + data: { + name: value.name, + message: value.message, + ...(value.stack ? { stack: value.stack } : {}), + }, + } +} + +function hasNumericCode(value: unknown): value is { code: number; data?: unknown } { + return ( + typeof value === "object" && + value !== null && + typeof Reflect.get(value, "code") === "number" && + Number.isInteger(Reflect.get(value, "code")) + ) +} + +function safeErrorData(value: unknown) { + const parsed = JsonValueSchema.safeParse(value) + if (parsed.success) return parsed.data + return { kind: "invalid_error_data", message: "Thrown JSON-RPC error data was not JSON-compatible" } +} + +function responseID(value: unknown) { + if (typeof value !== "object" || value === null) return undefined + const id = Reflect.get(value, "id") + return typeof id === "string" && id ? id : undefined +} + +function errorDetails(error: unknown) { + const value = asError(error) + return { name: value.name, message: value.message } +} + +function abortError(reason: unknown) { + if (reason instanceof Error) return reason + return new DOMException(typeof reason === "string" ? reason : "The operation was aborted", "AbortError") +} + +function asError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)) +} + +function concatBytes(left: Uint8Array, right: Uint8Array) { + if (left.byteLength === 0) return right.slice() + const result = new Uint8Array(left.byteLength + right.byteLength) + result.set(left) + result.set(right, left.byteLength) + return result +} diff --git a/src/apps/extension-host/src/semver.d.ts b/src/apps/extension-host/src/semver.d.ts new file mode 100644 index 000000000..406263719 --- /dev/null +++ b/src/apps/extension-host/src/semver.d.ts @@ -0,0 +1,3 @@ +declare module "semver" { + export function satisfies(version: string, range: string): boolean +} diff --git a/src/apps/extension-host/src/service.ts b/src/apps/extension-host/src/service.ts new file mode 100644 index 000000000..6a4e443df --- /dev/null +++ b/src/apps/extension-host/src/service.ts @@ -0,0 +1,102 @@ +import type { RpcConnection } from "./backend" +import { ExtensionHostError } from "./errors" +import { ExtensionHost, type InstanceOpenInput, type PluginsPrepareInput } from "./host" +import { logEvent, setLogLevel } from "./log" +import { HostMethodSchemas, type HostMethod } from "./protocol" +import type { WireValue } from "./wire" + +export type HandlerPeer = RpcConnection & { + handle(method: string, handler: (params: unknown) => unknown | Promise): void +} + +export function registerHostMethods(input: { + peer: HandlerPeer + host: ExtensionHost | Promise + shutdown(): void +}) { + const host = () => Promise.resolve(input.host) + const register = (method: HostMethod, handler: (params: unknown) => unknown | Promise) => { + input.peer.handle(method, async (params) => { + const parsed = HostMethodSchemas[method].params.safeParse(params) + if (!parsed.success) { + throw new ExtensionHostError(-32602, `Invalid parameters for ${method}`, { + kind: "invalid_params", + method, + issues: parsed.error.issues, + }) + } + return HostMethodSchemas[method].result.parse(await handler(parsed.data)) + }) + } + + register("host.plugins.prepare", async (params) => (await host()).prepare(params as PluginsPrepareInput)) + register("host.instance.open", async (params) => (await host()).open(params as InstanceOpenInput)) + register("host.instance.close", async (params) => (await host()).close(params as { instanceID: string })) + register("host.log.setLevel", (params) => { + const value = HostMethodSchemas["host.log.setLevel"].params.parse(params) + return { level: setLogLevel(value.level) } + }) + register("host.hook.call", async (params) => + (await host()).callHook( + (() => { + const value = params as { instanceID: string; hook: string; input: WireValue; output: WireValue } + return { ...value, name: value.hook } + })(), + ), + ) + register("host.event.emit", async (params) => + (await host()).emitEvent(params as { instanceID: string; event: WireValue }), + ) + register("host.tool.execute", async (params) => + (await host()).executeTool(params as Parameters[0]), + ) + register("host.tool.cancel", async (params) => + (await host()).cancelTool(params as Parameters[0]), + ) + register("host.auth.prompt.evaluate", async (params) => + (await host()).evaluateAuthPrompt(params as Parameters[0]), + ) + register("host.auth.authorize", async (params) => + (await host()).authorize(params as Parameters[0]), + ) + register("host.auth.callback", async (params) => + (await host()).authCallback(params as Parameters[0]), + ) + register("host.auth.flow.cancel", async (params) => + (await host()).cancelAuthFlow(params as Parameters[0]), + ) + register("host.auth.loader", async (params) => + (await host()).loadAuth(params as Parameters[0]), + ) + register("host.auth.fetch", async (params) => + (await host()).authFetch(params as Parameters[0]), + ) + register("host.auth.fetch.cancel", async (params) => + (await host()).cancelAuthFetch(params as Parameters[0]), + ) + register("host.auth.fetch.release", async (params) => + (await host()).releaseAuthFetch(params as Parameters[0]), + ) + register("host.provider.models", async (params) => + (await host()).providerModels(params as Parameters[0]), + ) + register("host.workspace.configure", async (params) => + (await host()).workspaceConfigure(params as Parameters[0]), + ) + register("host.workspace.create", async (params) => + (await host()).workspaceCreate(params as Parameters[0]), + ) + register("host.workspace.remove", async (params) => + (await host()).workspaceRemove(params as Parameters[0]), + ) + register("host.workspace.target", async (params) => + (await host()).workspaceTarget(params as Parameters[0]), + ) + register("host.shutdown", async () => { + logEvent("shutdown.requested") + const result = await (await host()).shutdown() + logEvent("shutdown.instances_closed") + setTimeout(() => input.shutdown(), 0) + return result + }) +} diff --git a/src/apps/extension-host/src/streams.ts b/src/apps/extension-host/src/streams.ts new file mode 100644 index 000000000..9438bc067 --- /dev/null +++ b/src/apps/extension-host/src/streams.ts @@ -0,0 +1,175 @@ +import { + MAX_STREAM_CHUNK_BYTES, + StreamReadResultSchema, + type StreamDescriptor, + type StreamReadResult, +} from "./protocol" + +type StreamEntry = { + reader: { + read(): Promise<{ done: boolean; value?: Uint8Array }> + cancel(reason?: unknown): Promise + releaseLock(): void + } + remainder?: Uint8Array + tail: Promise + done: boolean + released: boolean +} + +export type StreamRpcPeer = { + request(method: string, params: unknown): Promise +} + +export class StreamRegistry { + readonly #prefix: string + readonly #streams = new Map() + #sequence = 0 + + constructor(prefix = "host") { + if (!prefix) throw new TypeError("Stream ID prefix must not be empty") + this.#prefix = prefix + } + + get size() { + return this.#streams.size + } + + add(stream: ReadableStream, length?: number): StreamDescriptor { + if (length !== undefined && (!Number.isSafeInteger(length) || length < 0)) { + throw new RangeError("Stream length must be a non-negative safe integer") + } + const streamID = `${this.#prefix}-stream:${++this.#sequence}` + this.#streams.set(streamID, { reader: stream.getReader(), tail: Promise.resolve(), done: false, released: false }) + return { streamID, ...(length === undefined ? {} : { length }) } + } + + register(stream: ReadableStream, length?: number) { + return this.add(stream, length) + } + + async read(input: { streamID: string; maxBytes?: number }): Promise { + const maxBytes = input.maxBytes ?? MAX_STREAM_CHUNK_BYTES + if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_STREAM_CHUNK_BYTES) { + throw new RangeError(`maxBytes must be an integer between 1 and ${MAX_STREAM_CHUNK_BYTES}`) + } + const entry = this.#streams.get(input.streamID) + if (!entry) return { data: "", eof: true } + + return this.#serialized(entry, async () => { + if (entry.done) return { data: "", eof: true } + if (entry.remainder?.byteLength) return this.#take(entry, maxBytes) + + while (true) { + let result: { done: boolean; value?: Uint8Array } + try { + result = await entry.reader.read() + } catch (error) { + entry.done = true + this.#streams.delete(input.streamID) + throw error + } + if (result.done) { + entry.done = true + this.#streams.delete(input.streamID) + this.#release(entry) + return { data: "", eof: true } + } + if (!result.value || result.value.byteLength === 0) continue + entry.remainder = result.value + return this.#take(entry, maxBytes) + } + }) + } + + async cancel(input: { streamID: string; reason?: string }) { + const entry = this.#streams.get(input.streamID) + if (!entry) return { cancelled: false } + this.#streams.delete(input.streamID) + if (entry.done) return { cancelled: false } + entry.done = true + entry.remainder = undefined + try { + await entry.reader.cancel(input.reason) + await entry.tail + } finally { + this.#release(entry) + } + return { cancelled: true } + } + + async cancelAll(reason = "Stream registry closed") { + await Promise.all(Array.from(this.#streams, ([streamID]) => this.cancel({ streamID, reason }))) + } + + #take(entry: StreamEntry, maxBytes: number): StreamReadResult { + const value = entry.remainder! + const data = value.byteLength <= maxBytes ? value : value.subarray(0, maxBytes) + entry.remainder = value.byteLength <= maxBytes ? undefined : value.subarray(maxBytes) + return { data: Buffer.from(data).toString("base64"), eof: false } + } + + #release(entry: StreamEntry) { + if (entry.released) return + entry.released = true + entry.reader.releaseLock() + } + + async #serialized(entry: StreamEntry, operation: () => Promise) { + const previous = entry.tail + const deferred = Promise.withResolvers() + entry.tail = deferred.promise + await previous + try { + return await operation() + } finally { + deferred.resolve() + } + } +} + +export function remoteReadable( + peer: StreamRpcPeer, + methodPrefix: "backend" | "host", + descriptor: StreamDescriptor, + params: Record = {}, +) { + let released = false + return new ReadableStream({ + async pull(controller) { + try { + const result = StreamReadResultSchema.parse( + await peer.request(`${methodPrefix}.stream.read`, { + ...params, + streamID: descriptor.streamID, + maxBytes: MAX_STREAM_CHUNK_BYTES, + }), + ) + if (result.data) controller.enqueue(Buffer.from(result.data, "base64")) + if (!result.eof) return + released = true + controller.close() + } catch (error) { + controller.error(error) + if (released) return + released = true + void peer + .request(`${methodPrefix}.stream.cancel`, { + ...params, + streamID: descriptor.streamID, + reason: error instanceof Error ? error.message : String(error), + }) + .catch(() => {}) + } + }, + async cancel(reason) { + if (released) return + released = true + await peer.request(`${methodPrefix}.stream.cancel`, { + ...params, + streamID: descriptor.streamID, + ...(reason === undefined ? {} : { reason: reason instanceof Error ? reason.message : String(reason) }), + }) + }, + }) +} diff --git a/src/apps/extension-host/src/tool-schema.ts b/src/apps/extension-host/src/tool-schema.ts new file mode 100644 index 000000000..87b39deee --- /dev/null +++ b/src/apps/extension-host/src/tool-schema.ts @@ -0,0 +1,94 @@ +import { z } from "zod" + +export type ToolJsonSchema = boolean | Record + +/** Convert the public plugin tool argument map to the schema sent to Rust. */ +export function toolParametersToJsonSchema(args: unknown): ToolJsonSchema { + const entries = Object.entries(isRecord(args) ? args : {}) + const zodParameters = entries.every((entry) => isZodType(entry[1])) + ? z.object(Object.fromEntries(entries) as z.ZodRawShape) + : undefined + if (!zodParameters) return legacyJsonSchema(entries) + + const result = normalizeZodJsonSchema( + z.toJSONSchema(zodParameters, { io: "input", metadata: zodMetadataRegistry(zodParameters) }), + ) + if (!isRecord(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema") + const { $defs, ...rest } = result + return $defs && isRecord($defs) ? { ...rest, definitions: $defs } : rest +} + +export const toolArgsToJsonSchema = toolParametersToJsonSchema + +/** + * Match OpenCode's registry boundary: Zod argument maps parse before execute, + * while legacy JSON Schema maps remain advisory and pass through unchanged. + */ +export function validateToolArguments(argsDefinition: unknown, value: unknown) { + const entries = Object.entries(isRecord(argsDefinition) ? argsDefinition : {}) + if (!entries.every((entry) => isZodType(entry[1]))) return value + return z.object(Object.fromEntries(entries) as z.ZodRawShape).parse(value) +} + +function isZodType(value: unknown): value is z.ZodType { + return typeof value === "object" && value !== null && "_zod" in value +} + +function isJsonSchemaDefinition(value: unknown): value is boolean | Record { + return typeof value === "boolean" || isRecord(value) +} + +function legacyJsonSchema(entries: [string, unknown][]): Record { + const properties = Object.fromEntries( + entries.filter((entry): entry is [string, boolean | Record] => isJsonSchemaDefinition(entry[1])), + ) + return { + type: "object", + properties, + required: Object.keys(properties), + } +} + +function zodMetadataRegistry(schema: z.ZodType) { + const registry = z.registry>() + const seen = new WeakSet() + const collect = (value: unknown) => { + if (typeof value !== "object" || value === null) return + if (seen.has(value)) return + seen.add(value) + + if (isZodType(value)) { + const metadata = typeof value.meta === "function" ? value.meta() : undefined + const description = typeof value.description === "string" ? value.description : undefined + const merged = { + ...(metadata && typeof metadata === "object" ? metadata : {}), + ...(description ? { description } : {}), + } + if (Object.keys(merged).length) registry.add(value, merged) + collect(value._zod.def) + return + } + + for (const item of Object.values(value)) collect(item) + } + collect(schema) + return registry +} + +function normalizeZodJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item)) + if (!isRecord(value)) return value + return Object.fromEntries( + Object.entries(value) + .filter((entry) => + (entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean" + ? false + : true, + ) + .map(([key, item]) => [key, normalizeZodJsonSchema(item)]), + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/apps/extension-host/src/wire.ts b/src/apps/extension-host/src/wire.ts new file mode 100644 index 000000000..0040a2fd6 --- /dev/null +++ b/src/apps/extension-host/src/wire.ts @@ -0,0 +1,79 @@ +export type WireValue = null | boolean | number | string | WireValue[] | { [key: string]: WireValue } + +export class WireValueError extends TypeError { + readonly path: string + + constructor(path: string, message: string) { + super(`Wire value at ${path} ${message}`) + this.name = "WireValueError" + this.path = path + } +} + +/** + * Validate and detach a value before it crosses the RPC boundary. This is + * deliberately stricter than JSON.stringify, which otherwise drops functions + * and undefined values silently and converts non-finite numbers to null. + */ +export function cloneWireValue(value: unknown, path = "$"): WireValue { + return clone(value, path, new Map()) +} + +export function assertWireValue(value: unknown, path = "$"): asserts value is WireValue { + cloneWireValue(value, path) +} + +export function isWireValue(value: unknown): value is WireValue { + try { + cloneWireValue(value) + return true + } catch { + return false + } +} + +function clone(value: unknown, path: string, ancestors: Map): WireValue { + if (value === null || typeof value === "string" || typeof value === "boolean") return value + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new WireValueError(path, "cannot contain a non-finite number") + return value + } + if (typeof value === "function") throw new WireValueError(path, "cannot contain a function") + if (typeof value === "bigint") throw new WireValueError(path, "cannot contain a BigInt") + if (typeof value === "undefined") throw new WireValueError(path, "cannot contain undefined") + if (typeof value === "symbol") throw new WireValueError(path, "cannot contain a symbol") + + if (typeof value !== "object") throw new WireValueError(path, `has unsupported type ${typeof value}`) + const previous = ancestors.get(value) + if (previous !== undefined) throw new WireValueError(path, `contains a cycle referencing ${previous}`) + ancestors.set(value, path) + + try { + if (Array.isArray(value)) { + return Array.from(value, (item, index) => + item === undefined ? null : clone(item, `${path}[${index}]`, ancestors), + ) + } + + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new WireValueError(path, `must be a plain object, received ${objectName(value)}`) + } + + return Object.fromEntries( + Object.entries(value) + .filter((entry) => entry[1] !== undefined) + .map(([key, item]) => [key, clone(item, propertyPath(path, key), ancestors)]), + ) + } finally { + ancestors.delete(value) + } +} + +function propertyPath(parent: string, key: string) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]` +} + +function objectName(value: object) { + return Object.prototype.toString.call(value) +} diff --git a/src/apps/extension-host/test/boundary.test.ts b/src/apps/extension-host/test/boundary.test.ts new file mode 100644 index 000000000..5fbd70bdd --- /dev/null +++ b/src/apps/extension-host/test/boundary.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" + +const extensionHostDirectory = path.resolve(import.meta.dir, "..") + +describe("standalone package boundary", () => { + test("imports only public OpenCode packages", async () => { + const packageJson = await Bun.file(path.join(extensionHostDirectory, "package.json")).json() + const files = [ + ...new Bun.Glob("src/**/*.ts").scanSync({ cwd: extensionHostDirectory }), + ...new Bun.Glob("script/**/*.ts").scanSync({ cwd: extensionHostDirectory }), + ] + const resolvedImports = ( + await Promise.all( + files.map(async (file) => { + const source = await Bun.file(path.join(extensionHostDirectory, file)).text() + return Array.from( + source.matchAll(/(?:from\s+|import\s*\(\s*|import\s+)["']([^"']+)["']/g), + (match) => match[1], + ).flatMap((specifier) => (specifier ? [{ file, specifier }] : [])) + }), + ) + ).flat() + const invalidOpenCodeImports = resolvedImports.filter( + (item) => + item.specifier.startsWith("@opencode-ai/") && + item.specifier !== "@opencode-ai/plugin" && + item.specifier !== "@opencode-ai/sdk" && + !item.specifier.startsWith("@opencode-ai/sdk/"), + ) + const escapedRelativeImports = resolvedImports.filter( + (item) => + item.specifier.startsWith(".") && + !path + .resolve(path.dirname(path.join(extensionHostDirectory, item.file)), item.specifier) + .startsWith(`${extensionHostDirectory}${path.sep}`), + ) + + expect(invalidOpenCodeImports).toEqual([]) + expect(escapedRelativeImports).toEqual([]) + expect(packageJson.dependencies["@opencode-ai/plugin"]).toBe("1.17.18") + expect(packageJson.dependencies["@opencode-ai/sdk"]).toBe("1.17.18") + expect(Object.values({ ...packageJson.dependencies, ...packageJson.devDependencies })).not.toContain( + expect.stringContaining("workspace:"), + ) + }) + + test("packs, installs, and builds outside the repository", async () => { + const root = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-pack-")) + const archiveDirectory = path.join(root, "archive") + const extractedDirectory = path.join(root, "extracted") + await Promise.all([mkdir(archiveDirectory), mkdir(extractedDirectory)]) + + try { + await command( + [process.execPath, "pm", "pack", "--destination", archiveDirectory, "--ignore-scripts", "--quiet"], + extensionHostDirectory, + ) + const archive = path.join( + archiveDirectory, + (await readdir(archiveDirectory)).find((file) => file.endsWith(".tgz")) ?? "missing.tgz", + ) + expect(await Bun.file(archive).exists()).toBe(true) + await command(["tar", "-xzf", archive, "-C", extractedDirectory], extensionHostDirectory) + + const standaloneDirectory = path.join(extractedDirectory, "package") + expect(await Bun.file(path.join(standaloneDirectory, "protocol.schema.json")).exists()).toBe(true) + await command([process.execPath, "install", "--ignore-scripts"], standaloneDirectory) + await command([process.execPath, "run", "build"], standaloneDirectory) + expect(await Bun.file(path.join(standaloneDirectory, "dist", "extension-host.js")).exists()).toBe(true) + } finally { + await rm(root, { recursive: true, force: true }) + } + }, 30_000) +}) + +async function command(cmd: string[], cwd: string) { + const child = Bun.spawn({ cmd, cwd, stdin: "ignore", 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) return stdout + throw new Error(`${cmd.join(" ")} failed with status ${code}\n${stderr || stdout}`) +} diff --git a/src/apps/extension-host/test/fixtures/gateway/injected.ts b/src/apps/extension-host/test/fixtures/gateway/injected.ts new file mode 100644 index 000000000..0a423ddc7 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/gateway/injected.ts @@ -0,0 +1,50 @@ +import type { PluginModule } from "@opencode-ai/plugin" + +const server: PluginModule["server"] = async (input) => { + const project = await input.client.project.current() + const raw = await fetch(new URL("/raw?fixture=1", input.serverUrl)).then((response) => response.text()) + const shell = (await input.$`printf injected-shell`.text()).trim() + + input.experimental_workspace.register("fixture-remote", { + name: "Fixture remote", + description: "Workspace registered by the injected API fixture", + configure(config) { + return { + ...config, + name: `${config.name}-configured`, + } + }, + async create() {}, + async remove() {}, + target() { + return { + type: "remote", + url: new URL("https://workspace.example.test/root"), + headers: new Headers([ + ["x-fixture", "yes"], + ["x-second", "two"], + ]), + } + }, + }) + + return { + async config(config) { + Object.assign(config, { + injectedFixture: { + projectID: project.data?.id, + raw, + shell, + serverURL: input.serverUrl.href, + directory: input.directory, + worktree: input.worktree, + }, + }) + }, + } +} + +export default { + id: "gateway-injected-fixture", + server, +} satisfies PluginModule diff --git a/src/apps/extension-host/test/fixtures/loader/legacy.ts b/src/apps/extension-host/test/fixtures/loader/legacy.ts new file mode 100644 index 000000000..c89bee275 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/loader/legacy.ts @@ -0,0 +1,4 @@ +const shared = async () => ({ legacy: true }) + +export { shared as named } +export default shared diff --git a/src/apps/extension-host/test/fixtures/loader/preferred.ts b/src/apps/extension-host/test/fixtures/loader/preferred.ts new file mode 100644 index 000000000..cf74b2b51 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/loader/preferred.ts @@ -0,0 +1,6 @@ +export const ignored = async () => ({ ignored: true }) + +export default { + id: "fixture.preferred", + server: async () => ({ preferred: true }), +} diff --git a/src/apps/extension-host/test/fixtures/runtime/full.js b/src/apps/extension-host/test/fixtures/runtime/full.js new file mode 100644 index 000000000..e6a975dc9 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/full.js @@ -0,0 +1,152 @@ +import { appendFile } from "node:fs/promises" +import { z } from "zod" + +const moduleToken = crypto.randomUUID() +let entrypointRuns = 0 + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.full", + server: async (input, options = {}) => { + entrypointRuns += 1 + const run = entrypointRuns + + input.experimental_workspace.register("fixture-remote", { + name: "Fixture Remote", + description: "Runtime test workspace", + configure: async (config) => ({ ...config, name: `configured:${config.name}` }), + create: async (config, env, from) => { + await record(options.workspaceMarker, `create:${config.id}:${env.FIXTURE ?? "missing"}:${from?.id ?? "none"}`) + }, + remove: async (config) => { + await record(options.workspaceMarker, `remove:${config.id}`) + }, + target: async (config) => ({ + type: "remote", + url: new URL(`https://workspace.example/${config.id}?branch=${config.branch ?? "none"}`), + headers: new Headers({ authorization: "Bearer fixture", "x-workspace": config.id }), + }), + }) + + return { + config: async (config) => { + config.runtime = { moduleToken, run, directory: input.directory } + }, + "chat.message": async (hookInput, output) => { + hookInput.trace ??= [] + output.trace ??= [] + hookInput.trace.push("full") + output.trace.push("full") + }, + dispose: async () => { + await record(options.disposeMarker, `full:${run}`) + }, + tool: { + "fixture.echo": { + description: "Exercise the tool bridge", + args: { + value: z.string().describe("Value to echo"), + waitForAbort: z.boolean().optional(), + }, + execute: async (args, context) => { + context.metadata({ title: `metadata:${args.value}`, metadata: { phase: "before-ask" } }) + await context.ask({ + permission: "fixture.execute", + patterns: [args.value], + always: [], + metadata: { value: args.value }, + }) + if (args.waitForAbort) { + await new Promise((resolve, reject) => { + if (context.abort.aborted) return reject(new Error("fixture aborted")) + context.abort.addEventListener("abort", () => reject(new Error("fixture aborted")), { once: true }) + }) + } + return { + title: `echo:${args.value}`, + output: `${args.value}:${context.directory}:${context.worktree}`, + metadata: { sessionID: context.sessionID, callID: context.callID ?? null }, + attachments: [ + { + type: "file", + mime: "text/plain", + url: "data:text/plain,fixture", + filename: "fixture.txt", + }, + ], + } + }, + }, + }, + auth: { + provider: "fixture-auth", + loader: async (getAuth, provider) => { + const auth = await getAuth() + return { + credential: auth.key, + providerID: provider.id, + fetch: async (request, init) => { + if (new URL(request).pathname === "/wait") { + await new Promise((resolve, reject) => { + if (init?.signal?.aborted) return reject(init.signal.reason) + init?.signal?.addEventListener("abort", () => reject(init.signal.reason), { once: true }) + }) + } + const body = init?.body ? await new Response(init.body).text() : "" + return new Response(`${init?.method ?? "GET"}:${request.toString()}:${body}`, { + status: 201, + headers: { "content-type": "text/plain", "x-fixture-fetch": "yes" }, + }) + }, + } + }, + methods: [ + { + type: "api", + label: "Fixture key", + prompts: [ + { + type: "text", + key: "token", + message: "Token", + validate: (value) => (value.startsWith("ok-") ? undefined : "Token must start with ok-"), + condition: (inputs) => inputs.enabled === "yes", + }, + ], + authorize: async (inputs) => + inputs?.token + ? { type: "success", key: inputs.token, provider: "fixture-auth", metadata: { source: "fixture" } } + : { type: "failed" }, + }, + { + type: "oauth", + label: "Fixture OAuth", + authorize: async () => ({ + url: "https://auth.example/authorize", + instructions: "Paste the fixture code", + method: "code", + callback: async (code) => + code === "good" + ? { type: "success", key: "oauth-key", provider: "fixture-auth", metadata: { code } } + : { type: "failed" }, + }), + }, + ], + }, + provider: { + id: "fixture-provider", + models: async (provider, context) => ({ + "fixture-model": { + id: "fixture-model", + providerID: provider.id, + name: `Fixture ${context.auth?.type ?? "anonymous"}`, + }, + }), + }, + } + }, +} diff --git a/src/apps/extension-host/test/fixtures/runtime/sequence-a.js b/src/apps/extension-host/test/fixtures/runtime/sequence-a.js new file mode 100644 index 000000000..be7a45856 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/sequence-a.js @@ -0,0 +1,33 @@ +import { appendFile } from "node:fs/promises" + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.sequence-a", + server: async (_input, options = {}) => ({ + config: async (config) => { + config.order ??= [] + config.order.push("a") + if (options.configFails) throw new Error("a config failed") + }, + "chat.message": async (input, output) => { + input.order ??= [] + output.order ??= [] + input.order.push("a") + output.order.push("a") + await record(options.hookMarker, "a") + if (options.hookFails) throw new Error("a hook failed") + }, + event: async () => { + await record(options.eventMarker, "a") + if (options.eventFails) throw new Error("a event failed") + }, + dispose: async () => { + await record(options.disposeMarker, "a") + if (options.disposeFails) throw new Error("a dispose failed") + }, + }), +} diff --git a/src/apps/extension-host/test/fixtures/runtime/sequence-b.js b/src/apps/extension-host/test/fixtures/runtime/sequence-b.js new file mode 100644 index 000000000..e22490c62 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/sequence-b.js @@ -0,0 +1,29 @@ +import { appendFile } from "node:fs/promises" + +async function record(file, value) { + if (!file) return + await appendFile(file, `${value}\n`) +} + +export default { + id: "fixture.sequence-b", + server: async (_input, options = {}) => ({ + config: async (config) => { + config.order ??= [] + config.order.push("b") + }, + "chat.message": async (input, output) => { + input.order ??= [] + output.order ??= [] + input.order.push("b") + output.order.push("b") + await record(options.hookMarker, "b") + }, + event: async () => { + await record(options.eventMarker, "b") + }, + dispose: async () => { + await record(options.disposeMarker, "b") + }, + }), +} diff --git a/src/apps/extension-host/test/gateway.test.ts b/src/apps/extension-host/test/gateway.test.ts new file mode 100644 index 000000000..d95d142cc --- /dev/null +++ b/src/apps/extension-host/test/gateway.test.ts @@ -0,0 +1,429 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { createConnection } from "node:net" +import path from "node:path" +import { tmpdir } from "node:os" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" +import { createGateway } from "../src/gateway" +import { ExtensionHost } from "../src/host" +import { preparePlugins } from "../src/loader" +import { StreamRegistry, remoteReadable } from "../src/streams" + +const temporaryDirectories: string[] = [] +const noProxy = process.env.NO_PROXY +const noProxyLowercase = process.env.no_proxy + +beforeAll(() => { + process.env.NO_PROXY = [process.env.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",") + process.env.no_proxy = [process.env.no_proxy, "127.0.0.1", "localhost"].filter(Boolean).join(",") +}) + +afterAll(async () => { + await Promise.all(temporaryDirectories.map((directory) => rm(directory, { recursive: true, force: true }))) + restoreEnvironment("NO_PROXY", noProxy) + restoreEnvironment("no_proxy", noProxyLowercase) +}) + +describe("per-instance HTTP gateway", () => { + test("forwards method, path, headers, and streaming request and response bodies", async () => { + const streams = new TestStreams() + const requests: BackendRequest[] = [] + const rpc = createRpc(async (method, params) => { + expect(method).toBe("backend.http.request") + const request = params as BackendRequest + requests.push(request) + expect(await streams.readHost(request.body)).toBe("request-one-request-two") + return { + status: 207, + headers: [ + ["content-type", "text/plain"], + ["x-backend", "forwarded"], + ], + body: streams.addBackend( + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("response-one-")) + controller.enqueue(Buffer.from("response-two")) + controller.close() + }, + }), + 25, + ), + } + }) + const gateway = createGateway({ instanceID: "instance-http", rpc, streams }) + + try { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("request-one-")) + controller.enqueue(Buffer.from("request-two")) + controller.close() + }, + }) + const response = await fetch(new URL("/api/items?limit=2&tag=a", gateway.url), { + method: "POST", + headers: { + "content-length": "23", + "content-type": "application/octet-stream", + "x-plugin": "fixture", + }, + body, + duplex: "half", + }) + + expect(response.status).toBe(207) + expect(response.headers.get("x-backend")).toBe("forwarded") + expect(await response.text()).toBe("response-one-response-two") + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + instanceID: "instance-http", + method: "POST", + path: "/api/items?limit=2&tag=a", + }) + expect(requests[0]?.requestID).toMatch(/^[0-9a-f-]{36}$/) + expect(new Headers(requests[0]?.headers).get("x-plugin")).toBe("fixture") + expect(requests[0]?.body?.length).toBe(23) + expect(streams.backendReadCount).toBeGreaterThanOrEqual(2) + } finally { + await gateway.close() + } + }) + + test("streams SSE incrementally and cancels the backend body when the client stops reading", async () => { + const streams = new TestStreams() + const next = Promise.withResolvers() + const never = Promise.withResolvers() + const cancelled = Promise.withResolvers() + streams.onBackendCancel = () => cancelled.resolve() + const rpc = createRpc(async () => ({ + status: 200, + headers: [["content-type", "text/event-stream"]], + body: streams.addBackend( + new ReadableStream({ + async pull(controller) { + if (!streams.backendProduced) { + streams.backendProduced = 1 + controller.enqueue(Buffer.from("data: first\n\n")) + return + } + if (streams.backendProduced === 1) { + await next.promise + streams.backendProduced = 2 + controller.enqueue(Buffer.alloc(256 * 1024, 120)) + return + } + await never.promise + }, + }), + ), + })) + const gateway = createGateway({ instanceID: "instance-sse", rpc, streams }) + + try { + const controller = new AbortController() + const response = await fetch(new URL("/event", gateway.url), { signal: controller.signal }) + expect(response.headers.get("content-type")).toBe("text/event-stream") + const reader = response.body!.getReader() + expect(Buffer.from((await reader.read()).value!).toString()).toBe("data: first\n\n") + expect(streams.backendProduced).toBe(1) + controller.abort("fixture finished") + next.resolve() + await Promise.race([ + cancelled.promise, + Bun.sleep(1_000).then(() => { + throw new Error("Backend stream cancellation was not forwarded") + }), + ]) + } finally { + next.resolve() + await gateway.close() + } + }) + + test("rejects WebSocket upgrades without forwarding them to Rust", async () => { + const streams = new TestStreams() + const methods: string[] = [] + const rpc = createRpc(async (method) => { + methods.push(method) + throw new Error("WebSocket request should not be forwarded") + }) + const gateway = createGateway({ instanceID: "instance-websocket", rpc, streams }) + + try { + const response = await rawHttp( + gateway.url, + [ + "GET /socket HTTP/1.1", + `Host: ${gateway.url.host}`, + "Connection: Upgrade", + "Upgrade: websocket", + "Sec-WebSocket-Version: 13", + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==", + "", + "", + ].join("\r\n"), + ) + + expect(response).toContain(" 426 ") + expect(response).toContain("WebSocket forwarding is not supported") + expect(methods).toEqual([]) + } finally { + await gateway.close() + } + }) + + test("releases request bodies when backend forwarding fails", async () => { + const streams = new TestStreams() + const gateway = createGateway({ + instanceID: "instance-failure", + streams, + rpc: createRpc(async () => { + throw new Error("backend unavailable") + }), + }) + + try { + const response = await fetch(new URL("/failure", gateway.url), { method: "POST", body: "request body" }) + expect(response.status).toBe(502) + expect(streams.hostSize).toBe(0) + } finally { + await gateway.close() + } + }) +}) + +describe("plugin injected API", () => { + test("supports the SDK, raw serverUrl, Bun shell, and workspace registration during initialization", async () => { + const directory = await temporaryDirectory() + const streams = new TestStreams() + const paths: string[] = [] + const rpc = createRpc(async (method, params) => { + expect(method).toBe("backend.http.request") + const request = params as BackendRequest + paths.push(request.path) + if (request.path.startsWith("/project/current")) { + return jsonResponse(streams, { + id: "project-injected", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 2 }, + }) + } + if (request.path === "/raw?fixture=1") { + return textResponse(streams, "raw-gateway-ok") + } + throw new Error(`Unexpected gateway path ${request.path}`) + }) + const host = new ExtensionHost({ + rpc, + streams, + cacheDirectory: path.join(directory, "cache"), + gatewayFactory: createGateway, + preparePlugins, + shell: Bun.$, + }) + const fixture = path.join(import.meta.dir, "fixtures/gateway/injected.ts") + + try { + const opened = await host.open({ + instanceID: "instance-injected", + project: { id: "project-injected" }, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: fixture }], + }) + + expect(opened.diagnostics).toEqual([]) + expect(paths).toHaveLength(2) + expect(paths[0]).toBe(`/project/current?directory=${encodeURIComponent(directory)}`) + expect(paths[1]).toBe("/raw?fixture=1") + expect(opened.config).toMatchObject({ + injectedFixture: { + projectID: "project-injected", + raw: "raw-gateway-ok", + shell: "injected-shell", + serverURL: opened.gatewayURL, + directory, + worktree: directory, + }, + }) + expect(opened.workspaces).toEqual([ + expect.objectContaining({ + type: "fixture-remote", + name: "Fixture remote", + description: "Workspace registered by the injected API fixture", + }), + ]) + + const registrationID = opened.workspaces[0]!.registrationID + const config = { + id: "workspace-1", + type: "fixture-remote", + name: "demo", + branch: null, + directory: null, + extra: null, + projectID: "project-injected", + } + expect(await host.workspaceConfigure({ instanceID: opened.instanceID, registrationID, config })).toEqual({ + config: { ...config, name: "demo-configured" }, + }) + expect(await host.workspaceTarget({ instanceID: opened.instanceID, registrationID, config })).toEqual({ + target: { + type: "remote", + url: "https://workspace.example.test/root", + headers: [ + ["x-fixture", "yes"], + ["x-second", "two"], + ], + }, + }) + } finally { + await host.shutdown() + } + }) +}) + +type BackendRequest = { + instanceID: string + requestID: string + method: string + path: string + headers: Array<[string, string]> + body?: StreamDescriptor +} + +class TestStreams implements StreamBridge { + readonly #host = new StreamRegistry("test-host") + readonly #backend = new StreamRegistry("test-backend") + backendReadCount = 0 + backendProduced = 0 + backendCancelReasons: string[] = [] + onBackendCancel?: () => void + + get hostSize() { + return this.#host.size + } + + register(_instanceID: string, stream: ReadableStream, length?: number) { + return this.#host.add(stream, length) + } + + remote(methodPrefix: "backend" | "host", instanceID: string, descriptor: StreamDescriptor) { + expect(methodPrefix).toBe("backend") + return remoteReadable( + { + request: async (method: string, params: unknown) => { + const input = params as { streamID: string; maxBytes?: number; reason?: string } + if (method === "backend.stream.read") { + this.backendReadCount += 1 + return this.#backend.read(input) as Promise + } + if (method === "backend.stream.cancel") { + if (input.reason) this.backendCancelReasons.push(input.reason) + const result = await this.#backend.cancel(input) + this.onBackendCancel?.() + return result as Result + } + throw new Error(`Unexpected stream method ${method}`) + }, + }, + "backend", + descriptor, + { instanceID }, + ) + } + + async cancel(_instanceID: string, descriptor: StreamDescriptor) { + await this.#host.cancel(descriptor) + } + + async cancelAll(_instanceID: string) { + await this.#host.cancelAll() + } + + addBackend(stream: ReadableStream, length?: number) { + return this.#backend.add(stream, length) + } + + async readHost(descriptor?: StreamDescriptor) { + if (!descriptor) return "" + const chunks: Uint8Array[] = [] + while (true) { + const result = await this.#host.read({ streamID: descriptor.streamID }) + if (result.data) chunks.push(Buffer.from(result.data, "base64")) + if (result.eof) return Buffer.concat(chunks).toString() + } + } +} + +function createRpc(request: (method: string, params: unknown) => Promise): RpcConnection { + return { + request(method: string, params: unknown) { + return request(method, params) as Promise + }, + notify() {}, + } +} + +function jsonResponse(streams: TestStreams, value: unknown) { + const body = JSON.stringify(value) + return { + status: 200, + headers: [ + ["content-type", "application/json"], + ["content-length", String(Buffer.byteLength(body))], + ], + body: streams.addBackend(new Blob([body]).stream(), Buffer.byteLength(body)), + } +} + +function textResponse(streams: TestStreams, value: string) { + return { + status: 200, + headers: [ + ["content-type", "text/plain"], + ["content-length", String(Buffer.byteLength(value))], + ], + body: streams.addBackend(new Blob([value]).stream(), Buffer.byteLength(value)), + } +} + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-gateway-")) + temporaryDirectories.push(directory) + return directory +} + +function rawHttp(url: URL, request: string) { + const deferred = Promise.withResolvers() + const chunks: Buffer[] = [] + const socket = createConnection({ host: url.hostname, port: Number(url.port) }) + socket.on("connect", () => socket.write(request)) + socket.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + const response = Buffer.concat(chunks) + const boundary = response.indexOf("\r\n\r\n") + if (boundary < 0) return + const match = response + .subarray(0, boundary) + .toString() + .match(/content-length:\s*(\d+)/i) + if (!match || response.byteLength < boundary + 4 + Number(match[1])) return + socket.destroy() + deferred.resolve(response.toString()) + }) + socket.on("error", deferred.reject) + socket.on("end", () => deferred.resolve(Buffer.concat(chunks).toString())) + return deferred.promise +} + +function restoreEnvironment(key: string, value?: string) { + if (value === undefined) { + delete process.env[key] + return + } + process.env[key] = value +} diff --git a/src/apps/extension-host/test/helpers/process-host.ts b/src/apps/extension-host/test/helpers/process-host.ts new file mode 100644 index 000000000..099493c43 --- /dev/null +++ b/src/apps/extension-host/test/helpers/process-host.ts @@ -0,0 +1,146 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "node:os" +import { DEFAULT_MAX_FRAME_BYTES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" +import { RpcPeer } from "../../src/rpc" + +type Handshake = { + token: string + protocolVersion: number + opencodeVersion: string + maxFrameBytes: number +} + +export async function launchExtensionHost( + input: { token?: string; acceptedToken?: string; maxFrameBytes?: number; logLevel?: string } = {}, +) { + const root = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-process-")) + const cacheDirectory = path.join(root, "cache") + await mkdir(cacheDirectory, { recursive: true }) + + const accepted = Promise.withResolvers<{ + peer: RpcPeer + write(data: Uint8Array): void + }>() + const handshake = Promise.withResolvers() + const peers = new WeakMap() + const maxFrameBytes = input.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + const peer = new RpcPeer(socket, { idPrefix: "backend" }) + peers.set(socket, peer) + peer.handle("backend.handshake", (value) => { + const params = value as Handshake + handshake.resolve(params) + if (params.token !== (input.acceptedToken ?? "test-rpc-token")) { + throw Object.assign(new Error("Invalid extension host RPC token"), { + code: -32001, + data: { kind: "authentication_failed" }, + }) + } + peer.setMaxFrameBytes(maxFrameBytes) + return { protocolVersion: PROTOCOL_VERSION, maxFrameBytes, cacheDirectory } + }) + accepted.resolve({ + peer, + write(data) { + socket.write(data) + }, + }) + }, + data(socket, data) { + peers.get(socket)?.receive(data) + }, + close(socket) { + peers.get(socket)?.end() + }, + error(socket, error) { + peers.get(socket)?.end(error) + }, + }, + }) + const extensionHostDirectory = path.resolve(import.meta.dir, "..", "..") + const child = Bun.spawn({ + cmd: [process.execPath, path.join(extensionHostDirectory, "src", "main.ts")], + cwd: extensionHostDirectory, + env: { + ...process.env, + OPENCODE_EXTENSION_HOST_RPC_ADDRESS: `127.0.0.1:${server.port}`, + OPENCODE_EXTENSION_HOST_RPC_TOKEN: input.token ?? "test-rpc-token", + OPENCODE_EXTENSION_HOST_LOG_LEVEL: input.logLevel ?? "debug", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }) + const stdout = new Response(child.stdout).text() + const stderr = new Response(child.stderr).text() + + try { + const connection = await deadline(accepted.promise, 5_000, "extension host did not connect") + const seenHandshake = await deadline(handshake.promise, 5_000, "extension host did not handshake") + return { + root, + cacheDirectory, + peer: connection.peer, + write: connection.write, + handshake: seenHandshake, + child, + stdout, + stderr, + waitForExit(timeout = 5_000) { + return deadline(child.exited, timeout, "extension host did not exit") + }, + async cleanup() { + connection.peer.close() + const exited = await Promise.race([child.exited.then(() => true), Bun.sleep(250).then(() => false)]) + if (!exited) child.kill() + await child.exited + server.stop(true) + await rm(root, { recursive: true, force: true }) + }, + } + } catch (error) { + child.kill() + await child.exited + server.stop(true) + await rm(root, { recursive: true, force: true }) + throw error + } +} + +export function rawFrame(payload: string) { + const bytes = new TextEncoder().encode(payload) + const frame = new Uint8Array(bytes.byteLength + 4) + new DataView(frame.buffer).setUint32(0, bytes.byteLength, false) + frame.set(bytes, 4) + return frame +} + +export function oversizedFrameHeader(length: number) { + const frame = new Uint8Array(4) + new DataView(frame.buffer).setUint32(0, length, false) + return frame +} + +export function expectedHandshake(token = "test-rpc-token") { + return { + token, + protocolVersion: PROTOCOL_VERSION, + opencodeVersion: OPENCODE_VERSION, + maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + } +} + +async function deadline(promise: Promise, milliseconds: number, message: string) { + const timeout = Promise.withResolvers() + const timer = setTimeout(() => timeout.reject(new Error(message)), milliseconds) + try { + return await Promise.race([promise, timeout.promise]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/apps/extension-host/test/host.test.ts b/src/apps/extension-host/test/host.test.ts new file mode 100644 index 000000000..12452c244 --- /dev/null +++ b/src/apps/extension-host/test/host.test.ts @@ -0,0 +1,653 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdtemp, mkdir, rm } from "node:fs/promises" +import path from "node:path" +import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" +import { ExtensionHost } from "../src/host" +import { createGateway } from "../src/gateway" +import { preparePlugins, type LoadPluginsInput, type PreparePluginsResult } from "../src/loader" +import { HostMethodSchemas } from "../src/protocol" +import type { WireValue } from "../src/wire" + +const temporaryDirectories: string[] = [] +const hosts: ExtensionHost[] = [] +const fixtures = path.join(import.meta.dir, "fixtures", "runtime") + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.shutdown())) + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe("ExtensionHost lifecycle and hooks", () => { + test("shares an in-flight preparation between prepare and instance open", async () => { + const gate = Promise.withResolvers() + let prepareCalls = 0 + const harness = await createHarness(async (input) => { + prepareCalls += 1 + await gate.promise + return preparePlugins(input) + }) + const directory = await projectDirectory(harness.root, "prewarm") + const plugin = path.join(harness.root, "prewarm.ts") + await Bun.write(plugin, 'export default { id: "fixture.prewarm", server: async () => ({}) }\n') + const configurationFingerprint = "fixture-prewarm" + const plugins = [{ spec: plugin }] + + const preparing = harness.host.prepare({ plugins, configurationFingerprint }) + const opening = harness.host.open({ + instanceID: "prewarm", + project: {}, + directory, + worktree: directory, + config: {}, + plugins, + configurationFingerprint, + }) + await waitFor(() => prepareCalls === 1) + expect(prepareCalls).toBe(1) + + gate.resolve() + const [prepared, opened] = await Promise.all([preparing, opening]) + expect(prepared.prepared).toHaveLength(1) + expect(opened.instanceID).toBe("prewarm") + expect(prepareCalls).toBe(1) + }) + + test("isolates config and dispose failures while preserving shared sequential mutations", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const disposeMarker = path.join(harness.root, "dispose.txt") + const opened = await harness.host.open({ + instanceID: "lifecycle", + project: { id: "project" }, + directory, + worktree: directory, + config: { order: [] }, + plugins: [ + { + spec: path.join(fixtures, "sequence-a.js"), + options: { configFails: true, disposeFails: true, disposeMarker }, + }, + { spec: path.join(fixtures, "sequence-b.js"), options: { disposeMarker } }, + ], + }) + + expect(opened.config).toMatchObject({ order: ["a", "b"] }) + expect(opened.diagnostics).toHaveLength(1) + expect(opened.diagnostics[0]).toMatchObject({ code: "runtime", method: "runtime" }) + expect(opened.hooks).toContain("chat.message") + + const called = await harness.host.callHook({ + instanceID: "lifecycle", + name: "chat.message", + input: { order: [] }, + output: { order: [] }, + }) + expect(called).toEqual({ input: { order: ["a", "b"] }, output: { order: ["a", "b"] } }) + + expect(await harness.host.close({ instanceID: "lifecycle" })).toEqual({ closed: true }) + expect(await Bun.file(disposeMarker).text()).toBe("a\nb\n") + expect(harness.rpc.notifications).toContainEqual( + expect.objectContaining({ + method: "backend.diagnostic.publish", + params: expect.objectContaining({ instanceID: "lifecycle" }), + }), + ) + }) + + test("stops an operational hook at the first failure and dispatches events independently", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const hookMarker = path.join(harness.root, "hook.txt") + const eventMarker = path.join(harness.root, "event.txt") + await harness.host.open({ + instanceID: "failures", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [ + { + spec: path.join(fixtures, "sequence-a.js"), + options: { hookFails: true, hookMarker, eventFails: true, eventMarker }, + }, + { spec: path.join(fixtures, "sequence-b.js"), options: { hookMarker, eventMarker } }, + ], + }) + + await expect( + harness.host.callHook({ + instanceID: "failures", + name: "chat.message", + input: { order: [] }, + output: { order: [] }, + }), + ).rejects.toMatchObject({ + code: -32003, + data: expect.objectContaining({ operation: "chat.message" }), + }) + expect(await Bun.file(hookMarker).text()).toBe("a\n") + + expect(harness.host.emitEvent({ instanceID: "failures", event: { type: "fixture" } })).toEqual({ accepted: true }) + await waitFor( + async () => + (await Bun.file(eventMarker) + .text() + .catch(() => "")) === "a\nb\n", + ) + expect(harness.rpc.notifications).toContainEqual( + expect.objectContaining({ + method: "backend.diagnostic.publish", + params: expect.objectContaining({ instanceID: "failures" }), + }), + ) + }) +}) + +describe("ExtensionHost tools", () => { + test("projects schemas and preserves metadata, permission, results, attachments, and cancellation", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + harness.rpc.onRequest("backend.tool.ask", async () => { + await Bun.sleep(5) + return {} + }) + const opened = await openFull(harness, "tools", directory) + const registration = opened.tools.find((tool) => tool.id === "fixture.echo") + expect(registration?.parameters).toMatchObject({ + type: "object", + properties: { value: { type: "string", description: "Value to echo" } }, + required: ["value"], + }) + + const result = await harness.host.executeTool({ + instanceID: "tools", + registrationID: registration!.registrationID, + executionID: "execute-1", + args: { value: "hello" }, + context: { sessionID: "session", messageID: "message", agent: "agent", callID: "call" }, + }) + expect(result).toEqual({ + title: "echo:hello", + output: `hello:${directory}:${directory}`, + metadata: { sessionID: "session", callID: "call" }, + attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + }) + expect(harness.rpc.notifications).toContainEqual({ + method: "backend.tool.metadata", + params: { + instanceID: "tools", + executionID: "execute-1", + title: "metadata:hello", + metadata: { phase: "before-ask" }, + }, + }) + expect(harness.rpc.requests).toContainEqual( + expect.objectContaining({ + method: "backend.tool.ask", + params: expect.objectContaining({ + instanceID: "tools", + executionID: "execute-1", + permission: "fixture.execute", + patterns: ["hello"], + }), + }), + ) + + const pending = harness.host.executeTool({ + instanceID: "tools", + registrationID: registration!.registrationID, + executionID: "execute-2", + args: { value: "wait", waitForAbort: true }, + context: { sessionID: "session", messageID: "message", agent: "agent" }, + }) + await waitFor(() => harness.rpc.requests.some((request) => request.params.executionID === "execute-2")) + expect(harness.host.cancelTool({ instanceID: "tools", executionID: "execute-2" })).toEqual({ cancelled: true }) + await expect(pending).rejects.toMatchObject({ + code: -32003, + data: expect.objectContaining({ operation: "tool:fixture.echo" }), + }) + }) + + test("keeps legacy function registration metadata JSON-compatible", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "legacy-tool") + const plugin = path.join(harness.root, "legacy-tool.ts") + await Bun.write( + plugin, + `export default async () => ({ + tool: { + legacy: { description: "legacy", args: {}, execute: async () => "ok" }, + }, + })\n`, + ) + const opened = await harness.host.open({ + instanceID: "legacy-tool", + project: {}, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: plugin }], + }) + + expect(opened.tools[0]!.plugin).not.toHaveProperty("id") + expect(HostMethodSchemas["host.instance.open"].result.safeParse(opened).success).toBe(true) + }) +}) + +describe("ExtensionHost auth", () => { + test("keeps auth getters live and supports prompts, API and OAuth authorization, and streaming fetch handles", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + let authRead = 0 + harness.rpc.onRequest("backend.auth.get", () => ({ auth: { type: "api", key: `key-${++authRead}` } })) + const opened = await openFull(harness, "auth", directory) + expect(opened.auth[0]).toMatchObject({ + provider: "fixture-auth", + hasLoader: true, + methods: [ + { type: "api", methodIndex: 0, hasAuthorize: true }, + { type: "oauth", methodIndex: 1, hasAuthorize: true }, + ], + }) + + expect( + harness.host.evaluateAuthPrompt({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + promptIndex: 0, + operation: "validate", + value: "bad", + inputs: {}, + }), + ).toEqual({ operation: "validate", error: "Token must start with ok-" }) + expect( + harness.host.evaluateAuthPrompt({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + promptIndex: 0, + operation: "condition", + inputs: { enabled: "yes" }, + }), + ).toEqual({ operation: "condition", active: true }) + + const first = await harness.host.loadAuth({ + instanceID: "auth", + provider: "fixture-auth", + providerInfo: { id: "fixture-auth" }, + }) + const second = await harness.host.loadAuth({ + instanceID: "auth", + provider: "fixture-auth", + providerInfo: { id: "fixture-auth" }, + }) + expect(first).toMatchObject({ value: { credential: "key-1", providerID: "fixture-auth" } }) + expect(second).toMatchObject({ value: { credential: "key-2", providerID: "fixture-auth" } }) + expect(first.fetchID).not.toBe(second.fetchID) + + expect( + await harness.host.authorize({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 0, + inputs: { token: "ok-secret" }, + }), + ).toEqual({ + type: "api", + result: { type: "success", key: "ok-secret", provider: "fixture-auth", metadata: { source: "fixture" } }, + }) + const oauth = await harness.host.authorize({ + instanceID: "auth", + provider: "fixture-auth", + methodIndex: 1, + }) + if (oauth.type !== "oauth") throw new Error("Expected an OAuth flow") + if (!oauth.flowID) throw new Error("Expected an OAuth flow handle") + const flowID = oauth.flowID + expect(oauth).toMatchObject({ type: "oauth", method: "code", url: "https://auth.example/authorize" }) + expect(await harness.host.authCallback({ instanceID: "auth", flowID, code: "good" })).toEqual({ + type: "success", + key: "oauth-key", + provider: "fixture-auth", + metadata: { code: "good" }, + }) + expect(harness.host.cancelAuthFlow({ instanceID: "auth", flowID })).toEqual({ cancelled: false }) + + const requestBody = harness.streams.remoteDescriptor("auth", "request-body") + const fetched = await harness.host.authFetch({ + instanceID: "auth", + fetchID: first.fetchID!, + requestID: "fetch-request", + request: { + url: "https://api.example/resource", + method: "POST", + body: requestBody, + }, + }) + expect(fetched).toMatchObject({ status: 201, headers: expect.arrayContaining([["x-fixture-fetch", "yes"]]) }) + expect(await harness.streams.text(fetched.body!)).toBe("POST:https://api.example/resource:request-body") + + const pending = harness.host.authFetch({ + instanceID: "auth", + fetchID: first.fetchID!, + requestID: "fetch-cancel", + request: { url: "https://api.example/wait" }, + }) + expect(harness.host.cancelAuthFetch({ instanceID: "auth", requestID: "fetch-cancel", reason: "stop" })).toEqual({ + cancelled: true, + }) + await expect(pending).rejects.toMatchObject({ code: -32003 }) + expect(harness.host.cancelAuthFetch({ instanceID: "auth", requestID: "fetch-cancel" })).toEqual({ + cancelled: false, + }) + expect(harness.host.releaseAuthFetch({ instanceID: "auth", fetchID: first.fetchID! })).toEqual({ released: true }) + }) +}) + +describe("ExtensionHost providers and workspaces", () => { + test("dispatches provider models and normalizes workspace operations at the wire boundary", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "project") + const workspaceMarker = path.join(harness.root, "workspace.txt") + const opened = await openFull(harness, "adapters", directory, { workspaceMarker }) + expect(opened.providers).toContainEqual(expect.objectContaining({ provider: "fixture-provider", hasModels: true })) + expect( + await harness.host.providerModels({ + instanceID: "adapters", + providerID: "fixture-provider", + provider: { id: "fixture-provider" }, + auth: { type: "api", key: "secret" }, + }), + ).toEqual({ + models: { + "fixture-model": { id: "fixture-model", providerID: "fixture-provider", name: "Fixture api" }, + }, + }) + + const registration = opened.workspaces.find((workspace) => workspace.type === "fixture-remote")! + const config = workspaceConfig("workspace") + expect( + await harness.host.workspaceConfigure({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }), + ).toEqual({ config: { ...config, name: "configured:workspace" } }) + await harness.host.workspaceCreate({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + env: { FIXTURE: "present", OMITTED: null }, + from: { ...config, id: "source" }, + }) + await harness.host.workspaceRemove({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }) + expect(await Bun.file(workspaceMarker).text()).toBe("create:workspace:present:source\nremove:workspace\n") + expect( + await harness.host.workspaceTarget({ + instanceID: "adapters", + registrationID: registration.registrationID, + config, + }), + ).toEqual({ + target: { + type: "remote", + url: "https://workspace.example/workspace?branch=dev", + headers: [ + ["authorization", "Bearer fixture"], + ["x-workspace", "workspace"], + ], + }, + }) + }) +}) + +describe("ExtensionHost instance isolation", () => { + test("supports independent directories, process-wide module caching, reopen, and one-shot disposal", async () => { + const harness = await createHarness() + const firstDirectory = await projectDirectory(harness.root, "first") + const secondDirectory = await projectDirectory(harness.root, "second") + const marker = path.join(harness.root, "dispose.txt") + const first = await openFull(harness, "first", firstDirectory, { disposeMarker: marker }) + const second = await openFull(harness, "second", secondDirectory, { disposeMarker: marker }) + const firstRuntime = (first.config as { runtime: { moduleToken: string; run: number } }).runtime + const secondRuntime = (second.config as { runtime: { moduleToken: string; run: number } }).runtime + expect(secondRuntime.moduleToken).toBe(firstRuntime.moduleToken) + expect(secondRuntime.run).toBe(firstRuntime.run + 1) + + await expect(openFull(harness, "duplicate", firstDirectory)).rejects.toMatchObject({ code: -32002 }) + expect(await harness.host.close({ instanceID: "first" })).toEqual({ closed: true }) + expect(await harness.host.close({ instanceID: "first" })).toEqual({ closed: false }) + const remaining = await harness.host.callHook({ + instanceID: "second", + name: "chat.message", + input: { trace: [] }, + output: { trace: [] }, + }) + expect(remaining.output).toEqual({ trace: ["full"] }) + + const reopened = await openFull(harness, "reopened", firstDirectory, { disposeMarker: marker }) + const reopenedRuntime = (reopened.config as { runtime: { moduleToken: string; run: number } }).runtime + expect(reopenedRuntime.moduleToken).toBe(firstRuntime.moduleToken) + expect(reopenedRuntime.run).toBe(secondRuntime.run + 1) + expect(await Bun.file(marker).text()).toBe(`full:${firstRuntime.run}\n`) + }) + + test("reserves instance IDs and waits for opening plugins during shutdown", async () => { + const harness = await createHarness() + const firstDirectory = await projectDirectory(harness.root, "first-race") + const secondDirectory = await projectDirectory(harness.root, "second-race") + const plugin = path.join(harness.root, "opening-plugin.ts") + const started = path.join(harness.root, "started.txt") + const disposed = path.join(harness.root, "disposed.txt") + await Bun.write( + plugin, + `export default { + id: "fixture.opening", + server: async (_input, options) => { + await Bun.write(options.started, "started") + await Bun.sleep(30) + return { async dispose() { await Bun.write(options.disposed, "disposed") } } + }, + }\n`, + ) + + const cancelledBeforeReady = harness.host.open({ + instanceID: "cancel-before-ready", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }) + const cancelledResult = cancelledBeforeReady.then( + () => undefined, + (error) => error, + ) + expect(await harness.host.close({ instanceID: "cancel-before-ready" })).toEqual({ closed: true }) + expect(await cancelledResult).toMatchObject({ code: -32004 }) + + const opening = harness.host.open({ + instanceID: "opening", + project: {}, + directory: firstDirectory, + worktree: firstDirectory, + config: {}, + plugins: [{ spec: plugin, options: { started, disposed } }], + }) + await expect( + harness.host.open({ + instanceID: "opening", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ code: -32002 }) + await waitFor(() => Bun.file(started).exists()) + + const shutdown = harness.host.shutdown() + await expect(opening).rejects.toMatchObject({ code: -32004 }) + await shutdown + expect(await Bun.file(disposed).text()).toBe("disposed") + await expect( + harness.host.open({ + instanceID: "after-shutdown", + project: {}, + directory: secondDirectory, + worktree: secondDirectory, + config: {}, + plugins: [], + }), + ).rejects.toMatchObject({ code: -32004 }) + }) + + test("concurrent closes join one disposer run", async () => { + const harness = await createHarness() + const directory = await projectDirectory(harness.root, "close-race") + const marker = path.join(harness.root, "close-race.txt") + await openFull(harness, "close-race", directory, { disposeMarker: marker }) + + const first = harness.host.close({ instanceID: "close-race" }) + const second = harness.host.close({ instanceID: "close-race" }) + expect(await Promise.all([first, second])).toEqual([{ closed: true }, { closed: false }]) + expect((await Bun.file(marker).text()).trim().split("\n")).toHaveLength(1) + }) +}) + +class FakeRpc implements RpcConnection { + readonly requests: Array<{ method: string; params: Record }> = [] + readonly notifications: Array<{ method: string; params: unknown }> = [] + readonly #handlers = new Map) => unknown | Promise>() + + onRequest(method: string, handler: (params: Record) => unknown | Promise) { + this.#handlers.set(method, handler) + } + + async request(method: string, params: unknown): Promise { + this.requests.push({ method, params: params as Record }) + const handler = this.#handlers.get(method) + return (handler ? await handler(params as Record) : {}) as Result + } + + notify(method: string, params: unknown) { + this.notifications.push({ method, params }) + } +} + +class FakeStreams implements StreamBridge { + readonly #streams = new Map>() + #counter = 0 + + register(instanceID: string, stream: ReadableStream, length?: number) { + const descriptor = { + streamID: `${instanceID}:stream:${++this.#counter}`, + ...(length === undefined ? {} : { length }), + } + this.#streams.set(descriptor.streamID, stream) + return descriptor + } + + remote(_methodPrefix: "backend" | "host", _instanceID: string, descriptor: StreamDescriptor) { + const stream = this.#streams.get(descriptor.streamID) + if (!stream) throw new Error(`Unknown test stream ${descriptor.streamID}`) + return stream + } + + async cancel(_instanceID: string, descriptor: StreamDescriptor) { + await this.#streams.get(descriptor.streamID)?.cancel() + this.#streams.delete(descriptor.streamID) + } + + async cancelAll(instanceID: string) { + await Promise.all( + Array.from(this.#streams) + .filter(([streamID]) => streamID.startsWith(`${instanceID}:`)) + .map(async ([streamID, stream]) => { + await stream.cancel().catch(() => {}) + this.#streams.delete(streamID) + }), + ) + } + + remoteDescriptor(instanceID: string, body: string) { + return this.register(instanceID, new Blob([body]).stream(), body.length) + } + + async text(descriptor: StreamDescriptor) { + const stream = this.#streams.get(descriptor.streamID) + if (!stream) throw new Error(`Unknown test stream ${descriptor.streamID}`) + return new Response(stream).text() + } +} + +async function createHarness( + prepare: (input: LoadPluginsInput) => Promise = preparePlugins, +) { + const root = await temporaryDirectory() + const rpc = new FakeRpc() + const streams = new FakeStreams() + const host = new ExtensionHost({ + rpc, + streams, + cacheDirectory: path.join(root, "cache"), + gatewayFactory: createGateway, + preparePlugins: prepare, + shell: Bun.$, + }) + hosts.push(host) + return { root, rpc, streams, host } +} + +async function openFull( + harness: Awaited>, + instanceID: string, + directory: string, + options: Record = {}, +) { + return harness.host.open({ + instanceID, + project: { id: "project" }, + directory, + worktree: directory, + config: {}, + plugins: [{ spec: path.join(fixtures, "full.js"), options }], + }) +} + +async function projectDirectory(root: string, name: string) { + const directory = path.join(root, name) + await mkdir(directory) + return directory +} + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-runtime-")) + temporaryDirectories.push(directory) + return directory +} + +async function waitFor(predicate: () => boolean | Promise) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await predicate()) return + await Bun.sleep(5) + } + throw new Error("Timed out waiting for runtime fixture") +} + +function workspaceConfig(id: string) { + return { + id, + type: "fixture-remote", + name: id, + branch: "dev", + directory: null, + extra: null, + projectID: "project", + } +} diff --git a/src/apps/extension-host/test/loader.test.ts b/src/apps/extension-host/test/loader.test.ts new file mode 100644 index 000000000..e425465ac --- /dev/null +++ b/src/apps/extension-host/test/loader.test.ts @@ -0,0 +1,422 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { z } from "zod" +import { + extractServerEntrypoints, + loadPreparedPlugins, + loadPlugins, + normalizePluginDeclarations, + parseNpmPluginSpecifier, + preparePlugins, +} from "../src/loader" +import { installNpmPlugin } from "../src/bun-loader" +import { toolParametersToJsonSchema, validateToolArguments } from "../src/tool-schema" +import { WireValueError, cloneWireValue } from "../src/wire" + +const temporaryDirectories: string[] = [] +const fixtures = path.join(import.meta.dir, "fixtures", "loader") + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe("plugin loader", () => { + test("normalizes relative specs and keeps the final declaration for each identity", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin.ts") + await Bun.write(plugin, 'export default { id: "fixture.dedupe", server: async () => ({}) }\n') + + const result = await normalizePluginDeclarations([ + { spec: pathToFileURL(plugin).href, options: { order: 1 } }, + { spec: "./plugin.ts", options: { order: 2 }, baseDirectory: directory }, + ]) + + expect(result.diagnostics).toEqual([]) + expect(result.declarations).toHaveLength(1) + expect(result.declarations[0]?.options).toEqual({ order: 2 }) + expect(result.declarations[0]?.resolvedSpec).toBe(pathToFileURL(await realpath(plugin)).href) + }) + + test("prefers the default object-form plugin and exposes entrypoints without executing them", async () => { + const result = await loadPlugins({ + declarations: [path.join(fixtures, "preferred.ts")], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded).toHaveLength(1) + expect(result.loaded[0]?.entrypoints).toHaveLength(1) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.preferred") + expect(result.loaded[0]?.entrypoints[0]?.index).toBe(0) + }) + + test("prepares plugin entrypoints without importing their modules", async () => { + const directory = await temporaryDirectory() + const marker = path.join(directory, "imported.txt") + const plugin = path.join(directory, "plugin.ts") + await Bun.write( + plugin, + `await Bun.write(${JSON.stringify(marker)}, "imported")\nexport default { id: "fixture.prepared", server: async () => ({}) }\n`, + ) + + const prepared = await preparePlugins({ declarations: [plugin], cacheDirectory: directory }) + + expect(prepared.diagnostics).toEqual([]) + expect(prepared.prepared).toHaveLength(1) + expect(await Bun.file(marker).exists()).toBe(false) + + const loaded = await loadPreparedPlugins(prepared) + expect(loaded.loaded[0]?.entrypoints[0]?.id).toBe("fixture.prepared") + expect(await Bun.file(marker).exists()).toBe(true) + }) + + test("deduplicates legacy exports by exported value identity", async () => { + const module = await import(pathToFileURL(path.join(fixtures, "legacy.ts")).href) + const entrypoints = extractServerEntrypoints({ + module, + source: "file", + spec: path.join(fixtures, "legacy.ts"), + }) + + expect(entrypoints).toHaveLength(1) + expect(entrypoints[0]?.index).toBe(0) + }) + + test("resolves package ./server import before default and main", async () => { + const directory = await temporaryDirectory() + await Bun.write( + path.join(directory, "package.json"), + JSON.stringify({ + name: "fixture-entry", + exports: { "./server": { import: "./import.ts", default: "./default.ts" } }, + main: "./main.ts", + }), + ) + await Bun.write( + path.join(directory, "import.ts"), + 'export default { id: "fixture.import", server: async () => ({}) }\n', + ) + await Bun.write(path.join(directory, "default.ts"), 'throw new Error("default entry loaded")\n') + await Bun.write(path.join(directory, "main.ts"), 'throw new Error("main entry loaded")\n') + + const result = await loadPlugins({ + declarations: [directory], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.import") + }) + + test("uses a local directory index when no package manifest exists", async () => { + const directory = await temporaryDirectory() + await Bun.write( + path.join(directory, "index.ts"), + 'export default { id: "fixture.index", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ + declarations: [directory], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.index") + }) + + test("falls back to package main when no server export exists", async () => { + const directory = await temporaryDirectory() + await Bun.write(path.join(directory, "package.json"), JSON.stringify({ name: "fixture-main", main: "main.ts" })) + await Bun.write( + path.join(directory, "main.ts"), + 'export default { id: "fixture.main", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ declarations: [directory], cacheDirectory: await temporaryDirectory() }) + + expect(result.diagnostics).toEqual([]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("fixture.main") + }) + + test("isolates import and module-shape failures from successful neighbors", async () => { + const directory = await temporaryDirectory() + await Bun.write(path.join(directory, "bad-import.ts"), 'throw new Error("bad import")\n') + await Bun.write(path.join(directory, "bad-shape.ts"), "export const value = 1\n") + await Bun.write( + path.join(directory, "good.ts"), + 'export default { id: "fixture.good", server: async () => ({}) }\n', + ) + + const result = await loadPlugins({ + declarations: [ + path.join(directory, "bad-import.ts"), + path.join(directory, "bad-shape.ts"), + path.join(directory, "good.ts"), + ], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded.flatMap((plugin) => plugin.entrypoints.map((entrypoint) => entrypoint.id))).toEqual([ + "fixture.good", + ]) + expect(result.diagnostics.map((diagnostic) => diagnostic.stage)).toEqual(["load", "shape"]) + }) + + test("rejects invalid object-form path plugins and TUI/server hybrids", () => { + expect(() => + extractServerEntrypoints({ + module: { default: { server: async () => ({}) } }, + source: "file", + spec: "missing-id.ts", + }), + ).toThrow("must export id") + expect(() => + extractServerEntrypoints({ + module: { default: { id: "fixture.hybrid", server: async () => ({}), tui: async () => ({}) } }, + source: "file", + spec: "hybrid.ts", + }), + ).toThrow("either server() or tui()") + }) + + test("imports candidates concurrently but returns them in declaration order", async () => { + const directory = await temporaryDirectory() + const marker = path.join(directory, "imports.txt") + await Bun.write( + path.join(directory, "slow.ts"), + [ + "await Bun.sleep(20)", + `await Bun.write(${JSON.stringify(marker)}, (await Bun.file(${JSON.stringify(marker)}).text().catch(() => "")) + "slow\\n")`, + 'export default { id: "fixture.slow", server: async () => ({}) }', + ].join("\n"), + ) + await Bun.write( + path.join(directory, "fast.ts"), + [ + `await Bun.write(${JSON.stringify(marker)}, (await Bun.file(${JSON.stringify(marker)}).text().catch(() => "")) + "fast\\n")`, + 'export default { id: "fixture.fast", server: async () => ({}) }', + ].join("\n"), + ) + + const result = await loadPlugins({ + declarations: [path.join(directory, "slow.ts"), path.join(directory, "fast.ts")], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded.flatMap((plugin) => plugin.entrypoints.map((entrypoint) => entrypoint.id))).toEqual([ + "fixture.slow", + "fixture.fast", + ]) + expect(await Bun.file(marker).text()).toBe("fast\nslow\n") + }) + + test("rejects a package entry that escapes through a symlink", async () => { + const directory = await temporaryDirectory() + const plugin = path.join(directory, "plugin") + const outside = path.join(directory, "outside") + await mkdir(plugin) + await mkdir(outside) + await Bun.write( + path.join(plugin, "package.json"), + JSON.stringify({ exports: { "./server": "./escape/server.ts" } }), + ) + await Bun.write( + path.join(outside, "server.ts"), + 'export default { id: "fixture.escape", server: async () => ({}) }\n', + ) + await symlink(outside, path.join(plugin, "escape"), "dir") + + const result = await loadPlugins({ + declarations: [plugin], + cacheDirectory: await temporaryDirectory(), + }) + + expect(result.loaded).toEqual([]) + expect(result.diagnostics[0]?.stage).toBe("entry") + expect(result.diagnostics[0]?.message).toContain("outside plugin directory") + }) + + test("checks npm engines against OpenCode 1.17.18 and isolates failures", async () => { + const directory = await temporaryDirectory() + const incompatible = path.join(directory, "incompatible") + const compatible = path.join(directory, "compatible") + await Promise.all([mkdir(incompatible), mkdir(compatible)]) + await Bun.write( + path.join(incompatible, "package.json"), + JSON.stringify({ name: "incompatible", engines: { opencode: ">=2" }, main: "./index.ts" }), + ) + await Bun.write(path.join(incompatible, "index.ts"), "export default { server: async () => ({}) }\n") + await Bun.write( + path.join(compatible, "package.json"), + JSON.stringify({ name: "compatible", engines: { opencode: "^1.17.0" }, main: "./index.ts" }), + ) + await Bun.write(path.join(compatible, "index.ts"), "export default { server: async () => ({}) }\n") + + const result = await loadPlugins({ + declarations: ["incompatible@1.0.0", "compatible@1.0.0"], + cacheDirectory: await temporaryDirectory(), + install: async (input) => (input.packageName === "incompatible" ? incompatible : compatible), + }) + + expect(result.loaded.map((plugin) => plugin.spec)).toEqual(["compatible@1.0.0"]) + expect(result.loaded[0]?.entrypoints[0]?.id).toBe("compatible") + expect(result.diagnostics).toHaveLength(1) + expect(result.diagnostics[0]?.stage).toBe("compatibility") + }) + + test("parses scoped, alias, tarball, and git npm specs without inventing filesystem package names", () => { + expect(parseNpmPluginSpecifier("@scope/plugin@2.3.4")).toMatchObject({ + packageName: "@scope/plugin", + identity: "@scope/plugin", + }) + expect(parseNpmPluginSpecifier("alias-plugin@npm:@scope/plugin@2.3.4")).toMatchObject({ + packageName: "alias-plugin", + identity: "alias-plugin", + }) + expect(parseNpmPluginSpecifier("https://example.com/plugin.tgz")).toMatchObject({ + packageName: undefined, + type: "remote", + }) + expect(parseNpmPluginSpecifier("github:example/plugin#main")).toMatchObject({ + packageName: undefined, + type: "git", + }) + expect(parseNpmPluginSpecifier("file:./plugin.tgz", "/tmp/extension-host-base").installSpec).toBe( + "file:/tmp/extension-host-base/plugin.tgz", + ) + }) + + test("installs npm packages with lifecycle scripts disabled", async () => { + const directory = await temporaryDirectory() + const source = path.join(directory, "source") + const marker = path.join(directory, "postinstall.txt") + await mkdir(source) + await Bun.write( + path.join(source, "package.json"), + JSON.stringify({ + name: "fixture-install", + version: "1.0.0", + main: "./index.js", + scripts: { postinstall: `bun -e 'Bun.write(${JSON.stringify(marker)}, "ran")'` }, + }), + ) + await Bun.write(path.join(source, "index.js"), "export default { server: async () => ({}) }\n") + + const target = await installNpmPlugin({ + spec: `file:${source}`, + packageName: "fixture-install", + cacheDirectory: path.join(directory, "cache"), + }) + + expect(target.cache).toBe("installed") + expect(await Bun.file(path.join(target.target, "package.json")).exists()).toBe(true) + expect(await Bun.file(marker).exists()).toBe(false) + }) + + test("reuses an installed npm package directory without reinstalling it", async () => { + const directory = await temporaryDirectory() + const cacheDirectory = path.join(directory, "cache") + const spec = "fixture-cache-hit@1.0.0" + const installDirectory = path.join( + cacheDirectory, + "plugins", + `fixture-cache-hit-${Bun.hash(spec).toString(16)}`, + ) + const target = path.join(installDirectory, "node_modules", "fixture-cache-hit") + await mkdir(target, { recursive: true }) + await Bun.write( + path.join(installDirectory, "package.json"), + JSON.stringify({ dependencies: { "fixture-cache-hit": "1.0.0" } }), + ) + await Bun.write(path.join(target, "package.json"), JSON.stringify({ name: "fixture-cache-hit", version: "1.0.0" })) + + const resolved = await installNpmPlugin({ + spec, + packageName: "fixture-cache-hit", + cacheDirectory, + }) + + expect(resolved).toEqual({ target: await realpath(target), cache: "hit" }) + }) +}) + +describe("wire values", () => { + test("clones plain JSON values without retaining aliases", () => { + const child = { value: 1 } + const input = { left: child, right: child } + const cloned = cloneWireValue(input) as { left: { value: number }; right: { value: number } } + + expect(cloned).toEqual(input) + expect(cloned).not.toBe(input) + expect(cloned.left).not.toBe(child) + expect(cloned.left).not.toBe(cloned.right) + }) + + test("matches JSON omission semantics for nested undefined values", () => { + expect(cloneWireValue({ missing: undefined, values: [undefined, 1] })).toEqual({ values: [null, 1] }) + expect(() => cloneWireValue(undefined)).toThrow("Wire value at $ cannot contain undefined") + }) + + test.each([ + [{ auth: { fetch: () => {} } }, "$.auth.fetch", "function"], + [{ count: 1n }, "$.count", "BigInt"], + [{ values: [Number.NaN] }, "$.values[0]", "non-finite"], + ])("rejects unsupported values with their path", (value, location, kind) => { + expect(() => cloneWireValue(value)).toThrow(location) + expect(() => cloneWireValue(value)).toThrow(kind) + }) + + test("reports the source path of a cycle", () => { + const value: { child?: unknown } = {} + value.child = value + + expect(() => cloneWireValue(value)).toThrow("$.child contains a cycle referencing $") + expect(() => cloneWireValue(value)).toThrow(WireValueError) + }) +}) + +describe("plugin tool schemas", () => { + test("converts Zod argument maps and preserves metadata", () => { + const schema = toolParametersToJsonSchema({ + query: z.string().describe("Search query"), + limit: z.number().int().optional(), + }) as Record + + expect(schema.type).toBe("object") + expect(schema.properties).toEqual({ + query: { type: "string", description: "Search query" }, + limit: { type: "integer", minimum: -9007199254740991, maximum: 9007199254740991 }, + }) + expect(schema.required).toEqual(["query"]) + }) + + test("projects legacy definitions and only requires valid schema entries", () => { + expect( + toolParametersToJsonSchema({ + query: { type: "string" }, + enabled: true, + ignored: "not-json-schema", + }), + ).toEqual({ + type: "object", + properties: { query: { type: "string" }, enabled: true }, + required: ["query", "enabled"], + }) + }) + + test("validates Zod argument maps and passes legacy values through", () => { + expect(validateToolArguments({ count: z.number().int() }, { count: 2, ignored: true })).toEqual({ count: 2 }) + expect(() => validateToolArguments({ count: z.number().int() }, { count: 2.5 })).toThrow() + + const legacy = { count: 2.5 } + expect(validateToolArguments({ count: { type: "integer" } }, legacy)).toBe(legacy) + }) +}) + +async function temporaryDirectory() { + const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-")) + temporaryDirectories.push(directory) + return directory +} diff --git a/src/apps/extension-host/test/process.test.ts b/src/apps/extension-host/test/process.test.ts new file mode 100644 index 000000000..afe973057 --- /dev/null +++ b/src/apps/extension-host/test/process.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { RpcError } from "../src/rpc" +import { expectedHandshake, launchExtensionHost, oversizedFrameHeader, rawFrame } from "./helpers/process-host" + +type Harness = Awaited> + +const running = new Set() + +afterEach(async () => { + await Promise.all(Array.from(running, (harness) => harness.cleanup())) + running.clear() +}) + +describe("extension host process boundary", () => { + test("authenticates its handshake and shuts down over the control socket", async () => { + const harness = await launch() + expect(harness.handshake).toEqual(expectedHandshake()) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + expect(await harness.stdout).toBe("") + const stderr = await harness.stderr + expect(stderr).toContain('"event":"startup.begin"') + expect(stderr).toContain('"event":"rpc.send"') + expect(stderr).toContain('"method":"backend.handshake"') + expect(stderr).toContain('"event":"rpc.receive"') + expect(stderr).toContain('"method":"host.shutdown"') + expect(stderr).toContain('"event":"shutdown.requested"') + expect(stderr).toContain('"event":"shutdown.instances_closed"') + expect(stderr).toContain('"event":"shutdown.complete"') + expect(stderr).not.toContain("test-rpc-token") + }) + + test("logs the plugin names activated during instance open", async () => { + const harness = await launch() + const directory = path.join(harness.root, "activation-project") + const plugin = path.join(harness.root, "activation-plugin.ts") + await mkdir(directory) + await Bun.write(plugin, "export default async () => ({})\n") + + expect( + await harness.peer.request<{ diagnostics: unknown[] }>("host.instance.open", { + instanceID: "activation-instance", + project: {}, + config: {}, + directory, + worktree: directory, + plugins: [{ spec: plugin }], + }), + ).toMatchObject({ diagnostics: [] }) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"event":"plugin.activation.begin"') + expect(stderr).toContain('"event":"plugin.activation.completed"') + expect(stderr).toContain('"event":"plugin.activation.complete"') + expect(stderr).toContain(JSON.stringify([plugin])) + }) + + test("filters debug diagnostics when the configured log level is info", async () => { + const harness = await launchExtensionHost({ logLevel: "info" }) + running.add(harness) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"event":"startup.begin"') + expect(stderr).toContain('"event":"shutdown.complete"') + expect(stderr).not.toContain('"event":"rpc.send"') + expect(stderr).not.toContain('"event":"rpc.receive"') + }) + + test("disables structured diagnostics when the configured log level is off", async () => { + const harness = await launchExtensionHost({ logLevel: "off" }) + running.add(harness) + + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + expect(await harness.stderr).toBe("") + }) + + test("updates the structured log threshold without restarting the host", async () => { + const harness = await launchExtensionHost({ logLevel: "debug" }) + running.add(harness) + + expect(await harness.peer.request<{ level: string }>("host.log.setLevel", { level: "off" })).toEqual({ + level: "off", + }) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + const stderr = await harness.stderr + expect(stderr).toContain('"method":"host.log.setLevel"') + expect(stderr).not.toContain('"method":"host.shutdown"') + expect(stderr).not.toContain('"event":"shutdown.complete"') + }) + + test("exits when the Rust peer rejects its handshake token", async () => { + const harness = await launchExtensionHost({ token: "wrong-token", acceptedToken: "expected-token" }) + running.add(harness) + + expect(harness.handshake).toEqual(expectedHandshake("wrong-token")) + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("Invalid extension host RPC token") + }) + + test("returns structured invalid-parameter errors without dropping the connection", async () => { + const harness = await launch() + const error = (await harness.peer + .request("host.instance.close", { wrong: true }) + .catch((value) => value)) as RpcError + + expect(error).toBeInstanceOf(RpcError) + expect(error).toMatchObject({ + code: -32602, + data: { kind: "invalid_params", method: "host.instance.close" }, + }) + expect(await harness.peer.request<{ closed: boolean }>("host.instance.close", { instanceID: "missing" })).toEqual({ + closed: false, + }) + expect( + await harness.peer.request<{ cancelled: boolean }>("host.stream.cancel", { + instanceID: "missing", + streamID: "missing-stream", + }), + ).toEqual({ cancelled: false }) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + }) + + test("terminates cleanly after a malformed JSON frame", async () => { + const harness = await launch() + await harness.peer.request("host.instance.close", { instanceID: "ready" }) + harness.write(rawFrame("{")) + + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("Invalid JSON-RPC JSON payload") + }) + + test("rejects an oversized frame from the negotiated limit", async () => { + const harness = await launchExtensionHost({ maxFrameBytes: 1024 }) + running.add(harness) + await harness.peer.request("host.instance.close", { instanceID: "ready" }) + harness.write(oversizedFrameHeader(1025)) + + expect(await harness.waitForExit()).toBe(1) + expect(await harness.stderr).toContain("frame length 1025 exceeds limit 1024") + }) + + test("disposes open instances when the Rust-owned socket reaches EOF", async () => { + const harness = await launch() + const directory = path.join(harness.root, "project") + const marker = path.join(harness.root, "disposed.txt") + const plugin = path.join(harness.root, "dispose-plugin.ts") + await mkdir(directory) + await Bun.write( + plugin, + `export default async (_input, options) => ({ + async dispose() { + await Bun.write(options.marker, "disposed") + }, + })\n`, + ) + const result = await harness.peer.request<{ diagnostics: unknown[] }>("host.instance.open", { + instanceID: "eof-instance", + project: {}, + config: {}, + directory, + worktree: directory, + plugins: [{ spec: plugin, options: { marker } }], + }) + expect(result.diagnostics).toEqual([]) + + harness.peer.close() + expect(await harness.waitForExit()).toBe(0) + expect(await Bun.file(marker).text()).toBe("disposed") + }) + + test("allows concurrent out-of-order opens with reentrant backend HTTP", async () => { + const harness = await launch() + const plugin = path.join(harness.root, "initializing-plugin.ts") + const slowDirectory = path.join(harness.root, "slow") + const fastDirectory = path.join(harness.root, "fast") + await Promise.all([mkdir(slowDirectory), mkdir(fastDirectory)]) + await Bun.write( + plugin, + `export default async (input, options) => { + await Bun.sleep(options.delay) + const response = await fetch(new URL("/initialize?name=" + options.name, input.serverUrl)) + return { + config(config) { + config.initialized = { name: options.name, status: response.status } + }, + } + }\n`, + ) + const forwarded: string[] = [] + harness.peer.handle("backend.http.request", (value) => { + forwarded.push((value as { path: string }).path) + return { status: 204, headers: [] } + }) + + const slow = harness.peer.request("host.instance.open", { + instanceID: "slow-instance", + project: {}, + config: {}, + directory: slowDirectory, + worktree: slowDirectory, + plugins: [{ spec: plugin, options: { delay: 150, name: "slow" } }], + }) + await Bun.sleep(10) + const fast = harness.peer.request("host.instance.open", { + instanceID: "fast-instance", + project: {}, + config: {}, + directory: fastDirectory, + worktree: fastDirectory, + plugins: [{ spec: plugin, options: { delay: 0, name: "fast" } }], + }) + + expect(await Promise.race([slow.then(() => "slow"), fast.then(() => "fast")])).toBe("fast") + expect((await fast).config).toEqual({ initialized: { name: "fast", status: 204 } }) + expect((await slow).config).toEqual({ initialized: { name: "slow", status: 204 } }) + expect(forwarded).toEqual(["/initialize?name=fast", "/initialize?name=slow"]) + + await Promise.all([ + harness.peer.request("host.instance.close", { instanceID: "slow-instance" }), + harness.peer.request("host.instance.close", { instanceID: "fast-instance" }), + ]) + expect(await harness.peer.request<{ closed: boolean }>("host.shutdown", {})).toEqual({ closed: true }) + expect(await harness.waitForExit()).toBe(0) + }, 10_000) +}) + +type OpenResult = { + config: Record +} + +async function launch() { + const harness = await launchExtensionHost() + running.add(harness) + return harness +} diff --git a/src/apps/extension-host/test/rpc.test.ts b/src/apps/extension-host/test/rpc.test.ts new file mode 100644 index 000000000..09ba900bf --- /dev/null +++ b/src/apps/extension-host/test/rpc.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test" +import { RpcError, RpcPeer, RpcProtocolError, connectRpcPeer, encodeFrame } from "../src/rpc" +import { HeaderSchema, MAX_MAX_FRAME_BYTES, MAX_STREAM_CHUNK_BYTES } from "../src/protocol" +import { StreamRegistry, remoteReadable } from "../src/streams" + +describe("protocol schema", () => { + test("keeps header pairs exactly two strings at runtime and in generated JSON Schema", async () => { + expect(HeaderSchema.safeParse(["name", "value"]).success).toBe(true) + expect(HeaderSchema.safeParse(["name"]).success).toBe(false) + expect(HeaderSchema.safeParse(["name", "value", "extra"]).success).toBe(false) + + const schema = await Bun.file(new URL("../protocol.schema.json", import.meta.url)).json() + expect(schema.$defs.BackendHttpRequestParams.properties.headers.items).toMatchObject({ + minItems: 2, + maxItems: 2, + type: "array", + items: { type: "string" }, + }) + }) +}) + +describe("RpcPeer", () => { + test("frames JSON with a four-byte big-endian length", () => { + const frame = encodeFrame({ jsonrpc: "2.0", method: "ping", params: {} }) + expect(new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false)).toBe(frame.byteLength - 4) + expect(JSON.parse(new TextDecoder().decode(frame.subarray(4)))).toEqual({ + jsonrpc: "2.0", + method: "ping", + params: {}, + }) + }) + + test("supports concurrent, out-of-order, and reentrant requests", async () => { + const { host, backend } = peerPair() + host.handle("host.decorate", ({ value }: { value: string }) => ({ value: `${value}:host` })) + backend.handle("backend.work", async ({ value, wait }: { value: string; wait: boolean }) => { + if (wait) await new Promise((resolve) => setTimeout(resolve, 10)) + return hostResult(await backend.request<{ value: string }>("host.decorate", { value })) + }) + + const slow = host.request<{ value: string }>("backend.work", { value: "slow", wait: true }) + const fast = host.request<{ value: string }>("backend.work", { value: "fast", wait: false }) + expect(await fast).toEqual({ value: "fast:host:backend" }) + expect(await slow).toEqual({ value: "slow:host:backend" }) + host.close() + backend.close() + }) + + test("parses frames split at arbitrary byte boundaries", async () => { + let right: RpcPeer + const left = new RpcPeer({ write: (data) => deliver(right, data, 1) }, { idPrefix: "host" }) + right = new RpcPeer({ write: (data) => deliver(left, data, 2) }, { idPrefix: "backend" }) + right.handle("backend.echo", (params) => params) + expect( + await left.request<{ unicode: string; list: number[] }>("backend.echo", { unicode: "你好", list: [1, 2, 3] }), + ).toEqual({ + unicode: "你好", + list: [1, 2, 3], + }) + }) + + test("preserves numeric error code and JSON-compatible data", async () => { + const { host, backend } = peerPair() + backend.handle("backend.fail", () => { + throw Object.assign(new Error("not ready"), { code: -32042, data: { kind: "not_ready", retry: true } }) + }) + const error = (await host.request("backend.fail").catch((value) => value)) as RpcError + expect(error).toBeInstanceOf(RpcError) + expect(error).toMatchObject({ code: -32042, message: "not ready", data: { kind: "not_ready", retry: true } }) + }) + + test("serializes unexpected errors as internal errors with diagnostics", async () => { + const { host, backend } = peerPair() + backend.handle("backend.fail", () => { + throw new TypeError("broken plugin") + }) + const error = (await host.request("backend.fail").catch((value) => value)) as RpcError + expect(error).toBeInstanceOf(RpcError) + expect(error.code).toBe(-32603) + expect(error.data).toMatchObject({ name: "TypeError", message: "broken plugin" }) + expect((error.data as { stack: string }).stack).toContain("broken plugin") + }) + + test("returns method-not-found without closing the connection", async () => { + const { host, backend } = peerPair() + const error = await host.request("backend.missing").catch((value) => value) + expect(error).toMatchObject({ code: -32601 }) + backend.handle("backend.ok", () => "ok") + expect(await host.request("backend.ok")).toBe("ok") + }) + + test("rejects oversized frames before waiting for their payload", async () => { + const errors: Error[] = [] + const peer = new RpcPeer( + { write() {}, terminate() {} }, + { idPrefix: "host", maxFrameBytes: 32, onError: (error) => errors.push(error) }, + ) + const header = new Uint8Array(4) + new DataView(header.buffer).setUint32(0, 33, false) + peer.receive(header) + await peer.closed + expect(peer.closeError).toBeInstanceOf(RpcProtocolError) + expect(errors).toHaveLength(1) + }) + + test("rejects malformed JSON and calls the EOF callback once", async () => { + let eof = 0 + const peer = new RpcPeer( + { write() {}, terminate() {} }, + { idPrefix: "host", onEof: () => void eof++, onError() {} }, + ) + const payload = new TextEncoder().encode("{") + const frame = new Uint8Array(5) + new DataView(frame.buffer).setUint32(0, 1, false) + frame.set(payload, 4) + peer.receive(frame) + peer.end() + await peer.closed + await Promise.resolve() + expect(peer.closeError).toMatchObject({ code: -32700 }) + expect(eof).toBe(1) + }) + + test("rejects pending requests when the transport reaches EOF", async () => { + const peer = new RpcPeer({ write() {} }, { idPrefix: "host" }) + const request = peer.request("backend.never") + peer.end() + expect(((await request.catch((error) => error)) as Error).name).toBe("RpcConnectionClosedError") + }) + + test("validates negotiated and outbound frame limits", () => { + const peer = new RpcPeer({ write() {} }, { idPrefix: "host" }) + expect(() => peer.setMaxFrameBytes(MAX_MAX_FRAME_BYTES + 1)).toThrow(RangeError) + expect(() => encodeFrame({ value: "too large" }, 4)).toThrow(RangeError) + expect(() => encodeFrame({ value: Number.NaN })).toThrow(TypeError) + }) + + test("connects over a real Bun TCP socket", async () => { + const accepted = Promise.withResolvers() + const peers = new WeakMap() + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + const peer = new RpcPeer(socket, { idPrefix: "backend" }) + peers.set(socket, peer) + peer.handle("backend.ping", ({ value }: { value: number }) => ({ value })) + accepted.resolve(peer) + }, + data(socket, data) { + peers.get(socket)?.receive(data) + }, + close(socket) { + peers.get(socket)?.end() + }, + error(socket, error) { + peers.get(socket)?.end(error) + }, + }, + }) + const client = await connectRpcPeer(`127.0.0.1:${server.port}`) + const backend = await accepted.promise + expect(await client.request<{ value: number }>("backend.ping", { value: 42 })).toEqual({ value: 42 }) + client.close() + backend.close() + server.stop(true) + }) +}) + +describe("StreamRegistry", () => { + test("pulls base64 chunks no larger than 64 KiB and releases at EOF", async () => { + const registry = new StreamRegistry() + const bytes = new Uint8Array(MAX_STREAM_CHUNK_BYTES + 7).map((_, index) => index % 251) + const descriptor = registry.add( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }), + bytes.byteLength, + ) + const first = await registry.read({ streamID: descriptor.streamID }) + const second = await registry.read({ streamID: descriptor.streamID }) + const third = await registry.read({ streamID: descriptor.streamID }) + expect(Buffer.from(first.data, "base64").byteLength).toBe(MAX_STREAM_CHUNK_BYTES) + expect(Buffer.from(second.data, "base64").byteLength).toBe(7) + expect(third).toEqual({ data: "", eof: true }) + expect(registry.size).toBe(0) + }) + + test("cancels registered readers idempotently", async () => { + let reason: unknown + const registry = new StreamRegistry() + const descriptor = registry.add( + new ReadableStream({ + cancel(value) { + reason = value + }, + }), + ) + expect(await registry.cancel({ streamID: descriptor.streamID, reason: "closed" })).toEqual({ cancelled: true }) + expect(await registry.cancel({ streamID: descriptor.streamID })).toEqual({ cancelled: false }) + expect(reason).toBe("closed") + }) + + test("cancellation interrupts a pending read", async () => { + const registry = new StreamRegistry() + const descriptor = registry.add(new ReadableStream({ pull() {} })) + const read = registry.read({ streamID: descriptor.streamID }) + await Promise.resolve() + expect(await registry.cancel({ streamID: descriptor.streamID, reason: "stop" })).toEqual({ cancelled: true }) + expect(await read).toEqual({ data: "", eof: true }) + }) + + test("turns a remote descriptor into a pull-based ReadableStream", async () => { + const registry = new StreamRegistry("backend") + const descriptor = registry.add(new Blob(["hello world"]).stream() as ReadableStream) + const calls: string[] = [] + const stream = remoteReadable( + { + async request(method: string, params: unknown) { + calls.push(method) + const input = params as { streamID: string; maxBytes?: number } + if (method.endsWith(".read")) return (await registry.read(input)) as Result + return (await registry.cancel(input)) as Result + }, + }, + "backend", + descriptor, + { instanceID: "instance-1" }, + ) + expect(await new Response(stream).text()).toBe("hello world") + expect(calls).toEqual(["backend.stream.read", "backend.stream.read"]) + }) +}) + +function peerPair() { + let host: RpcPeer + let backend: RpcPeer + host = new RpcPeer({ write: (data) => deliver(backend, data, 7) }, { idPrefix: "host" }) + backend = new RpcPeer({ write: (data) => deliver(host, data, 11) }, { idPrefix: "backend" }) + return { host, backend } +} + +async function deliver(peer: RpcPeer, data: Uint8Array, size: number) { + for (let offset = 0; offset < data.byteLength; offset += size) { + peer.receive(data.subarray(offset, Math.min(offset + size, data.byteLength))) + await Promise.resolve() + } +} + +function hostResult(input: { value: string }) { + return { value: `${input.value}:backend` } +} diff --git a/src/apps/extension-host/tsconfig.json b/src/apps/extension-host/tsconfig.json new file mode 100644 index 000000000..4c0340f9b --- /dev/null +++ b/src/apps/extension-host/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "noEmit": true, + "strict": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "script/**/*.ts", "test/**/*.ts"] +} diff --git a/src/apps/server/src/routes/dispatch.rs b/src/apps/server/src/routes/dispatch.rs index 088544777..86329e95f 100644 --- a/src/apps/server/src/routes/dispatch.rs +++ b/src/apps/server/src/routes/dispatch.rs @@ -10,12 +10,11 @@ use bitfun_core::external_sources::{ use bitfun_core::service::dispatch::{ answer_dispatch, append_dispatch, cancel_dispatch, cancel_dispatch_cli_install, get_dispatch_status, list_dispatch_jobs, list_dispatch_targets, poll_dispatch_cli_install, - probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, - sync_dispatch_model_config, sync_dispatch_result, DispatchAnswerRequest, - DispatchAppendRequest, DispatchConnectionRequest, DispatchInstallPollRequest, - DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, - DispatchListTargetsRequest, DispatchProbeTargetRequest, DispatchStatusRequest, - DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, + probe_dispatch_target, start_dispatch_cli_install, submit_dispatch, sync_dispatch_model_config, + sync_dispatch_result, DispatchAnswerRequest, DispatchAppendRequest, DispatchConnectionRequest, + DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, + DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, + DispatchStatusRequest, DispatchSubmitRequest, DispatchSyncResultRequest, OutboundDispatchStore, }; use serde::de::DeserializeOwned; diff --git a/src/crates/adapters/opencode-plugin-host/AGENTS.md b/src/crates/adapters/opencode-plugin-host/AGENTS.md new file mode 100644 index 000000000..7b64277b2 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/AGENTS.md @@ -0,0 +1,9 @@ +# OpenCode Plugin Host Adapter + +This private adapter owns the framed loopback JSON-RPC transport and maps the +OpenCode extension-host process onto BitFun lifecycle operations. It may use the +managed process-tree primitive from `services-core`, but it must not own product +configuration selection, workspace/session policy, or plugin trust decisions. + +The backend always binds the loopback listener before spawning the child. The +first accepted frame must be an authenticated `backend.handshake` request. diff --git a/src/crates/adapters/opencode-plugin-host/Cargo.toml b/src/crates/adapters/opencode-plugin-host/Cargo.toml new file mode 100644 index 000000000..1d192db76 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bitfun-opencode-plugin-host" +version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false +description = "Private OpenCode plugin host process and IPC adapter" + +[lib] +name = "bitfun_opencode_plugin_host" +crate-type = ["rlib"] + +[dependencies] +base64 = { workspace = true } +bitfun-services-core = { path = "../../services/services-core", default-features = false, features = ["process-runtime"] } +log = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt", "sync", "time"] } +url = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/opencode-plugin-host/src/frame.rs b/src/crates/adapters/opencode-plugin-host/src/frame.rs new file mode 100644 index 000000000..0459063b0 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/frame.rs @@ -0,0 +1,54 @@ +use crate::PluginHostError; +use serde_json::Value; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub(super) async fn read_frame(stream: &mut R, limit: usize) -> Result +where + R: AsyncRead + Unpin, +{ + let length = stream.read_u32().await.map_err(PluginHostError::Io)?; + let length = usize::try_from(length).map_err(|_| { + PluginHostError::InvalidHandshake("frame length does not fit usize".to_string()) + })?; + if length == 0 || length > limit { + return Err(PluginHostError::InvalidHandshake(format!( + "frame length {length} exceeds limit {limit}" + ))); + } + let mut payload = vec![0; length]; + stream + .read_exact(&mut payload) + .await + .map_err(PluginHostError::Io)?; + serde_json::from_slice(&payload) + .map_err(|error| PluginHostError::InvalidHandshake(error.to_string())) +} + +pub(super) async fn write_frame( + stream: &mut W, + value: &Value, + limit: usize, +) -> Result<(), PluginHostError> +where + W: AsyncWrite + Unpin, +{ + let payload = serde_json::to_vec(value) + .map_err(|error| PluginHostError::InvalidHandshake(error.to_string()))?; + if payload.is_empty() || payload.len() > limit { + return Err(PluginHostError::InvalidHandshake(format!( + "response length {} exceeds limit {limit}", + payload.len() + ))); + } + let length = u32::try_from(payload.len()).map_err(|_| { + PluginHostError::InvalidHandshake("response length exceeds u32".to_string()) + })?; + stream + .write_u32(length) + .await + .map_err(PluginHostError::Io)?; + stream + .write_all(&payload) + .await + .map_err(PluginHostError::Io) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/host_log.rs b/src/crates/adapters/opencode-plugin-host/src/host_log.rs new file mode 100644 index 000000000..c799321ed --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/host_log.rs @@ -0,0 +1,191 @@ +use bitfun_services_core::process_tree::ProcessTreeChild; +use std::io; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +const LOG_CHANNEL_CAPACITY: usize = 256; +const READ_BUFFER_BYTES: usize = 4096; +const MAX_LOG_LINE_BYTES: usize = 32 * 1024; + +struct HostLogLine { + source: &'static str, + bytes: Vec, + truncated: bool, +} + +pub(crate) struct HostLogDrain { + readers: [JoinHandle<()>; 2], + writer: JoinHandle<()>, +} + +impl HostLogDrain { + pub(crate) async fn flush(self, deadline: std::time::Duration) -> bool { + tokio::time::timeout(deadline, async move { + for reader in self.readers { + let _ = reader.await; + } + let _ = self.writer.await; + }) + .await + .is_ok() + } +} + +#[derive(Default)] +struct DroppedLogLines { + stdout: AtomicU64, + stderr: AtomicU64, +} + +impl DroppedLogLines { + fn counter(&self, source: &str) -> &AtomicU64 { + match source { + "stdout" => &self.stdout, + "stderr" => &self.stderr, + _ => &self.stderr, + } + } +} + +pub(crate) async fn attach_host_log( + child: &mut ProcessTreeChild, + log_file: &Path, +) -> io::Result { + let parent = log_file + .parent() + .ok_or_else(|| io::Error::other("plugin host log file has no parent directory"))?; + tokio::fs::create_dir_all(parent).await?; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .await?; + let stdout = child + .take_stdout() + .ok_or_else(|| io::Error::other("plugin host stdout is not piped"))?; + let stderr = child + .take_stderr() + .ok_or_else(|| io::Error::other("plugin host stderr is not piped"))?; + let (sender, receiver) = mpsc::channel(LOG_CHANNEL_CAPACITY); + let dropped = Arc::new(DroppedLogLines::default()); + let writer = tokio::spawn(write_log(file, receiver, dropped.clone())); + let stdout_reader = tokio::spawn(read_log(stdout, "stdout", sender.clone(), dropped.clone())); + let stderr_reader = tokio::spawn(read_log(stderr, "stderr", sender, dropped)); + Ok(HostLogDrain { + readers: [stdout_reader, stderr_reader], + writer, + }) +} + +async fn read_log( + mut reader: R, + source: &'static str, + sender: mpsc::Sender, + dropped: Arc, +) where + R: AsyncRead + Unpin, +{ + let mut buffer = [0_u8; READ_BUFFER_BYTES]; + let mut line = Vec::with_capacity(READ_BUFFER_BYTES); + let mut truncated = false; + loop { + let read = match reader.read(&mut buffer).await { + Ok(0) => { + enqueue_line(&sender, &dropped, source, &mut line, truncated); + return; + } + Ok(read) => read, + Err(_) => return, + }; + for byte in &buffer[..read] { + if *byte == b'\n' { + enqueue_line(&sender, &dropped, source, &mut line, truncated); + truncated = false; + } else if line.len() < MAX_LOG_LINE_BYTES { + line.push(*byte); + } else { + truncated = true; + } + } + } +} + +fn enqueue_line( + sender: &mpsc::Sender, + dropped: &DroppedLogLines, + source: &'static str, + line: &mut Vec, + truncated: bool, +) { + if line.is_empty() && !truncated { + return; + } + let bytes = std::mem::take(line); + let message = HostLogLine { + source, + bytes, + truncated, + }; + if let Err(mpsc::error::TrySendError::Full(_)) = sender.try_send(message) { + dropped.counter(source).fetch_add(1, Ordering::Relaxed); + } +} + +async fn write_log( + mut file: File, + mut receiver: mpsc::Receiver, + dropped: Arc, +) { + let mut flush_dropped = tokio::time::interval(std::time::Duration::from_secs(1)); + flush_dropped.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + line = receiver.recv() => { + let Some(line) = line else { + break; + }; + if write_dropped_lines(&mut file, &dropped).await.is_err() + || write_line(&mut file, &line).await.is_err() + { + return; + } + } + _ = flush_dropped.tick() => { + if write_dropped_lines(&mut file, &dropped).await.is_err() { + return; + } + } + } + } + let _ = write_dropped_lines(&mut file, &dropped).await; + let _ = file.flush().await; +} + +async fn write_line(file: &mut File, line: &HostLogLine) -> io::Result<()> { + file.write_all(b"[").await?; + file.write_all(line.source.as_bytes()).await?; + file.write_all(b"] ").await?; + file.write_all(&line.bytes).await?; + if line.truncated { + file.write_all(b" [truncated]").await?; + } + file.write_all(b"\n").await +} + +async fn write_dropped_lines(file: &mut File, dropped: &DroppedLogLines) -> io::Result<()> { + for source in ["stdout", "stderr"] { + let count = dropped.counter(source).swap(0, Ordering::Relaxed); + if count > 0 { + file.write_all( + format!("[plugin-host] dropped_lines={count}, source={source}\n").as_bytes(), + ) + .await?; + } + } + Ok(()) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/http.rs b/src/crates/adapters/opencode-plugin-host/src/http.rs new file mode 100644 index 000000000..47113a126 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/http.rs @@ -0,0 +1,744 @@ +use crate::{PluginHostClient, PluginHostError}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::time::Duration; +use thiserror::Error; +use url::Url; + +pub const MAX_HTTP_BODY_BYTES: usize = 1024 * 1024; +pub const MAX_STREAM_CHUNK_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackendHttpRequest { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "requestID")] + pub request_id: String, + pub method: String, + pub path: String, + pub headers: Vec<(String, String)>, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StreamDescriptor { + #[serde(rename = "streamID")] + pub stream_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub length: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BackendHttpResponse { + pub status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_text: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenCodeClientRoute { + ProjectList, + ProjectCurrent, + PathGet, + VcsGet, + ConfigGet, + ConfigProviders, + ToolIds, + ToolList, + ProviderList, + AppLog, + AgentList, + CommandList, + SessionList, + SessionCreate, + SessionStatus, + SessionDelete { + session_id: String, + }, + SessionGet { + session_id: String, + }, + SessionUpdate { + session_id: String, + }, + SessionChildren { + session_id: String, + }, + SessionTodo { + session_id: String, + }, + SessionFork { + session_id: String, + }, + SessionAbort { + session_id: String, + }, + SessionDiff { + session_id: String, + }, + SessionMessages { + session_id: String, + }, + SessionMessage { + session_id: String, + message_id: String, + }, + PtyList, + PtyCreate, + PtyDelete { + pty_id: String, + }, + PtyGet { + pty_id: String, + }, + PtyUpdate { + pty_id: String, + }, + FindText, + FindFiles, + FileList, + FileRead, + FileStatus, + McpStatus, + LspStatus, +} + +impl OpenCodeClientRoute { + pub fn operation(&self) -> &'static str { + match self { + Self::ProjectList => "project.list", + Self::ProjectCurrent => "project.current", + Self::PathGet => "path.get", + Self::VcsGet => "vcs.get", + Self::ConfigGet => "config.get", + Self::ConfigProviders => "config.providers", + Self::ToolIds => "tool.ids", + Self::ToolList => "tool.list", + Self::ProviderList => "provider.list", + Self::AppLog => "app.log", + Self::AgentList => "app.agents", + Self::CommandList => "command.list", + Self::SessionList => "session.list", + Self::SessionCreate => "session.create", + Self::SessionStatus => "session.status", + Self::SessionDelete { .. } => "session.delete", + Self::SessionGet { .. } => "session.get", + Self::SessionUpdate { .. } => "session.update", + Self::SessionChildren { .. } => "session.children", + Self::SessionTodo { .. } => "session.todo", + Self::SessionFork { .. } => "session.fork", + Self::SessionAbort { .. } => "session.abort", + Self::SessionDiff { .. } => "session.diff", + Self::SessionMessages { .. } => "session.messages", + Self::SessionMessage { .. } => "session.message", + Self::PtyList => "pty.list", + Self::PtyCreate => "pty.create", + Self::PtyDelete { .. } => "pty.remove", + Self::PtyGet { .. } => "pty.get", + Self::PtyUpdate { .. } => "pty.update", + Self::FindText => "find.text", + Self::FindFiles => "find.files", + Self::FileList => "file.list", + Self::FileRead => "file.read", + Self::FileStatus => "file.status", + Self::McpStatus => "mcp.status", + Self::LspStatus => "lsp.status", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpRouteMatch { + pub route: OpenCodeClientRoute, + pub path: String, + pub query: HashMap>, +} + +impl HttpRouteMatch { + pub fn query_first(&self, key: &str) -> Option<&str> { + self.query + .get(key) + .and_then(|values| values.first()) + .map(String::as_str) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum HttpRouteError { + #[error("request path is invalid")] + InvalidPath, + #[error("OpenCode client route was not found")] + NotFound, + #[error("HTTP method is not allowed for this OpenCode client route")] + MethodNotAllowed, +} + +pub fn match_http_route( + method: &str, + path_and_query: &str, +) -> Result { + if !path_and_query.starts_with('/') || path_and_query.len() > 16 * 1024 { + return Err(HttpRouteError::InvalidPath); + } + let url = Url::parse(&format!("http://127.0.0.1{path_and_query}")) + .map_err(|_| HttpRouteError::InvalidPath)?; + let path = url.path().trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + let query = url.query_pairs().fold( + HashMap::>::new(), + |mut query, (key, value)| { + query + .entry(key.into_owned()) + .or_default() + .push(value.into_owned()); + query + }, + ); + let method = method.trim().to_ascii_uppercase(); + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(decode_segment) + .collect::, _>>()?; + let route = match (method.as_str(), path, segments.as_slice()) { + ("GET", "/project", _) => OpenCodeClientRoute::ProjectList, + ("GET", "/project/current", _) => OpenCodeClientRoute::ProjectCurrent, + ("GET", "/path", _) => OpenCodeClientRoute::PathGet, + ("GET", "/vcs", _) => OpenCodeClientRoute::VcsGet, + ("GET", "/config", _) => OpenCodeClientRoute::ConfigGet, + ("GET", "/config/providers", _) => OpenCodeClientRoute::ConfigProviders, + ("GET", "/experimental/tool/ids", _) => OpenCodeClientRoute::ToolIds, + ("GET", "/experimental/tool", _) => OpenCodeClientRoute::ToolList, + ("GET", "/provider", _) => OpenCodeClientRoute::ProviderList, + ("POST", "/log", _) => OpenCodeClientRoute::AppLog, + ("GET", "/agent", _) => OpenCodeClientRoute::AgentList, + ("GET", "/command", _) => OpenCodeClientRoute::CommandList, + ("GET", "/session", _) => OpenCodeClientRoute::SessionList, + ("POST", "/session", _) => OpenCodeClientRoute::SessionCreate, + ("GET", "/session/status", _) => OpenCodeClientRoute::SessionStatus, + ("DELETE", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionDelete { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionGet { + session_id: session_id.clone(), + } + } + ("PATCH", _, [session, session_id]) if session == "session" && session_id != "status" => { + OpenCodeClientRoute::SessionUpdate { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "children" => + { + OpenCodeClientRoute::SessionChildren { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "todo" => + { + OpenCodeClientRoute::SessionTodo { + session_id: session_id.clone(), + } + } + ("POST", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "fork" => + { + OpenCodeClientRoute::SessionFork { + session_id: session_id.clone(), + } + } + ("POST", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "abort" => + { + OpenCodeClientRoute::SessionAbort { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "diff" => + { + OpenCodeClientRoute::SessionDiff { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, suffix]) + if session == "session" && session_id != "status" && suffix == "message" => + { + OpenCodeClientRoute::SessionMessages { + session_id: session_id.clone(), + } + } + ("GET", _, [session, session_id, message, message_id]) + if session == "session" && session_id != "status" && message == "message" => + { + OpenCodeClientRoute::SessionMessage { + session_id: session_id.clone(), + message_id: message_id.clone(), + } + } + ("GET", "/pty", _) => OpenCodeClientRoute::PtyList, + ("POST", "/pty", _) => OpenCodeClientRoute::PtyCreate, + ("DELETE", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyDelete { + pty_id: pty_id.clone(), + }, + ("GET", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyGet { + pty_id: pty_id.clone(), + }, + ("PUT", _, [pty, pty_id]) if pty == "pty" => OpenCodeClientRoute::PtyUpdate { + pty_id: pty_id.clone(), + }, + ("GET", "/find", _) => OpenCodeClientRoute::FindText, + ("GET", "/find/file", _) => OpenCodeClientRoute::FindFiles, + ("GET", "/file", _) => OpenCodeClientRoute::FileList, + ("GET", "/file/content", _) => OpenCodeClientRoute::FileRead, + ("GET", "/file/status", _) => OpenCodeClientRoute::FileStatus, + ("GET", "/mcp", _) => OpenCodeClientRoute::McpStatus, + ("GET", "/lsp", _) => OpenCodeClientRoute::LspStatus, + _ if is_known_adapted_path(path, &segments) => { + return Err(HttpRouteError::MethodNotAllowed) + } + _ => return Err(HttpRouteError::NotFound), + }; + Ok(HttpRouteMatch { + route, + path: path.to_string(), + query, + }) +} + +fn decode_segment(segment: &str) -> Result { + let bytes = segment.as_bytes(); + for index in 0..bytes.len() { + if bytes[index] == b'%' + && (index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit()) + { + return Err(HttpRouteError::InvalidPath); + } + } + let decoded = urlencoding::decode(segment).map_err(|_| HttpRouteError::InvalidPath)?; + if decoded.is_empty() || decoded.contains('/') || decoded.contains('\\') { + return Err(HttpRouteError::InvalidPath); + } + Ok(decoded.into_owned()) +} + +fn is_known_adapted_path(path: &str, segments: &[String]) -> bool { + matches!( + path, + "/project" + | "/project/current" + | "/path" + | "/vcs" + | "/config" + | "/config/providers" + | "/experimental/tool/ids" + | "/experimental/tool" + | "/provider" + | "/log" + | "/agent" + | "/command" + | "/session" + | "/session/status" + | "/pty" + | "/find" + | "/find/file" + | "/file" + | "/file/content" + | "/file/status" + | "/mcp" + | "/lsp" + ) || matches!(segments, [root, _] if root == "session" || root == "pty") + || matches!( + segments, + [root, _, suffix] + if root == "session" + && matches!( + suffix.as_str(), + "children" | "todo" | "fork" | "abort" | "diff" | "message" + ) + ) + || matches!(segments, [root, _, message, _] if root == "session" && message == "message") +} + +#[derive(Debug, Error)] +pub enum HostStreamReadError { + #[error("request body exceeds the maximum allowed size")] + BodyTooLarge, + #[error("host stream returned invalid base64 data: {0}")] + InvalidBase64(#[source] base64::DecodeError), + #[error("host stream RPC failed: {0}")] + Rpc(#[from] PluginHostError), + #[error("host stream returned an invalid response")] + InvalidResponse, +} + +pub async fn read_host_stream( + client: &PluginHostClient, + instance_id: &str, + descriptor: &StreamDescriptor, + max_bytes: usize, + deadline: Duration, +) -> Result, HostStreamReadError> { + if descriptor.length.is_some_and(|length| length > max_bytes) { + cancel_host_stream(client, instance_id, descriptor, "request body too large").await; + return Err(HostStreamReadError::BodyTooLarge); + } + let mut output = Vec::with_capacity(descriptor.length.unwrap_or(0).min(max_bytes)); + loop { + let response = client + .request( + "host.stream.read", + json!({ + "instanceID": instance_id, + "streamID": descriptor.stream_id, + "maxBytes": MAX_STREAM_CHUNK_BYTES, + }), + deadline, + ) + .await?; + let data = response + .get("data") + .and_then(Value::as_str) + .ok_or(HostStreamReadError::InvalidResponse)?; + let eof = response + .get("eof") + .and_then(Value::as_bool) + .ok_or(HostStreamReadError::InvalidResponse)?; + let chunk = BASE64_STANDARD + .decode(data) + .map_err(HostStreamReadError::InvalidBase64)?; + if output.len().saturating_add(chunk.len()) > max_bytes { + cancel_host_stream(client, instance_id, descriptor, "request body too large").await; + return Err(HostStreamReadError::BodyTooLarge); + } + output.extend_from_slice(&chunk); + if eof { + return Ok(output); + } + } +} + +async fn cancel_host_stream( + client: &PluginHostClient, + instance_id: &str, + descriptor: &StreamDescriptor, + reason: &str, +) { + let _ = client + .request( + "host.stream.cancel", + json!({ + "instanceID": instance_id, + "streamID": descriptor.stream_id, + "reason": reason, + }), + Duration::from_secs(2), + ) + .await; +} + +pub fn json_error_body(code: &str, message: &str, route: &str) -> Vec { + serde_json::to_vec(&json!({ + "error": { + "code": code, + "message": message, + "route": route, + } + })) + .unwrap_or_else(|_| b"{\"error\":{\"code\":\"backend_failure\"}}".to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{match_http_route, HttpRouteError, OpenCodeClientRoute}; + + fn assert_route(method: &str, path: &str, expected: OpenCodeClientRoute) { + let matched = match_http_route(method, path) + .unwrap_or_else(|error| panic!("route did not match: {method} {path}: {error}")); + assert_eq!( + matched.route, expected, + "unexpected route for {method} {path}" + ); + } + + #[test] + fn adapted_route_matrix_covers_every_documented_a_route() { + let cases = vec![ + ("GET", "/project", OpenCodeClientRoute::ProjectList), + ( + "GET", + "/project/current?directory=C%3A%5Cworkspace", + OpenCodeClientRoute::ProjectCurrent, + ), + ("GET", "/path", OpenCodeClientRoute::PathGet), + ("GET", "/vcs", OpenCodeClientRoute::VcsGet), + ("GET", "/config", OpenCodeClientRoute::ConfigGet), + ( + "GET", + "/config/providers", + OpenCodeClientRoute::ConfigProviders, + ), + ( + "GET", + "/experimental/tool/ids", + OpenCodeClientRoute::ToolIds, + ), + ( + "GET", + "/experimental/tool?provider=bitfun&model=primary", + OpenCodeClientRoute::ToolList, + ), + ("GET", "/provider", OpenCodeClientRoute::ProviderList), + ("POST", "/log", OpenCodeClientRoute::AppLog), + ("GET", "/agent", OpenCodeClientRoute::AgentList), + ("GET", "/command", OpenCodeClientRoute::CommandList), + ("GET", "/session", OpenCodeClientRoute::SessionList), + ("POST", "/session", OpenCodeClientRoute::SessionCreate), + ("GET", "/session/status", OpenCodeClientRoute::SessionStatus), + ( + "DELETE", + "/session/session%3A1", + OpenCodeClientRoute::SessionDelete { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1", + OpenCodeClientRoute::SessionGet { + session_id: "session:1".to_string(), + }, + ), + ( + "PATCH", + "/session/session%3A1", + OpenCodeClientRoute::SessionUpdate { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/children", + OpenCodeClientRoute::SessionChildren { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/todo", + OpenCodeClientRoute::SessionTodo { + session_id: "session:1".to_string(), + }, + ), + ( + "POST", + "/session/session%3A1/fork", + OpenCodeClientRoute::SessionFork { + session_id: "session:1".to_string(), + }, + ), + ( + "POST", + "/session/session%3A1/abort", + OpenCodeClientRoute::SessionAbort { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/diff?messageID=message%3A1", + OpenCodeClientRoute::SessionDiff { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/message?limit=10", + OpenCodeClientRoute::SessionMessages { + session_id: "session:1".to_string(), + }, + ), + ( + "GET", + "/session/session%3A1/message/message%3A2", + OpenCodeClientRoute::SessionMessage { + session_id: "session:1".to_string(), + message_id: "message:2".to_string(), + }, + ), + ("GET", "/pty", OpenCodeClientRoute::PtyList), + ("POST", "/pty", OpenCodeClientRoute::PtyCreate), + ( + "DELETE", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyDelete { + pty_id: "pty:1".to_string(), + }, + ), + ( + "GET", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyGet { + pty_id: "pty:1".to_string(), + }, + ), + ( + "PUT", + "/pty/pty%3A1", + OpenCodeClientRoute::PtyUpdate { + pty_id: "pty:1".to_string(), + }, + ), + ("GET", "/find?pattern=needle", OpenCodeClientRoute::FindText), + ( + "GET", + "/find/file?query=needle", + OpenCodeClientRoute::FindFiles, + ), + ("GET", "/file?path=src", OpenCodeClientRoute::FileList), + ( + "GET", + "/file/content?path=README.md", + OpenCodeClientRoute::FileRead, + ), + ("GET", "/file/status", OpenCodeClientRoute::FileStatus), + ("GET", "/mcp", OpenCodeClientRoute::McpStatus), + ("GET", "/lsp", OpenCodeClientRoute::LspStatus), + ]; + + for (method, path, expected) in cases { + assert_route(method, path, expected); + } + } + + #[test] + fn normalizes_methods_paths_and_query_values() { + let matched = match_http_route( + " get ", + "/project/current/?directory=C%3A%5Cworkspace&directory=D%3A%5Cignored", + ) + .expect("normalized project route"); + + assert_eq!(matched.route, OpenCodeClientRoute::ProjectCurrent); + assert_eq!(matched.path, "/project/current"); + assert_eq!(matched.query_first("directory"), Some("C:\\workspace")); + assert_eq!( + matched.query.get("directory"), + Some(&vec![ + "C:\\workspace".to_string(), + "D:\\ignored".to_string() + ]) + ); + } + + #[test] + fn rejects_invalid_and_unsafe_route_paths() { + let oversized = format!("/{}", "a".repeat(16 * 1024)); + for path in [ + "project/current", + "/session/%ZZ", + "/session/session%2Fescape", + "/session/session%5Cescape", + oversized.as_str(), + ] { + assert_eq!( + match_http_route("GET", path), + Err(HttpRouteError::InvalidPath), + "invalid path unexpectedly matched: {path}" + ); + } + + assert_eq!( + match_http_route("GET", "/unknown"), + Err(HttpRouteError::NotFound) + ); + } + + #[test] + fn postponed_and_excluded_routes_are_not_in_the_route_table() { + for (method, path) in [ + ("GET", "/global/event"), + ("GET", "/event"), + ("POST", "/instance/dispose"), + ("GET", "/provider/auth"), + ("POST", "/provider/openai/oauth/authorize"), + ("POST", "/provider/openai/oauth/callback"), + ("GET", "/pty/pty-1/connect"), + ("GET", "/find/symbol"), + ("GET", "/formatter"), + ("POST", "/session/s1/init"), + ("POST", "/session/s1/summarize"), + ("DELETE", "/session/s1/share"), + ("POST", "/session/s1/share"), + ("POST", "/session/s1/prompt_async"), + ("POST", "/session/s1/command"), + ("POST", "/session/s1/shell"), + ("POST", "/session/s1/revert"), + ("POST", "/session/s1/unrevert"), + ("POST", "/mcp/server/connect"), + ("POST", "/mcp/server/disconnect"), + ("DELETE", "/mcp/server/auth"), + ("POST", "/mcp/server/auth"), + ("POST", "/mcp/server/auth/callback"), + ("POST", "/mcp/server/auth/authenticate"), + ("PUT", "/auth/server"), + ("DELETE", "/auth/provider"), + ("POST", "/auth/provider"), + ("POST", "/auth/provider/callback"), + ("POST", "/auth/provider/authenticate"), + ("POST", "/tui/append-prompt"), + ("POST", "/tui/open-help"), + ("POST", "/tui/open-sessions"), + ("POST", "/tui/open-themes"), + ("POST", "/tui/open-models"), + ("POST", "/tui/submit-prompt"), + ("POST", "/tui/clear-prompt"), + ("POST", "/tui/execute-command"), + ("POST", "/tui/show-toast"), + ("POST", "/tui/publish"), + ("GET", "/tui/control/next"), + ("POST", "/tui/control/response"), + ("POST", "/session/s1/permissions/p1"), + ] { + assert_eq!( + match_http_route(method, path), + Err(HttpRouteError::NotFound), + "excluded route unexpectedly matched: {method} {path}" + ); + } + + for (method, path) in [ + ("POST", "/project/current"), + ("PATCH", "/config"), + ("DELETE", "/config"), + ("DELETE", "/session/status"), + ("POST", "/session/s1/message"), + ("PATCH", "/pty/pty-1"), + ("POST", "/mcp"), + ("POST", "/file/status"), + ] { + assert_eq!( + match_http_route(method, path), + Err(HttpRouteError::MethodNotAllowed), + "adapted route accepted the wrong method: {method} {path}" + ); + } + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/lib.rs b/src/crates/adapters/opencode-plugin-host/src/lib.rs new file mode 100644 index 000000000..738375603 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/lib.rs @@ -0,0 +1,463 @@ +mod frame; +mod host_log; +mod http; +mod peer; +mod peer_runtime; +mod stream_registry; + +use bitfun_services_core::process_tree::{CleanupOutcome, ProcessTreeChild}; +use rand::{distributions::Alphanumeric, Rng}; +use serde::Serialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use std::time::Instant; +use thiserror::Error; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Command; + +use frame::{read_frame, write_frame}; +pub use http::{ + json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, + HostStreamReadError, HttpRouteError, HttpRouteMatch, OpenCodeClientRoute, StreamDescriptor, + MAX_HTTP_BODY_BYTES, MAX_STREAM_CHUNK_BYTES, +}; +pub use peer::{JsonRpcPeer, PluginHostClient, RpcHandlerError}; +pub use stream_registry::{ + PluginHostStreamRegistry, StreamCancelParams, StreamCancelResult, StreamReadParams, + StreamReadResult, StreamRegistryError, +}; + +const PROTOCOL_VERSION: u64 = 1; +const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; +const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +static NEXT_CONNECTION_GENERATION: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub struct PluginHostConfig { + pub runtime_command: PathBuf, + pub entry: PathBuf, + pub working_directory: PathBuf, + pub cache_directory: PathBuf, + pub log_file: PathBuf, + pub log_level: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDeclaration { + pub spec: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_directory: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPrepareRequest { + pub plugins: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub configuration_fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_base_directory: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstanceOpenRequest { + #[serde(rename = "instanceID")] + pub instance_id: String, + pub project: Value, + pub config: serde_json::Map, + pub directory: String, + pub worktree: String, + pub plugins: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub configuration_fingerprint: Option, +} + +#[derive(Debug, Error)] +pub enum PluginHostError { + #[error("plugin host entry is not an absolute path: {0}")] + RelativeEntry(PathBuf), + #[error("plugin host cache directory is not an absolute path: {0}")] + RelativeCacheDirectory(PathBuf), + #[error("plugin host log file is not an absolute path: {0}")] + RelativeLogFile(PathBuf), + #[error("failed to prepare plugin host cache directory: {0}")] + PrepareCache(#[source] std::io::Error), + #[error("failed to bind plugin host listener: {0}")] + Bind(#[source] std::io::Error), + #[error("failed to start plugin host runtime: {0}")] + Spawn(#[source] std::io::Error), + #[error("failed to prepare plugin host log: {0}")] + PrepareLog(#[source] std::io::Error), + #[error("plugin host did not connect within the startup timeout")] + StartupTimeout, + #[error("plugin host IPC failed: {0}")] + Io(#[source] std::io::Error), + #[error("plugin host handshake frame is invalid: {0}")] + InvalidHandshake(String), + #[error("plugin host JSON-RPC protocol error: {0}")] + Protocol(String), + #[error("plugin host JSON-RPC connection closed: {0}")] + ConnectionClosed(String), + #[error("plugin host is shutting down")] + ShuttingDown, + #[error("plugin host JSON-RPC request timed out: method={method}, request_id={request_id}")] + RequestTimeout { method: String, request_id: String }, + #[error("plugin host JSON-RPC returned an error: code={code}, message={message}")] + Rpc { + code: i64, + message: String, + data: Option, + }, + #[error("plugin host JSON-RPC handler is already registered: {0}")] + DuplicateHandler(String), +} + +pub struct PluginHost { + child: ProcessTreeChild, + client: PluginHostClient, + host_log: Option, + max_frame_bytes: usize, +} + +#[derive(Debug, Clone, Copy)] +pub struct PluginHostShutdownPolicy { + pub drain_timeout: Duration, + pub rpc_timeout: Duration, + pub exit_timeout: Duration, + pub eof_timeout: Duration, + pub terminate_grace: Duration, +} + +impl Default for PluginHostShutdownPolicy { + fn default() -> Self { + Self { + drain_timeout: Duration::from_secs(3), + rpc_timeout: Duration::from_secs(5), + exit_timeout: Duration::from_secs(2), + eof_timeout: Duration::from_secs(1), + terminate_grace: Duration::from_millis(500), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostShutdownDisposition { + Graceful, + ExitedAfterShutdown, + ExitedAfterConnectionClose, + Forced, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHostShutdownReport { + pub generation: u64, + pub disposition: PluginHostShutdownDisposition, + pub rpc_completed: bool, + pub exit_code: Option, + pub duration_ms: u64, +} + +impl PluginHost { + pub async fn start(config: PluginHostConfig) -> Result { + validate_config(&config)?; + tokio::fs::create_dir_all(&config.cache_directory) + .await + .map_err(PluginHostError::PrepareCache)?; + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(PluginHostError::Bind)?; + let address = listener.local_addr().map_err(PluginHostError::Bind)?; + let token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect(); + + let mut command = Command::new(&config.runtime_command); + command + .arg(&config.entry) + .current_dir(&config.working_directory) + .env("OPENCODE_EXTENSION_HOST_RPC_ADDRESS", address.to_string()) + .env("OPENCODE_EXTENSION_HOST_RPC_TOKEN", &token) + .env("OPENCODE_EXTENSION_HOST_LOG_LEVEL", &config.log_level) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(PluginHostError::Spawn)?; + let host_log = host_log::attach_host_log(&mut child, &config.log_file) + .await + .map_err(PluginHostError::PrepareLog)?; + + let (mut stream, _) = tokio::time::timeout(STARTUP_TIMEOUT, listener.accept()) + .await + .map_err(|_| PluginHostError::StartupTimeout)? + .map_err(PluginHostError::Io)?; + let max_frame_bytes = + complete_handshake(&mut stream, &token, &config.cache_directory).await?; + let generation = NEXT_CONNECTION_GENERATION.fetch_add(1, Ordering::Relaxed); + let peer = JsonRpcPeer::start(stream, generation, max_frame_bytes); + Ok(Self { + child, + client: peer.client(), + host_log: Some(host_log), + max_frame_bytes, + }) + } + + pub fn max_frame_bytes(&self) -> usize { + self.max_frame_bytes + } + + pub fn client(&self) -> PluginHostClient { + self.client.clone() + } + + pub fn is_connected(&mut self) -> Result { + if self + .child + .try_wait() + .map_err(PluginHostError::Io)? + .is_some() + { + return Ok(false); + } + Ok(!self.client.is_closed()) + } + + pub async fn shutdown(mut self, policy: PluginHostShutdownPolicy) -> PluginHostShutdownReport { + let started_at = Instant::now(); + let generation = self.client.generation(); + let pending = self.client.begin_draining().await; + log::info!( + "Plugin host shutdown started: generation={}, pending_requests={}, drain_deadline_ms={}, rpc_deadline_ms={}", + generation, + pending, + policy.drain_timeout.as_millis(), + policy.rpc_timeout.as_millis() + ); + if !self.client.wait_for_pending(policy.drain_timeout).await { + log::warn!( + "Plugin host RPC drain timed out: generation={}, pending_requests={}", + generation, + pending + ); + } + + let rpc_completed = self + .client + .request_during_shutdown("host.shutdown", json!({}), policy.rpc_timeout) + .await + .is_ok_and(|result| result.get("closed").and_then(Value::as_bool) == Some(true)); + if rpc_completed { + log::info!( + "Plugin host shutdown RPC completed: generation={}, duration_ms={}", + generation, + elapsed_ms(started_at) + ); + if let Ok(Ok(status)) = + tokio::time::timeout(policy.exit_timeout, self.child.wait()).await + { + let disposition = if status.success() { + PluginHostShutdownDisposition::Graceful + } else { + PluginHostShutdownDisposition::ExitedAfterShutdown + }; + let report = + shutdown_report(generation, disposition, true, status.code(), started_at); + if report.disposition == PluginHostShutdownDisposition::Graceful { + log::info!( + "Plugin host exited gracefully: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + } else { + log::warn!( + "Plugin host exited after shutdown with a failure status: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + } + self.flush_host_log(policy.eof_timeout).await; + return report; + } + log::warn!( + "Plugin host exit timed out after shutdown response: generation={}", + generation + ); + } else { + log::warn!( + "Plugin host shutdown RPC failed or timed out: generation={}", + generation + ); + } + + self.client + .close("plugin host graceful shutdown fallback") + .await; + if let Ok(Ok(status)) = tokio::time::timeout(policy.eof_timeout, self.child.wait()).await { + let report = shutdown_report( + generation, + PluginHostShutdownDisposition::ExitedAfterConnectionClose, + rpc_completed, + status.code(), + started_at, + ); + log::info!( + "Plugin host exited after RPC connection close: generation={}, exit_code={:?}, duration_ms={}", + generation, + report.exit_code, + report.duration_ms + ); + self.flush_host_log(policy.eof_timeout).await; + return report; + } + + let cleanup = self.child.terminate(policy.terminate_grace).await; + let exit_code = self + .child + .try_wait() + .ok() + .flatten() + .and_then(|status| status.code()); + let report = shutdown_report( + generation, + PluginHostShutdownDisposition::Forced, + rpc_completed, + exit_code, + started_at, + ); + match cleanup { + Ok(CleanupOutcome::AlreadyExited) => log::warn!( + "Plugin host exited during forced cleanup: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Ok(_) => log::warn!( + "Plugin host process tree terminated: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Err(error) => log::error!( + "Plugin host process tree termination failed: generation={}, error={}", + generation, + error + ), + } + self.flush_host_log(policy.eof_timeout).await; + report + } + + async fn flush_host_log(&mut self, deadline: Duration) { + let Some(host_log) = self.host_log.take() else { + return; + }; + if !host_log.flush(deadline).await { + log::warn!( + "Plugin host log flush timed out: generation={}", + self.client.generation() + ); + } + } +} + +fn shutdown_report( + generation: u64, + disposition: PluginHostShutdownDisposition, + rpc_completed: bool, + exit_code: Option, + started_at: Instant, +) -> PluginHostShutdownReport { + PluginHostShutdownReport { + generation, + disposition, + rpc_completed, + exit_code, + duration_ms: elapsed_ms(started_at), + } +} + +fn elapsed_ms(started_at: Instant) -> u64 { + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn validate_config(config: &PluginHostConfig) -> Result<(), PluginHostError> { + if !config.entry.is_absolute() { + return Err(PluginHostError::RelativeEntry(config.entry.clone())); + } + if !config.cache_directory.is_absolute() { + return Err(PluginHostError::RelativeCacheDirectory( + config.cache_directory.clone(), + )); + } + if !config.log_file.is_absolute() { + return Err(PluginHostError::RelativeLogFile(config.log_file.clone())); + } + Ok(()) +} + +async fn complete_handshake( + stream: &mut TcpStream, + expected_token: &str, + cache_directory: &Path, +) -> Result { + let request = read_frame(stream, DEFAULT_MAX_FRAME_BYTES).await?; + let jsonrpc = request.get("jsonrpc").and_then(Value::as_str); + let method = request.get("method").and_then(Value::as_str); + let request_id = request + .get("id") + .and_then(Value::as_str) + .filter(|request_id| !request_id.is_empty()); + let params = request.get("params").and_then(Value::as_object); + let token = params + .and_then(|params| params.get("token")) + .and_then(Value::as_str); + let protocol_version = params + .and_then(|params| params.get("protocolVersion")) + .and_then(Value::as_u64); + let requested_frame_bytes = params + .and_then(|params| params.get("maxFrameBytes")) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()); + if jsonrpc != Some("2.0") + || method != Some("backend.handshake") + || request_id.is_none() + || request.get("result").is_some() + || request.get("error").is_some() + || token != Some(expected_token) + || protocol_version != Some(PROTOCOL_VERSION) + { + return Err(PluginHostError::InvalidHandshake( + "method, token, request id, or protocol version did not match".to_string(), + )); + } + let max_frame_bytes = requested_frame_bytes + .unwrap_or(DEFAULT_MAX_FRAME_BYTES) + .min(DEFAULT_MAX_FRAME_BYTES) + .min(MAX_FRAME_BYTES); + let response = json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "maxFrameBytes": max_frame_bytes, + "cacheDirectory": cache_directory.to_string_lossy() + } + }); + write_frame(stream, &response, DEFAULT_MAX_FRAME_BYTES).await?; + Ok(max_frame_bytes) +} + +#[cfg(test)] +mod tests; diff --git a/src/crates/adapters/opencode-plugin-host/src/peer.rs b/src/crates/adapters/opencode-plugin-host/src/peer.rs new file mode 100644 index 000000000..e2ebf9719 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/peer.rs @@ -0,0 +1,353 @@ +use crate::peer_runtime::{run_reader, run_writer}; +use crate::{PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, +}; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::sync::{mpsc, oneshot, watch, Mutex, Notify, RwLock, Semaphore}; + +const OUTBOUND_CAPACITY: usize = 128; +const HANDLER_CONCURRENCY: usize = 32; + +pub(super) type HandlerFuture = + Pin> + Send>>; +pub(super) type Handler = Arc HandlerFuture + Send + Sync>; +pub(super) type PendingSender = oneshot::Sender>; + +#[derive(Debug, Clone)] +pub struct RpcHandlerError { + pub code: i64, + pub message: String, + pub data: Option, +} + +impl RpcHandlerError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +#[derive(Clone)] +pub struct PluginHostClient { + state: Arc, +} + +impl PluginHostClient { + pub fn generation(&self) -> u64 { + self.state.generation + } + + pub fn is_closed(&self) -> bool { + self.state.closed.load(Ordering::Acquire) + } + + pub async fn set_log_level(&self, level: &str) -> Result<(), PluginHostError> { + let result = self + .request( + "host.log.setLevel", + json!({ "level": level }), + Duration::from_secs(5), + ) + .await?; + if result.get("level").and_then(Value::as_str) == Some(level) { + return Ok(()); + } + Err(PluginHostError::Protocol( + "host.log.setLevel returned an invalid level".to_string(), + )) + } + + pub async fn open_instance( + &self, + request: PluginInstanceOpenRequest, + deadline: Duration, + ) -> Result { + let params = serde_json::to_value(request) + .map_err(|error| PluginHostError::Protocol(error.to_string()))?; + self.request("host.instance.open", params, deadline).await + } + + pub async fn prepare_plugins( + &self, + request: PluginPrepareRequest, + deadline: Duration, + ) -> Result { + let params = serde_json::to_value(request) + .map_err(|error| PluginHostError::Protocol(error.to_string()))?; + self.request("host.plugins.prepare", params, deadline).await + } + + pub async fn close_instance( + &self, + instance_id: &str, + deadline: Duration, + ) -> Result { + let result = self + .request( + "host.instance.close", + json!({"instanceID": instance_id}), + deadline, + ) + .await?; + result + .get("closed") + .and_then(Value::as_bool) + .ok_or_else(|| { + PluginHostError::Protocol( + "host.instance.close returned an invalid result".to_string(), + ) + }) + } + + pub async fn request( + &self, + method: &str, + params: Value, + deadline: Duration, + ) -> Result { + self.request_inner(method, params, deadline, false).await + } + + pub(crate) async fn request_during_shutdown( + &self, + method: &str, + params: Value, + deadline: Duration, + ) -> Result { + self.request_inner(method, params, deadline, true).await + } + + async fn request_inner( + &self, + method: &str, + params: Value, + deadline: Duration, + allow_during_shutdown: bool, + ) -> Result { + let sequence = self.state.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let request_id = format!("backend:{}:{}", self.state.generation, sequence); + let (sender, receiver) = oneshot::channel(); + let exchange = async { + self.state + .register_pending(request_id.clone(), sender, allow_during_shutdown) + .await?; + log::debug!( + "Plugin host RPC request sending: generation={}, request_id={}, method={}", + self.state.generation, + request_id, + method + ); + self.state + .outbound + .send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + })) + .await + .map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC writer is closed".to_string()) + })?; + receiver.await.map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC response channel is closed".to_string()) + })? + }; + match tokio::time::timeout(deadline, exchange).await { + Ok(result) => { + if result.is_err() { + self.state.remove_pending(&request_id).await; + } + result + } + Err(_) => { + self.state.remove_pending(&request_id).await; + log::warn!( + "Plugin host RPC request timed out: generation={}, request_id={}, method={}", + self.state.generation, + request_id, + method + ); + Err(PluginHostError::RequestTimeout { + method: method.to_string(), + request_id, + }) + } + } + } + + pub async fn notify(&self, method: &str, params: Value) -> Result<(), PluginHostError> { + if self.state.draining.load(Ordering::Acquire) { + return Err(PluginHostError::ShuttingDown); + } + let permit = self.state.outbound.reserve().await.map_err(|_| { + PluginHostError::ConnectionClosed("JSON-RPC writer is closed".to_string()) + })?; + let _admission = self.state.admission.lock().await; + if self.state.draining.load(Ordering::Acquire) { + return Err(PluginHostError::ShuttingDown); + } + if self.is_closed() { + return Err(PluginHostError::ConnectionClosed( + "JSON-RPC peer is closed".to_string(), + )); + } + permit.send(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + })); + log::debug!( + "Plugin host RPC notification sent: generation={}, method={}", + self.state.generation, + method + ); + Ok(()) + } + + pub async fn begin_draining(&self) -> usize { + let _admission = self.state.admission.lock().await; + self.state.draining.store(true, Ordering::Release); + let pending = self.state.pending.lock().await; + pending.len() + } + + pub async fn wait_for_pending(&self, deadline: Duration) -> bool { + let wait = async { + loop { + let notified = self.state.pending_empty.notified(); + if self.state.pending.lock().await.is_empty() { + return; + } + notified.await; + } + }; + tokio::time::timeout(deadline, wait).await.is_ok() + } + + pub async fn close(&self, reason: impl Into) { + self.state.close(reason.into()).await; + } + + pub async fn register_handler( + &self, + method: &str, + handler: F, + ) -> Result<(), PluginHostError> + where + F: Fn(Value) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let mut handlers = self.state.handlers.write().await; + if handlers.contains_key(method) { + return Err(PluginHostError::DuplicateHandler(method.to_string())); + } + handlers.insert( + method.to_string(), + Arc::new(move |params| Box::pin(handler(params))), + ); + Ok(()) + } +} + +pub struct JsonRpcPeer { + client: PluginHostClient, +} + +impl JsonRpcPeer { + pub fn start(stream: TcpStream, generation: u64, max_frame_bytes: usize) -> Self { + let (outbound, receiver) = mpsc::channel(OUTBOUND_CAPACITY); + let state = Arc::new(PeerState { + generation, + max_frame_bytes, + sequence: AtomicU64::new(0), + admission: Mutex::new(()), + pending: Mutex::new(HashMap::new()), + handlers: RwLock::new(HashMap::new()), + handler_limit: Arc::new(Semaphore::new(HANDLER_CONCURRENCY)), + outbound, + closed: AtomicBool::new(false), + draining: AtomicBool::new(false), + pending_empty: Notify::new(), + close_signal: watch::channel(false).0, + }); + let (reader, writer) = stream.into_split(); + tokio::spawn(run_reader(reader, state.clone())); + tokio::spawn(run_writer(writer, receiver, state.clone())); + Self { + client: PluginHostClient { state }, + } + } + + pub fn client(&self) -> PluginHostClient { + self.client.clone() + } +} + +pub(super) struct PeerState { + pub(super) generation: u64, + pub(super) max_frame_bytes: usize, + pub(super) sequence: AtomicU64, + pub(super) admission: Mutex<()>, + pub(super) pending: Mutex>, + pub(super) handlers: RwLock>, + pub(super) handler_limit: Arc, + pub(super) outbound: mpsc::Sender, + pub(super) closed: AtomicBool, + pub(super) draining: AtomicBool, + pub(super) pending_empty: Notify, + pub(super) close_signal: watch::Sender, +} + +impl PeerState { + async fn register_pending( + &self, + request_id: String, + sender: PendingSender, + allow_during_shutdown: bool, + ) -> Result<(), PluginHostError> { + let _admission = self.admission.lock().await; + let mut pending = self.pending.lock().await; + if self.draining.load(Ordering::Acquire) && !allow_during_shutdown { + return Err(PluginHostError::ShuttingDown); + } + if self.closed.load(Ordering::Acquire) { + return Err(PluginHostError::ConnectionClosed( + "JSON-RPC peer is closed".to_string(), + )); + } + pending.insert(request_id, sender); + Ok(()) + } + + pub(super) async fn remove_pending(&self, request_id: &str) -> Option { + let mut pending = self.pending.lock().await; + let sender = pending.remove(request_id); + if pending.is_empty() { + self.pending_empty.notify_waiters(); + } + sender + } + + pub(super) async fn close(&self, reason: String) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + self.close_signal.send_replace(true); + let pending = std::mem::take(&mut *self.pending.lock().await); + self.pending_empty.notify_waiters(); + for sender in pending.into_values() { + let _ = sender.send(Err(PluginHostError::ConnectionClosed(reason.clone()))); + } + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs b/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs new file mode 100644 index 000000000..ebe5d3d2a --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/peer_runtime.rs @@ -0,0 +1,227 @@ +use crate::peer::{PeerState, RpcHandlerError}; +use crate::{read_frame, write_frame, PluginHostError}; +use serde_json::{json, Map, Value}; +use std::sync::Arc; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::{mpsc, OwnedSemaphorePermit}; + +pub(super) async fn run_reader(mut reader: OwnedReadHalf, state: Arc) { + let mut closed = state.close_signal.subscribe(); + if *closed.borrow() { + return; + } + loop { + let message = tokio::select! { + biased; + change = closed.changed() => { + let _ = change; + return; + } + result = read_frame(&mut reader, state.max_frame_bytes) => result, + }; + let result = match message { + Ok(message) => route_message(message, state.clone()).await, + Err(error) => Err(error), + }; + if let Err(error) = result { + state.close(error.to_string()).await; + return; + } + } +} + +pub(super) async fn run_writer( + mut writer: OwnedWriteHalf, + mut receiver: mpsc::Receiver, + state: Arc, +) { + let mut closed = state.close_signal.subscribe(); + if *closed.borrow() { + return; + } + loop { + let message = tokio::select! { + biased; + change = closed.changed() => { + let _ = change; + receiver.close(); + return; + } + message = receiver.recv() => message, + }; + let Some(message) = message else { + state + .close("JSON-RPC outbound channel is closed".to_string()) + .await; + receiver.close(); + return; + }; + if let Err(error) = write_frame(&mut writer, &message, state.max_frame_bytes).await { + state.close(error.to_string()).await; + receiver.close(); + return; + } + } +} + +async fn route_message(message: Value, state: Arc) -> Result<(), PluginHostError> { + let object = message.as_object().ok_or_else(|| { + PluginHostError::Protocol("JSON-RPC message must be an object".to_string()) + })?; + if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Err(protocol_error("JSON-RPC version must be 2.0")); + } + if object.contains_key("method") { + return route_request(object, state); + } + route_response(object, &state).await +} + +fn route_request( + object: &Map, + state: Arc, +) -> Result<(), PluginHostError> { + if object.contains_key("result") || object.contains_key("error") { + return Err(protocol_error( + "JSON-RPC request must not contain result or error", + )); + } + let method = object + .get("method") + .and_then(Value::as_str) + .filter(|method| !method.is_empty()) + .ok_or_else(|| protocol_error("JSON-RPC request has no non-empty string method"))?; + let request_id = match object.get("id") { + Some(Value::String(request_id)) if !request_id.is_empty() => Some(request_id.clone()), + Some(_) => { + return Err(protocol_error( + "JSON-RPC request id must be a non-empty string", + )) + } + None => None, + }; + let params = object.get("params").cloned().unwrap_or(Value::Null); + match state.handler_limit.clone().try_acquire_owned() { + Ok(permit) => { + tokio::spawn(dispatch_request( + state, + permit, + request_id, + method.to_string(), + params, + )); + } + Err(_) => reject_overloaded_request(&state, request_id)?, + } + Ok(()) +} + +fn reject_overloaded_request( + state: &PeerState, + request_id: Option, +) -> Result<(), PluginHostError> { + let Some(request_id) = request_id else { + return Ok(()); + }; + state + .outbound + .try_send(json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32000, + "message": "JSON-RPC handler concurrency limit reached" + } + })) + .map_err(|_| protocol_error("JSON-RPC outbound queue is full")) +} + +async fn route_response( + object: &Map, + state: &PeerState, +) -> Result<(), PluginHostError> { + let request_id = object + .get("id") + .and_then(Value::as_str) + .filter(|request_id| !request_id.is_empty()) + .ok_or_else(|| protocol_error("JSON-RPC response has no non-empty string id"))?; + let result = match (object.get("result"), object.get("error")) { + (Some(result), None) => Ok(result.clone()), + (None, Some(error)) => parse_rpc_error(error), + (Some(_), Some(_)) => Err(protocol_error( + "JSON-RPC response must not contain both result and error", + )), + (None, None) => Err(protocol_error( + "JSON-RPC response has neither result nor error", + )), + }; + let protocol_failure = result.as_ref().err().and_then(|error| match error { + PluginHostError::Protocol(message) => Some(message.clone()), + _ => None, + }); + if let Some(sender) = state.remove_pending(request_id).await { + log::debug!( + "Plugin host RPC response received: generation={}, request_id={}, outcome={}", + state.generation, + request_id, + if result.is_ok() { "success" } else { "error" } + ); + let _ = sender.send(result); + } + match protocol_failure { + Some(message) => Err(PluginHostError::Protocol(message)), + None => Ok(()), + } +} + +async fn dispatch_request( + state: Arc, + _permit: OwnedSemaphorePermit, + request_id: Option, + method: String, + params: Value, +) { + let handler = state.handlers.read().await.get(&method).cloned(); + let result = match handler { + Some(handler) => handler(params).await, + None => Err(RpcHandlerError::new( + -32601, + format!("Method not found: {method}"), + )), + }; + let Some(request_id) = request_id else { + return; + }; + let response = match result { + Ok(value) => json!({"jsonrpc": "2.0", "id": request_id, "result": value}), + Err(error) => json!({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": error.code, "message": error.message, "data": error.data}, + }), + }; + let _ = state.outbound.send(response).await; +} + +fn parse_rpc_error(value: &Value) -> Result { + let Some(object) = value.as_object() else { + return Err(protocol_error("JSON-RPC error must be an object")); + }; + let code = object + .get("code") + .and_then(Value::as_i64) + .ok_or_else(|| protocol_error("JSON-RPC error has no integer code"))?; + let message = object + .get("message") + .and_then(Value::as_str) + .ok_or_else(|| protocol_error("JSON-RPC error has no string message"))?; + Err(PluginHostError::Rpc { + code, + message: message.to_string(), + data: object.get("data").cloned(), + }) +} + +fn protocol_error(message: &str) -> PluginHostError { + PluginHostError::Protocol(message.to_string()) +} diff --git a/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs new file mode 100644 index 000000000..bdcf33681 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/stream_registry.rs @@ -0,0 +1,328 @@ +use crate::http::{StreamDescriptor, MAX_STREAM_CHUNK_BYTES}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use thiserror::Error; +use tokio::sync::{Mutex, Notify}; + +const DEFAULT_MAX_ACTIVE_STREAMS: usize = 128; +const DEFAULT_MAX_TOTAL_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamReadParams { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "streamID")] + pub stream_id: String, + pub max_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StreamReadResult { + pub data: String, + pub eof: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StreamCancelParams { + #[serde(rename = "instanceID")] + pub instance_id: String, + #[serde(rename = "streamID")] + pub stream_id: String, + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct StreamCancelResult { + pub cancelled: bool, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum StreamRegistryError { + #[error("response stream registry capacity was reached")] + Capacity, + #[error("response stream body exceeds the registry byte limit")] + BodyTooLarge, + #[error("maxBytes must be between 1 and {MAX_STREAM_CHUNK_BYTES}")] + InvalidMaxBytes, + #[error("response stream belongs to a different plugin instance")] + InstanceMismatch, +} + +#[derive(Clone)] +pub struct PluginHostStreamRegistry { + state: Arc>, + sequence: Arc, + changed: Arc, + max_active_streams: usize, + max_total_bytes: usize, +} + +struct StreamRegistryState { + streams: HashMap, + total_bytes: usize, +} + +struct ResponseStream { + instance_id: String, + bytes: Vec, + offset: usize, +} + +impl Default for PluginHostStreamRegistry { + fn default() -> Self { + Self::with_limits(DEFAULT_MAX_ACTIVE_STREAMS, DEFAULT_MAX_TOTAL_BYTES) + } +} + +impl PluginHostStreamRegistry { + pub fn with_limits(max_active_streams: usize, max_total_bytes: usize) -> Self { + Self { + state: Arc::new(Mutex::new(StreamRegistryState { + streams: HashMap::new(), + total_bytes: 0, + })), + sequence: Arc::new(AtomicU64::new(0)), + changed: Arc::new(Notify::new()), + max_active_streams, + max_total_bytes, + } + } + + pub async fn add( + &self, + instance_id: &str, + bytes: Vec, + ) -> Result { + let mut state = self.state.lock().await; + if state.streams.len() >= self.max_active_streams { + return Err(StreamRegistryError::Capacity); + } + if bytes.len() > self.max_total_bytes + || state.total_bytes.saturating_add(bytes.len()) > self.max_total_bytes + { + return Err(StreamRegistryError::BodyTooLarge); + } + let length = bytes.len(); + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let stream_id = format!("backend-response-stream:{sequence}"); + state.total_bytes += length; + state.streams.insert( + stream_id.clone(), + ResponseStream { + instance_id: instance_id.to_string(), + bytes, + offset: 0, + }, + ); + Ok(StreamDescriptor { + stream_id, + length: Some(length), + }) + } + + pub async fn read( + &self, + params: StreamReadParams, + ) -> Result { + let max_bytes = params.max_bytes.unwrap_or(MAX_STREAM_CHUNK_BYTES); + if !(1..=MAX_STREAM_CHUNK_BYTES).contains(&max_bytes) { + return Err(StreamRegistryError::InvalidMaxBytes); + } + let mut state = self.state.lock().await; + let Some(stream) = state.streams.get_mut(¶ms.stream_id) else { + return Ok(StreamReadResult { + data: String::new(), + eof: true, + }); + }; + if stream.instance_id != params.instance_id { + return Err(StreamRegistryError::InstanceMismatch); + } + let end = stream + .offset + .saturating_add(max_bytes) + .min(stream.bytes.len()); + let data = BASE64_STANDARD.encode(&stream.bytes[stream.offset..end]); + stream.offset = end; + let eof = stream.offset == stream.bytes.len(); + if eof { + let removed = state + .streams + .remove(¶ms.stream_id) + .expect("stream exists"); + state.total_bytes = state.total_bytes.saturating_sub(removed.bytes.len()); + self.changed.notify_waiters(); + } + Ok(StreamReadResult { data, eof }) + } + + pub async fn cancel( + &self, + params: StreamCancelParams, + ) -> Result { + let mut state = self.state.lock().await; + let Some(stream) = state.streams.get(¶ms.stream_id) else { + return Ok(StreamCancelResult { cancelled: false }); + }; + if stream.instance_id != params.instance_id { + return Err(StreamRegistryError::InstanceMismatch); + } + let removed = state + .streams + .remove(¶ms.stream_id) + .expect("stream exists"); + state.total_bytes = state.total_bytes.saturating_sub(removed.bytes.len()); + self.changed.notify_waiters(); + Ok(StreamCancelResult { cancelled: true }) + } + + pub async fn cancel_instance(&self, instance_id: &str) -> usize { + let mut state = self.state.lock().await; + let stream_ids = state + .streams + .iter() + .filter(|(_, stream)| stream.instance_id == instance_id) + .map(|(stream_id, _)| stream_id.clone()) + .collect::>(); + for stream_id in &stream_ids { + if let Some(stream) = state.streams.remove(stream_id) { + state.total_bytes = state.total_bytes.saturating_sub(stream.bytes.len()); + } + } + if !stream_ids.is_empty() { + self.changed.notify_waiters(); + } + stream_ids.len() + } + + pub async fn cancel_all(&self) -> usize { + let mut state = self.state.lock().await; + let count = state.streams.len(); + state.streams.clear(); + state.total_bytes = 0; + if count > 0 { + self.changed.notify_waiters(); + } + count + } + + pub async fn active_count(&self) -> usize { + self.state.lock().await.streams.len() + } + + pub async fn wait_until_empty(&self, timeout: Duration) -> bool { + let wait = async { + loop { + let changed = self.changed.notified(); + if self.active_count().await == 0 { + return; + } + changed.await; + } + }; + tokio::time::timeout(timeout, wait).await.is_ok() + } +} + +#[cfg(test)] +mod tests { + use super::{ + PluginHostStreamRegistry, StreamCancelParams, StreamReadParams, StreamRegistryError, + }; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine; + + #[tokio::test] + async fn reads_chunks_and_removes_stream_at_eof() { + let registry = PluginHostStreamRegistry::with_limits(2, 32); + let descriptor = registry + .add("instance:1", b"abcdef".to_vec()) + .await + .expect("add stream"); + let first = registry + .read(StreamReadParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: Some(2), + }) + .await + .expect("first chunk"); + assert_eq!(BASE64_STANDARD.decode(first.data).unwrap(), b"ab"); + assert!(!first.eof); + let second = registry + .read(StreamReadParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: Some(8), + }) + .await + .expect("second chunk"); + assert_eq!(BASE64_STANDARD.decode(second.data).unwrap(), b"cdef"); + assert!(second.eof); + assert_eq!(registry.active_count().await, 0); + } + + #[tokio::test] + async fn enforces_instance_ownership_and_cancel() { + let registry = PluginHostStreamRegistry::default(); + let descriptor = registry + .add("instance:1", b"body".to_vec()) + .await + .expect("add stream"); + assert_eq!( + registry + .read(StreamReadParams { + instance_id: "instance:2".to_string(), + stream_id: descriptor.stream_id.clone(), + max_bytes: None, + }) + .await, + Err(StreamRegistryError::InstanceMismatch) + ); + assert!( + registry + .cancel(StreamCancelParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id, + reason: Some("test".to_string()), + }) + .await + .expect("cancel") + .cancelled + ); + } + + #[tokio::test] + async fn waits_for_response_streams_to_drain() { + let registry = PluginHostStreamRegistry::default(); + let descriptor = registry + .add("instance:1", b"body".to_vec()) + .await + .expect("add stream"); + let waiter = { + let registry = registry.clone(); + tokio::spawn(async move { + registry + .wait_until_empty(std::time::Duration::from_secs(1)) + .await + }) + }; + registry + .cancel(StreamCancelParams { + instance_id: "instance:1".to_string(), + stream_id: descriptor.stream_id, + reason: Some("test".to_string()), + }) + .await + .expect("cancel"); + assert!(waiter.await.expect("wait task")); + } +} diff --git a/src/crates/adapters/opencode-plugin-host/src/tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests.rs new file mode 100644 index 000000000..f4d65e377 --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/tests.rs @@ -0,0 +1,455 @@ +use super::{ + complete_handshake, read_frame, validate_config, write_frame, PluginHost, PluginHostConfig, + PluginHostError, PluginHostShutdownDisposition, PluginHostShutdownPolicy, + DEFAULT_MAX_FRAME_BYTES, +}; +use serde_json::json; +use std::path::PathBuf; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; +use tokio::process::Command; + +mod peer_tests; + +#[test] +fn relative_entry_is_rejected_before_process_start() { + let config = PluginHostConfig { + runtime_command: PathBuf::from("bun"), + entry: PathBuf::from("dist/extension-host.js"), + working_directory: PathBuf::from("."), + cache_directory: std::env::temp_dir(), + log_file: std::env::temp_dir().join("plugin-host.log"), + log_level: "debug".to_string(), + }; + + assert!(matches!( + validate_config(&config), + Err(PluginHostError::RelativeEntry(_)) + )); +} + +#[tokio::test] +async fn handshake_accepts_matching_token_and_returns_cache_directory() { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let cache_directory = std::env::temp_dir().join("bitfun-plugin-host-test-cache"); + let expected_cache_directory = cache_directory.to_string_lossy().into_owned(); + let host = tokio::spawn(async move { + let mut stream = TcpStream::connect(address) + .await + .expect("fake host should connect"); + write_frame( + &mut stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:1", + "method": "backend.handshake", + "params": { + "token": "test-token", + "protocolVersion": 1, + "opencodeVersion": "1.17.18", + "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES + } + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("fake host should write handshake"); + read_frame(&mut stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("fake host should read handshake response") + }); + let (mut backend_stream, _) = listener + .accept() + .await + .expect("backend should accept fake host"); + + let negotiated = complete_handshake(&mut backend_stream, "test-token", &cache_directory) + .await + .expect("matching handshake should succeed"); + let response = host.await.expect("fake host task should finish"); + + assert_eq!(negotiated, DEFAULT_MAX_FRAME_BYTES); + assert_eq!( + response["result"]["cacheDirectory"], + expected_cache_directory + ); +} + +#[tokio::test] +async fn node_child_connects_and_completes_authenticated_handshake() { + assert_runtime_child_connects("node").await; +} + +#[tokio::test] +async fn bun_child_connects_and_completes_authenticated_handshake() { + assert_runtime_child_connects("bun").await; +} + +#[tokio::test] +async fn configured_bun_host_connects_and_completes_authenticated_handshake() { + let Some(entry) = std::env::var_os("BITFUN_TEST_BUN_HOST_ENTRY").map(PathBuf::from) else { + return; + }; + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let working_directory = entry + .parent() + .expect("configured Bun host entry should have a parent") + .to_path_buf(); + + let mut host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("bun"), + entry, + working_directory, + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("configured Bun host should complete handshake"); + + assert!(host.is_connected().expect("host status should be readable")); +} + +#[tokio::test] +async fn child_stdout_and_stderr_are_written_to_plugin_host_log() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("logging-host.mjs"); + let log_file = directory.path().join("logs").join("plugin-host.log"); + tokio::fs::write( + &script, + r#"import net from "node:net"; +console.log("fixture stdout"); +console.error("fixture stderr"); +console.error(`fixture level=${process.env.OPENCODE_EXTENSION_HOST_LOG_LEVEL}`); +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +const request = Buffer.from(JSON.stringify({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +const header = Buffer.alloc(4); +header.writeUInt32BE(request.length); +socket.write(Buffer.concat([header, request])); +"#, + ) + .await + .expect("fake plugin host should be written"); + + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: log_file.clone(), + log_level: "info".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + for _ in 0..20 { + let content = tokio::fs::read_to_string(&log_file) + .await + .unwrap_or_default(); + if content.contains("[stdout] fixture stdout") + && content.contains("[stderr] fixture stderr") + && content.contains("[stderr] fixture level=info") + { + drop(host); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let content = tokio::fs::read_to_string(&log_file) + .await + .expect("plugin host log should be readable"); + assert!(content.contains("[stdout] fixture stdout")); + assert!(content.contains("[stderr] fixture stderr")); + assert!(content.contains("[stderr] fixture level=info")); +} + +#[tokio::test] +async fn plugin_host_shutdown_waits_for_rpc_response_and_process_exit() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("shutdown-host.mjs"); + tokio::fs::write(&script, graceful_shutdown_fixture()) + .await + .expect("graceful shutdown fixture should be written"); + let log_file = directory.path().join("plugin-host.log"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: log_file.clone(), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + + let report = host.shutdown(PluginHostShutdownPolicy::default()).await; + + assert_eq!(report.disposition, PluginHostShutdownDisposition::Graceful); + assert!(report.rpc_completed); + assert_eq!(report.exit_code, Some(0)); + let log = tokio::fs::read_to_string(log_file) + .await + .expect("plugin host shutdown log should be readable"); + assert!(log.contains("[stdout] fixture shutdown complete")); +} + +#[tokio::test] +async fn plugin_host_shutdown_reports_a_nonzero_exit_as_not_graceful() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("failed-shutdown-host.mjs"); + tokio::fs::write(&script, failed_shutdown_fixture()) + .await + .expect("failed shutdown fixture should be written"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + + let report = host.shutdown(PluginHostShutdownPolicy::default()).await; + + assert_eq!( + report.disposition, + PluginHostShutdownDisposition::ExitedAfterShutdown + ); + assert!(report.rpc_completed); + assert_eq!(report.exit_code, Some(7)); +} + +#[tokio::test] +async fn plugin_host_shutdown_forces_a_host_that_ignores_shutdown_and_eof() { + let runtime_available = Command::new("node") + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("hanging-shutdown-host.mjs"); + tokio::fs::write(&script, hanging_shutdown_fixture()) + .await + .expect("hanging shutdown fixture should be written"); + let host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from("node"), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + let policy = PluginHostShutdownPolicy { + drain_timeout: Duration::from_millis(50), + rpc_timeout: Duration::from_millis(50), + exit_timeout: Duration::from_millis(50), + eof_timeout: Duration::from_millis(50), + terminate_grace: Duration::from_millis(50), + }; + + let report = host.shutdown(policy).await; + + assert_eq!(report.disposition, PluginHostShutdownDisposition::Forced); + assert!(!report.rpc_completed); + assert!(report.duration_ms < 2_000); +} + +async fn assert_runtime_child_connects(runtime_command: &str) { + let runtime_available = Command::new(runtime_command) + .arg("--version") + .output() + .await + .is_ok_and(|output| output.status.success()); + if !runtime_available { + return; + } + let directory = tempfile::tempdir().expect("temporary directory should be created"); + let script = directory.path().join("fake-host.mjs"); + tokio::fs::write( + &script, + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +const request = Buffer.from(JSON.stringify({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +const header = Buffer.alloc(4); +header.writeUInt32BE(request.length); +socket.write(Buffer.concat([header, request])); +"#, + ) + .await + .expect("fake plugin host should be written"); + + let mut host = PluginHost::start(PluginHostConfig { + runtime_command: PathBuf::from(runtime_command), + entry: script, + working_directory: directory.path().to_path_buf(), + cache_directory: directory.path().join("cache"), + log_file: directory.path().join("plugin-host.log"), + log_level: "debug".to_string(), + }) + .await + .expect("runtime child should complete handshake"); + + assert!(host.is_connected().expect("host status should be readable")); +} + +fn graceful_shutdown_fixture() -> &'static str { + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < length + 4) return; + const message = JSON.parse(buffer.subarray(4, length + 4).toString()); + buffer = buffer.subarray(length + 4); + if (message.method === "host.shutdown") { + console.log("fixture shutdown complete"); + send({ jsonrpc: "2.0", id: message.id, result: { closed: true } }); + socket.end(); + } + } +}); +"# +} + +fn hanging_shutdown_fixture() -> &'static str { + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); +}); +setInterval(() => {}, 1000); +"# +} + +fn failed_shutdown_fixture() -> &'static str { + r#"import net from "node:net"; +const [host, port] = process.env.OPENCODE_EXTENSION_HOST_RPC_ADDRESS.split(":"); +const socket = net.createConnection({ host, port: Number(port) }); +let buffer = Buffer.alloc(0); +function send(message) { + const payload = Buffer.from(JSON.stringify(message)); + const header = Buffer.alloc(4); + header.writeUInt32BE(payload.length); + socket.write(Buffer.concat([header, payload])); +} +socket.on("connect", () => send({ + jsonrpc: "2.0", + id: "host:1", + method: "backend.handshake", + params: { + token: process.env.OPENCODE_EXTENSION_HOST_RPC_TOKEN, + protocolVersion: 1, + opencodeVersion: "1.17.18", + maxFrameBytes: 16777216 + } +})); +socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const length = buffer.readUInt32BE(0); + if (buffer.length < length + 4) return; + const message = JSON.parse(buffer.subarray(4, length + 4).toString()); + buffer = buffer.subarray(length + 4); + if (message.method === "host.shutdown") { + send({ jsonrpc: "2.0", id: message.id, result: { closed: true } }); + process.exitCode = 7; + socket.end(); + } + } +}); +"# +} diff --git a/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs new file mode 100644 index 000000000..5598724ee --- /dev/null +++ b/src/crates/adapters/opencode-plugin-host/src/tests/peer_tests.rs @@ -0,0 +1,349 @@ +use crate::{ + read_frame, write_frame, JsonRpcPeer, PluginDeclaration, PluginHostError, + PluginInstanceOpenRequest, PluginPrepareRequest, DEFAULT_MAX_FRAME_BYTES, +}; +use serde_json::json; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; + +#[tokio::test] +async fn peer_correlates_out_of_order_responses_by_request_id() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 7, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let first = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("first request should be readable"); + let second = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("second request should be readable"); + assert_eq!(first["id"], "backend:7:1"); + assert_eq!(second["id"], "backend:7:2"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": second["id"], "result": second["params"]}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("second response should be written first"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": first["id"], "result": first["params"]}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("first response should be written second"); + }); + + let first = client.request("host.first", json!({"value": 1}), Duration::from_secs(1)); + let second = client.request("host.second", json!({"value": 2}), Duration::from_secs(1)); + let (first_result, second_result) = tokio::join!(first, second); + + assert_eq!( + first_result.expect("first request should resolve"), + json!({"value": 1}) + ); + assert_eq!( + second_result.expect("second request should resolve"), + json!({"value": 2}) + ); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_handles_reentrant_host_request_while_backend_request_is_pending() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 8, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + client + .register_handler("backend.echo", |params| async move { Ok(params) }) + .await + .expect("handler should register"); + let host = tokio::spawn(async move { + let backend_request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("backend request should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": "host:2", + "method": "backend.echo", + "params": {"reentrant": true} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("reentrant request should be written"); + let reentrant_response = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("reentrant response should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": backend_request["id"], + "result": reentrant_response["result"] + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("backend response should be written"); + }); + + let result = client + .request("host.instance.open", json!({}), Duration::from_secs(1)) + .await + .expect("backend request should resolve after reentrant request"); + + assert_eq!(result, json!({"reentrant": true})); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn client_opens_a_typed_plugin_instance() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 13, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("instance open request should be readable"); + assert_eq!(request["method"], "host.instance.open"); + assert_eq!(request["params"]["instanceID"], "bitfun:test-instance"); + assert_eq!(request["params"]["plugins"][0]["spec"], "bitfun-demo-echo"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"instanceID": "bitfun:test-instance"} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("instance open response should be written"); + }); + + let result = client + .open_instance( + PluginInstanceOpenRequest { + instance_id: "bitfun:test-instance".to_string(), + project: json!({"id": "project", "worktree": "C:/workspace"}), + config: serde_json::Map::new(), + directory: "C:/workspace".to_string(), + worktree: "C:/workspace".to_string(), + plugins: vec![PluginDeclaration { + spec: "bitfun-demo-echo".to_string(), + options: None, + base_directory: None, + }], + configuration_fingerprint: Some("fixture-open".to_string()), + }, + Duration::from_secs(1), + ) + .await + .expect("instance open should resolve"); + + assert_eq!(result["instanceID"], "bitfun:test-instance"); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn client_prepares_typed_plugins() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 14, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("plugin prepare request should be readable"); + assert_eq!(request["method"], "host.plugins.prepare"); + assert_eq!( + request["params"]["configurationFingerprint"], + "fixture-prewarm" + ); + assert_eq!(request["params"]["plugins"][0]["spec"], "bitfun-demo-echo"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"prepared": [], "failed": [], "diagnostics": []} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("plugin prepare response should be written"); + }); + + let result = client + .prepare_plugins( + PluginPrepareRequest { + plugins: vec![PluginDeclaration { + spec: "bitfun-demo-echo".to_string(), + options: None, + base_directory: None, + }], + configuration_fingerprint: Some("fixture-prewarm".to_string()), + default_base_directory: None, + }, + Duration::from_secs(1), + ) + .await + .expect("plugin prepare should resolve"); + + assert_eq!(result["prepared"], json!([])); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_fails_pending_requests_when_host_disconnects() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 9, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("request should be readable"); + }); + + let error = client + .request("host.never", json!({}), Duration::from_secs(1)) + .await + .expect_err("disconnect should fail the pending request"); + + assert!(matches!(error, PluginHostError::ConnectionClosed(_))); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn peer_rejects_response_with_result_and_error() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 10, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let request = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("request should be readable"); + write_frame( + &mut host_stream, + &json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": {"invalid": true}, + "error": {"code": -32603, "message": "invalid envelope"} + }), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("malformed response should be written"); + }); + + let error = client + .request("host.invalid", json!({}), Duration::from_secs(1)) + .await + .expect_err("malformed response should fail the request"); + + assert!(matches!(error, PluginHostError::Protocol(_))); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn draining_waits_for_admitted_request_and_rejects_new_requests() { + let (backend_stream, mut host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 11, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + let host = tokio::spawn(async move { + let active = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("active request should be readable"); + assert_eq!(active["method"], "host.active"); + tokio::time::sleep(Duration::from_millis(50)).await; + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": active["id"], "result": {"done": true}}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("active response should be written"); + + let shutdown = read_frame(&mut host_stream, DEFAULT_MAX_FRAME_BYTES) + .await + .expect("shutdown request should be readable"); + assert_eq!(shutdown["method"], "host.shutdown"); + write_frame( + &mut host_stream, + &json!({"jsonrpc": "2.0", "id": shutdown["id"], "result": {"closed": true}}), + DEFAULT_MAX_FRAME_BYTES, + ) + .await + .expect("shutdown response should be written"); + }); + + let active_client = client.clone(); + let active = tokio::spawn(async move { + active_client + .request("host.active", json!({}), Duration::from_secs(1)) + .await + }); + tokio::task::yield_now().await; + + let admitted = client.begin_draining().await; + assert_eq!(admitted, 1); + let error = client + .request("host.rejected", json!({}), Duration::from_secs(1)) + .await + .expect_err("new request should be rejected while draining"); + assert!(matches!(error, PluginHostError::ShuttingDown)); + assert!(client.wait_for_pending(Duration::from_secs(1)).await); + assert_eq!( + active + .await + .expect("active request task should finish") + .expect("active request should complete"), + json!({"done": true}) + ); + assert_eq!( + client + .request_during_shutdown("host.shutdown", json!({}), Duration::from_secs(1)) + .await + .expect("shutdown request should complete"), + json!({"closed": true}) + ); + host.await.expect("fake host should finish"); +} + +#[tokio::test] +async fn draining_rejects_new_notifications() { + let (backend_stream, _host_stream) = connected_streams().await; + let peer = JsonRpcPeer::start(backend_stream, 12, DEFAULT_MAX_FRAME_BYTES); + let client = peer.client(); + client.begin_draining().await; + + let error = client + .notify("host.rejected", json!({})) + .await + .expect_err("new notification should be rejected while draining"); + + assert!(matches!(error, PluginHostError::ShuttingDown)); +} + +async fn connected_streams() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("test listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let host = tokio::spawn(async move { + TcpStream::connect(address) + .await + .expect("fake host should connect") + }); + let (backend_stream, _) = listener + .accept() + .await + .expect("backend should accept fake host"); + let host_stream = host.await.expect("fake host connection should finish"); + (backend_stream, host_stream) +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 677d83b2f..fe22cdcc0 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -118,6 +118,7 @@ bitfun-runtime-services = { path = "../../execution/runtime-services", optional # Reviewed product-full plugin composition root. bitfun-opencode-adapter = { path = "../../adapters/opencode-adapter", optional = true } +bitfun-opencode-plugin-host = { path = "../../adapters/opencode-plugin-host", optional = true } bitfun-claude-code-adapter = { path = "../../adapters/claude-code-adapter", optional = true } bitfun-codex-adapter = { path = "../../adapters/codex-adapter", optional = true } bitfun-plugin-runtime-client = { path = "../../execution/plugin-runtime-client", optional = true } @@ -345,7 +346,9 @@ tools-agent-control = [ "scheduled-jobs", ] plugin-runtime = [ + "agent-runtime", "external-sources", + "dep:bitfun-opencode-plugin-host", "dep:bitfun-plugin-runtime-client", ] debug-log = [ diff --git a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs index 89ede815f..0b0d21199 100644 --- a/src/crates/assembly/core/src/agentic/persistence/session_branch.rs +++ b/src/crates/assembly/core/src/agentic/persistence/session_branch.rs @@ -1,11 +1,11 @@ use super::manager::PersistenceManager; use crate::agentic::core::{Session, SessionKind}; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_services_core::session::SessionBranchBoundary; use bitfun_services_core::session::{ build_branched_session_metadata, format_branch_session_name, resolve_branch_session_lineage, BranchSessionMetadataFacts, }; -use bitfun_services_core::session::SessionBranchBoundary; pub use bitfun_services_core::session::{SessionBranchRequest, SessionBranchResult}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs index 3917e1c19..5459442cd 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs @@ -112,7 +112,11 @@ pub(crate) fn classify_evaluate_exception(message: &str) -> BitFunError { if message.contains("cross-origin iframe") { hints.push("The page contains cross-origin iframes whose contents cannot be inspected or clicked — an element inside one is unreachable; work with the top-level document instead"); } - structured_error(ErrorCode::NotFound, format!("JS error: {}", message), &hints) + structured_error( + ErrorCode::NotFound, + format!("JS error: {}", message), + &hints, + ) } else { structured_error( ErrorCode::Internal, @@ -1666,9 +1670,10 @@ mod structured_error_tests { #[test] fn classify_transport_error_maps_dead_socket_to_wrong_tab() { - let msg = - classify_transport_error(BitFunError::tool("CDP send failed: broken pipe".to_string())) - .to_string(); + let msg = classify_transport_error(BitFunError::tool( + "CDP send failed: broken pipe".to_string(), + )) + .to_string(); assert!(msg.contains("[WRONG_TAB]"), "got: {msg}"); assert!(msg.contains("browser.connect"), "got: {msg}"); } diff --git a/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs index 63c306048..059795907 100644 --- a/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs @@ -437,9 +437,10 @@ mod tests { custom.agent_type = Some("ReviewSecurity".to_string()); assert!(!review_read_receipts_enabled(&custom)); - custom - .custom_data - .insert("deep_review_run_manifest".to_string(), serde_json::json!({})); + custom.custom_data.insert( + "deep_review_run_manifest".to_string(), + serde_json::json!({}), + ); assert!(review_read_receipts_enabled(&custom)); let mut worker = test_context(Some("session-2"), PathBuf::from("/tmp")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index 9428f3b0c..3bd1c6e58 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -643,9 +643,7 @@ Output: meta: None, }; } - if let (Some(context), Some(parsed)) = - (context, exec_command_run_input_from_input(input)) - { + if let (Some(context), Some(parsed)) = (context, exec_command_run_input_from_input(input)) { if let Some(rejection) = crate::agentic::execution::edit_constraint_guard::check_bash_command( context, parsed.cmd, diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index c203a8653..bbfbbd5e7 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -382,9 +382,9 @@ impl PathManager { self.user_data_dir().join("plugins") } - /// Get logs directory: ~/.config/bitfun/logs/ + /// Get logs directory: ~/.config/bitfun/config/logs/ pub fn logs_dir(&self) -> PathBuf { - self.user_root.join("logs") + self.user_config_dir().join("logs") } /// Get temp directory: ~/.config/bitfun/temp/ @@ -919,6 +919,7 @@ mod tests { let pm = PathManager::new().expect("path manager should use env overrides"); assert_eq!(pm.user_config_dir(), user_root.join("config")); assert_eq!(pm.user_data_dir(), user_root.join("data")); + assert_eq!(pm.logs_dir(), user_root.join("config").join("logs")); assert_eq!(pm.bitfun_home_dir(), home_root); } diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 77ef2c8c4..607de1662 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -37,6 +37,12 @@ pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; #[cfg(feature = "plugin-runtime")] +pub mod plugin_host; +#[cfg(feature = "plugin-runtime")] +mod plugin_host_http; +#[cfg(feature = "plugin-runtime")] +mod plugin_host_http_routes; +#[cfg(feature = "plugin-runtime")] pub mod plugin_runtime; #[cfg(feature = "plugin-source")] pub mod plugin_source; diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs new file mode 100644 index 000000000..97c32d1a2 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -0,0 +1,735 @@ +use bitfun_opencode_plugin_host::{ + PluginDeclaration, PluginHost, PluginHostConfig, PluginHostShutdownPolicy, + PluginHostShutdownReport, PluginInstanceOpenRequest, PluginPrepareRequest, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use terminal_core::{CloseSessionRequest, TerminalApi}; +use tokio::sync::{Mutex, Notify, OnceCell}; + +const BUN_HOST_ENTRY_ENV: &str = "BITFUN_OPENCODE_BUN_HOST_ENTRY"; +const BUN_COMMAND_ENV: &str = "BITFUN_BUN_COMMAND"; +static PLUGIN_HOST: OnceCell>> = OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_REPORT: OnceCell>> = + OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_NOTIFY: OnceCell = OnceCell::const_new(); +static PLUGIN_HOST_SHUTDOWN_STARTED: AtomicBool = AtomicBool::new(false); +static PLUGIN_HOST_SHUTDOWN_COMPLETE: AtomicBool = AtomicBool::new(false); +static PLUGIN_HOST_INSTANCES: OnceCell>> = + OnceCell::const_new(); +static PLUGIN_HOST_PTY_OWNERS: OnceCell>> = OnceCell::const_new(); +static NEXT_INSTANCE_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +pub(crate) struct PluginHostInstance { + pub(crate) canonical_directory: String, + pub(crate) directory: PathBuf, + pub(crate) worktree: PathBuf, + pub(crate) project_id: String, + pub(crate) created_at_ms: i64, + pub(crate) instance_id: String, + pub(crate) open_result: Value, + pub(crate) ready: bool, +} + +impl PluginHostInstance { + pub(crate) fn is_ready(&self) -> bool { + self.ready + } +} + +#[derive(Debug, Clone, Copy)] +struct PluginHostLaunchSpec { + runtime_name: &'static str, + default_command: &'static str, + command_env: &'static str, + entry_env: &'static str, + entry_filename: &'static str, +} + +impl PluginHostLaunchSpec { + fn bun() -> Self { + Self { + runtime_name: "Bun", + default_command: "bun", + command_env: BUN_COMMAND_ENV, + entry_env: BUN_HOST_ENTRY_ENV, + entry_filename: "extension-host.js", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostStartup { + Disabled, + Started, + AlreadyStarted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginHostLaunchPolicy { + Enabled, + Disabled, +} + +pub async fn initialize_configured_plugin_host( + launch_policy: PluginHostLaunchPolicy, +) -> crate::BitFunResult { + initialize_configured_plugin_host_with_log_file(launch_policy, None).await +} + +pub async fn initialize_configured_plugin_host_with_log_file( + launch_policy: PluginHostLaunchPolicy, + log_file: Option, +) -> crate::BitFunResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + if launch_policy == PluginHostLaunchPolicy::Disabled { + return Ok(PluginHostStartup::Disabled); + } + let config_service = get_global_config_service().await?; + let config: GlobalConfig = config_service.get_config(None).await?; + if !config.has_configured_plugins() { + return Ok(PluginHostStartup::Disabled); + } + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + let launch_spec = PluginHostLaunchSpec::bun(); + + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let mut host_state = host_state.lock().await; + if PLUGIN_HOST_SHUTDOWN_STARTED.load(Ordering::Acquire) { + return Err(crate::BitFunError::ProcessError( + "Plugin host is shutting down".to_string(), + )); + } + if host_state.is_some() { + return Ok(PluginHostStartup::AlreadyStarted); + } + let path_manager = crate::infrastructure::try_get_path_manager_arc()?; + let log_file = log_file.unwrap_or_else(|| path_manager.logs_dir().join("plugin-host.log")); + let entry = resolve_host_entry(launch_spec)?; + let working_directory = entry.parent().ok_or_else(|| { + crate::BitFunError::config(format!( + "{} plugin host entry has no parent directory: {}", + launch_spec.runtime_name, + entry.display() + )) + })?; + let host = PluginHost::start(PluginHostConfig { + runtime_command: std::env::var_os(launch_spec.command_env) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(launch_spec.default_command)), + entry: entry.clone(), + working_directory: working_directory.to_path_buf(), + cache_directory: path_manager.cache_root().join("opencode-plugin-host"), + log_file, + log_level: config.app.logging.level.trim().to_lowercase(), + }) + .await + .map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to initialize {} plugin host from {}: {error}", + launch_spec.runtime_name, + entry.display() + )) + })?; + let client = host.client(); + crate::plugin_host_http::register_plugin_host_backend_handlers(client.clone()).await?; + let plugins = config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect::>(); + let configuration_fingerprint = plugin_config_fingerprint(&config)?; + *host_state = Some(host); + tokio::spawn(async move { + let plugin_count = plugins.len(); + log::info!( + "Configured plugin host background prewarm started: generation={}, plugin_count={}", + client.generation(), + plugin_count + ); + match client + .prepare_plugins( + PluginPrepareRequest { + plugins, + configuration_fingerprint: Some(configuration_fingerprint), + default_base_directory: None, + }, + std::time::Duration::from_secs(120), + ) + .await + { + Ok(result) => { + let prepared_count = result + .get("prepared") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let failed_count = result + .get("failed") + .and_then(Value::as_array) + .map_or(0, Vec::len); + log::info!( + "Configured plugin host background prewarm completed: generation={}, plugin_count={}, prepared_count={}, failed_count={}", + client.generation(), + plugin_count, + prepared_count, + failed_count + ); + } + Err(error) => { + log::warn!( + "Configured plugin host background prewarm failed: generation={}, plugin_count={}, error={}", + client.generation(), + plugin_count, + error + ); + } + } + }); + Ok(PluginHostStartup::Started) +} + +pub async fn set_configured_plugin_host_log_level(level: &str) -> crate::BitFunResult<()> { + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let client = host_state.lock().await.as_ref().map(PluginHost::client); + let Some(client) = client else { + return Ok(()); + }; + client.set_log_level(level).await.map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to update plugin host log level to {}: {}", + level, error + )) + }) +} + +pub async fn ensure_configured_plugin_instance( + launch_policy: PluginHostLaunchPolicy, + directory: PathBuf, + worktree: PathBuf, + project_id: Option, + config: Map, +) -> crate::BitFunResult> { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + if launch_policy == PluginHostLaunchPolicy::Disabled { + return Ok(None); + } + let config_service = get_global_config_service().await?; + let global_config: GlobalConfig = config_service.get_config(None).await?; + if !global_config.has_configured_plugins() { + return Ok(None); + } + if directory.as_os_str().is_empty() || !directory.is_dir() { + return Err(crate::BitFunError::Validation(format!( + "Plugin host instance directory does not exist: {}", + directory.display() + ))); + } + + let canonical_directory = dunce::canonicalize(&directory).map_err(|error| { + crate::BitFunError::Io(std::io::Error::other(format!( + "Failed to canonicalize plugin host instance directory {}: {error}", + directory.display() + ))) + })?; + let canonical_directory_string = canonical_directory.to_string_lossy().into_owned(); + let comparable_directory = comparable_instance_directory(&canonical_directory_string); + let config_fingerprint = plugin_config_fingerprint(&global_config)?; + let client = { + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + host_state + .lock() + .await + .as_ref() + .map(PluginHost::client) + .ok_or_else(|| { + crate::BitFunError::ProcessError( + "Configured plugin host is not running".to_string(), + ) + })? + }; + let instances = PLUGIN_HOST_INSTANCES + .get_or_init(|| async { Mutex::new(HashMap::new()) }) + .await; + let instance_key = format!("{comparable_directory}\n{config_fingerprint}"); + if let Some(instance) = instances.lock().await.get(&instance_key).cloned() { + log::debug!( + "Configured plugin host instance reused: generation={}, instance_id={}", + client.generation(), + instance.instance_id + ); + return Ok(Some(instance.open_result.clone())); + } + + let previous_keys = instances + .lock() + .await + .iter() + .filter(|(_, instance)| instance.canonical_directory == comparable_directory) + .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) + .collect::>(); + for (key, instance_id) in previous_keys { + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance_id).await; + } + client + .close_instance(&instance_id, std::time::Duration::from_secs(10)) + .await + .map_err(|error| { + crate::BitFunError::ProcessError(format!( + "Failed to close stale plugin host instance {instance_id}: {error}" + )) + })?; + close_plugin_host_ptys(&instance_id).await; + instances.lock().await.remove(&key); + } + + let sequence = NEXT_INSTANCE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let instance_id = format!("bitfun:host:{}:{sequence}", client.generation()); + let project_id = project_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + format!( + "bitfun-project-{}", + hex::encode(Sha256::digest(canonical_directory_string.as_bytes())) + ) + }); + let now_ms = chrono::Utc::now().timestamp_millis(); + let opening_context = PluginHostInstance { + canonical_directory: comparable_directory.clone(), + directory: canonical_directory.clone(), + worktree: worktree.clone(), + project_id: project_id.clone(), + created_at_ms: now_ms, + instance_id: instance_id.clone(), + open_result: Value::Null, + ready: false, + }; + instances + .lock() + .await + .insert(instance_key.clone(), opening_context); + let open_result = match client + .open_instance( + PluginInstanceOpenRequest { + instance_id: instance_id.clone(), + project: serde_json::json!({ + "id": project_id, + "worktree": canonical_directory_string, + "time": {"created": now_ms}, + }), + config, + directory: canonical_directory.to_string_lossy().into_owned(), + worktree: worktree.to_string_lossy().into_owned(), + plugins: global_config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect(), + configuration_fingerprint: Some(config_fingerprint.clone()), + }, + std::time::Duration::from_secs(30), + ) + .await + { + Ok(result) => result, + Err(error) => { + close_plugin_host_ptys(&instance_id).await; + instances.lock().await.remove(&instance_key); + return Err(crate::BitFunError::ProcessError(format!( + "Failed to activate plugins for workspace {}: {error}", + canonical_directory.display() + ))); + } + }; + log::info!( + "Configured plugin host instance activated: generation={}, instance_id={}, plugin_count={}", + client.generation(), + instance_id, + global_config.plugin.len() + ); + if let Some(instance) = instances.lock().await.get_mut(&instance_key) { + instance.open_result = open_result.clone(); + instance.ready = true; + } + Ok(Some(open_result)) +} + +pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option { + let instances = PLUGIN_HOST_INSTANCES.get()?; + instances + .lock() + .await + .values() + .find(|instance| instance.instance_id == instance_id) + .cloned() +} + +pub(crate) async fn register_plugin_host_pty(pty_id: &str, instance_id: &str) { + let owners = PLUGIN_HOST_PTY_OWNERS + .get_or_init(|| async { Mutex::new(HashMap::new()) }) + .await; + owners + .lock() + .await + .insert(pty_id.to_string(), instance_id.to_string()); +} + +pub(crate) async fn plugin_host_pty_owned_by(pty_id: &str, instance_id: &str) -> bool { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return false; + }; + owners + .lock() + .await + .get(pty_id) + .is_some_and(|owner| owner == instance_id) +} + +pub(crate) async fn unregister_plugin_host_pty(pty_id: &str, instance_id: &str) -> bool { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return false; + }; + let mut owners = owners.lock().await; + if owners.get(pty_id).is_some_and(|owner| owner == instance_id) { + owners.remove(pty_id); + true + } else { + false + } +} + +pub(crate) async fn prune_plugin_host_pty(pty_id: &str, instance_id: &str) { + if unregister_plugin_host_pty(pty_id, instance_id).await { + log::debug!( + "Removed stale plugin host PTY ownership: instance_id={}, pty_id={}", + instance_id, + pty_id + ); + } +} + +pub(crate) async fn plugin_host_pty_ids_for_instance(instance_id: &str) -> Vec { + let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() else { + return Vec::new(); + }; + owners + .lock() + .await + .iter() + .filter_map(|(pty_id, owner)| (owner == instance_id).then_some(pty_id.clone())) + .collect() +} + +async fn close_plugin_host_ptys(instance_id: &str) { + let pty_ids = plugin_host_pty_ids_for_instance(instance_id).await; + if pty_ids.is_empty() { + return; + } + let api = match TerminalApi::from_singleton() { + Ok(api) => Some(api), + Err(error) => { + log::warn!( + "Plugin host PTYs could not be closed because the terminal owner is unavailable: instance_id={}, pty_count={}, error={}", + instance_id, + pty_ids.len(), + error + ); + None + } + }; + for pty_id in &pty_ids { + if let Some(api) = api.as_ref() { + if let Err(error) = api + .close_session(CloseSessionRequest { + session_id: pty_id.clone(), + immediate: Some(false), + }) + .await + { + log::warn!( + "Plugin host PTY close failed: instance_id={}, pty_id={}, error={}", + instance_id, + pty_id, + error + ); + } + } + unregister_plugin_host_pty(pty_id, instance_id).await; + } + log::info!( + "Plugin host PTY cleanup completed: instance_id={}, pty_count={}", + instance_id, + pty_ids.len() + ); +} + +async fn close_all_plugin_host_ptys() { + let instance_ids = if let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() { + let mut instance_ids = owners.lock().await.values().cloned().collect::>(); + instance_ids.sort(); + instance_ids.dedup(); + instance_ids + } else { + Vec::new() + }; + for instance_id in instance_ids { + close_plugin_host_ptys(&instance_id).await; + } +} + +pub(crate) fn instance_directories_equal(requested: &str, expected: &Path) -> bool { + let matches = |candidate: &str| { + dunce::canonicalize(candidate) + .map(|path| { + comparable_instance_directory(&path.to_string_lossy()) + == comparable_instance_directory(&expected.to_string_lossy()) + }) + .unwrap_or(false) + }; + matches(requested) + || urlencoding::decode(requested) + .ok() + .is_some_and(|decoded| decoded.as_ref() != requested && matches(decoded.as_ref())) +} + +pub async fn shutdown_configured_plugin_host( +) -> crate::BitFunResult> { + let shutdown_report = PLUGIN_HOST_SHUTDOWN_REPORT + .get_or_init(|| async { Mutex::new(None) }) + .await; + let shutdown_notify = PLUGIN_HOST_SHUTDOWN_NOTIFY + .get_or_init(|| async { Notify::new() }) + .await; + + if PLUGIN_HOST_SHUTDOWN_STARTED.swap(true, Ordering::AcqRel) { + loop { + let notified = shutdown_notify.notified(); + if PLUGIN_HOST_SHUTDOWN_COMPLETE.load(Ordering::Acquire) { + return Ok(shutdown_report.lock().await.clone()); + } + notified.await; + } + } + + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.begin_draining().await; + } + let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; + let host = host_state.lock().await.take(); + if let Some(instances) = PLUGIN_HOST_INSTANCES.get() { + instances.lock().await.clear(); + } + let report = match host { + Some(host) => { + log::info!("Starting configured plugin host graceful shutdown"); + Some(host.shutdown(PluginHostShutdownPolicy::default()).await) + } + None => { + log::debug!("Configured plugin host graceful shutdown skipped: host not started"); + None + } + }; + close_all_plugin_host_ptys().await; + if let Some(owners) = PLUGIN_HOST_PTY_OWNERS.get() { + owners.lock().await.clear(); + } + *shutdown_report.lock().await = report.clone(); + PLUGIN_HOST_SHUTDOWN_COMPLETE.store(true, Ordering::Release); + shutdown_notify.notify_waiters(); + Ok(report) +} + +fn resolve_host_entry(spec: PluginHostLaunchSpec) -> crate::BitFunResult { + if let Some(entry) = std::env::var_os(spec.entry_env) { + return absolutize_existing_entry(PathBuf::from(entry), spec); + } + let executable = std::env::current_exe().map_err(crate::BitFunError::Io)?; + let executable_directory = executable.parent().ok_or_else(|| { + crate::BitFunError::config(format!( + "BitFun executable has no parent directory: {}", + executable.display() + )) + })?; + let bundled_entry = executable_directory + .join("resources") + .join("ext-host") + .join(spec.entry_filename); + if bundled_entry.is_file() { + return Ok(bundled_entry); + } + let development_entry = development_host_entry(spec); + if let Some(entry) = development_entry.filter(|entry| entry.is_file()) { + return Ok(entry); + } + Err(crate::BitFunError::NotFound(format!( + "{} plugin host entry does not exist at {}. Set {} in development.", + spec.runtime_name, + bundled_entry.display(), + spec.entry_env + ))) +} + +fn development_host_entry(spec: PluginHostLaunchSpec) -> Option { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(4) + .map(|repository_root| { + repository_root + .join("src") + .join("apps") + .join("extension-host") + .join("dist") + .join(spec.entry_filename) + }) +} + +fn plugin_declaration( + declaration: &crate::service::config::PluginDeclarationConfig, +) -> Option { + use crate::service::config::PluginDeclarationConfig; + + let declaration = match declaration { + PluginDeclarationConfig::Spec(spec) => PluginDeclaration { + spec: spec.clone(), + options: None, + base_directory: None, + }, + PluginDeclarationConfig::Detailed(details) => PluginDeclaration { + spec: details.spec.clone(), + options: details.options.clone(), + base_directory: details.base_directory.clone(), + }, + }; + if declaration.spec.trim().is_empty() { + None + } else { + Some(declaration) + } +} + +fn plugin_config_fingerprint( + config: &crate::service::config::GlobalConfig, +) -> crate::BitFunResult { + let declarations = config + .plugin + .iter() + .filter_map(plugin_declaration) + .collect::>(); + let bytes = serde_json::to_vec(&declarations)?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn comparable_instance_directory(directory: &str) -> String { + let mut comparable = directory.replace('\\', "/"); + #[cfg(windows)] + comparable.make_ascii_lowercase(); + comparable +} + +fn absolutize_existing_entry( + entry: PathBuf, + spec: PluginHostLaunchSpec, +) -> crate::BitFunResult { + let entry = if entry.is_absolute() { + entry + } else { + std::env::current_dir() + .map_err(crate::BitFunError::Io)? + .join(entry) + }; + if !entry.is_file() { + return Err(crate::BitFunError::NotFound(format!( + "{} plugin host entry does not exist: {}. Set {} in development.", + spec.runtime_name, + entry.display(), + spec.entry_env + ))); + } + Ok(entry) +} + +#[cfg(test)] +mod tests { + use super::{ + development_host_entry, initialize_configured_plugin_host, instance_directories_equal, + plugin_host_pty_ids_for_instance, plugin_host_pty_owned_by, register_plugin_host_pty, + unregister_plugin_host_pty, PluginHostLaunchPolicy, PluginHostLaunchSpec, + PluginHostStartup, + }; + use std::path::Path; + + #[test] + fn bun_runtime_selects_bun_command_and_entry() { + let spec = PluginHostLaunchSpec::bun(); + + assert_eq!(spec.default_command, "bun"); + assert_eq!(spec.entry_filename, "extension-host.js"); + assert_eq!(spec.command_env, "BITFUN_BUN_COMMAND"); + assert_eq!(spec.entry_env, "BITFUN_OPENCODE_BUN_HOST_ENTRY"); + } + + #[test] + fn development_host_entry_is_owned_by_the_bitfun_repository() { + let spec = PluginHostLaunchSpec::bun(); + let entry = development_host_entry(spec).expect("BitFun repository root"); + + assert!(entry.ends_with( + Path::new("src") + .join("apps") + .join("extension-host") + .join("dist") + .join("extension-host.js") + )); + } + + #[tokio::test] + async fn disabled_launch_policy_skips_host_initialization() { + let status = initialize_configured_plugin_host(PluginHostLaunchPolicy::Disabled) + .await + .expect("disabled policy"); + + assert_eq!(status, PluginHostStartup::Disabled); + } + + #[test] + fn instance_directory_matching_accepts_encoded_paths_and_rejects_siblings() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let workspace = directory.path().join("workspace with space"); + let sibling = directory.path().join("workspace with space-sibling"); + std::fs::create_dir_all(&workspace).expect("workspace directory"); + std::fs::create_dir_all(&sibling).expect("sibling directory"); + let encoded = urlencoding::encode(&workspace.to_string_lossy()).into_owned(); + + assert!(instance_directories_equal(&encoded, &workspace)); + assert!(!instance_directories_equal( + &sibling.to_string_lossy(), + &workspace + )); + } + + #[tokio::test] + async fn plugin_host_pty_ownership_is_instance_scoped() { + let pty_id = format!("pty-test-{}", std::process::id()); + let first = format!("instance-first-{}", std::process::id()); + let second = format!("instance-second-{}", std::process::id()); + + register_plugin_host_pty(&pty_id, &first).await; + assert!(plugin_host_pty_owned_by(&pty_id, &first).await); + assert!(!plugin_host_pty_owned_by(&pty_id, &second).await); + assert_eq!( + plugin_host_pty_ids_for_instance(&first).await, + vec![pty_id.clone()] + ); + assert!(unregister_plugin_host_pty(&pty_id, &first).await); + } +} diff --git a/src/crates/assembly/core/src/plugin_host_http.rs b/src/crates/assembly/core/src/plugin_host_http.rs new file mode 100644 index 000000000..c14b882a1 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http.rs @@ -0,0 +1,522 @@ +use bitfun_opencode_plugin_host::{ + json_error_body, match_http_route, read_host_stream, BackendHttpRequest, BackendHttpResponse, + HostStreamReadError, HttpRouteError, OpenCodeClientRoute, PluginHostClient, + PluginHostStreamRegistry, RpcHandlerError, StreamCancelParams, StreamReadParams, + StreamRegistryError, MAX_HTTP_BODY_BYTES, +}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{Notify, OnceCell}; + +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const HTTP_DRAIN_TIMEOUT: Duration = Duration::from_secs(3); + +static PLUGIN_HOST_BACKEND_BRIDGE: OnceCell> = OnceCell::const_new(); + +pub(crate) struct PluginHostBackendBridge { + client: PluginHostClient, + streams: PluginHostStreamRegistry, + draining: AtomicBool, + active_requests: AtomicUsize, + requests_drained: Notify, +} + +struct ActiveRequest<'a> { + bridge: &'a PluginHostBackendBridge, +} + +impl Drop for ActiveRequest<'_> { + fn drop(&mut self) { + if self.bridge.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.bridge.requests_drained.notify_waiters(); + } + } +} + +#[derive(Debug)] +pub(crate) struct RouteFailure { + status: u16, + code: &'static str, + message: String, +} + +impl RouteFailure { + pub(crate) fn bad_request(message: impl Into) -> Self { + Self::new(400, "invalid_request", message) + } + + pub(crate) fn forbidden(message: impl Into) -> Self { + Self::new(403, "instance_scope_denied", message) + } + + pub(crate) fn not_found(message: impl Into) -> Self { + Self::new(404, "not_found", message) + } + + pub(crate) fn backend(message: impl Into) -> Self { + Self::new(502, "backend_failure", message) + } + + pub(crate) fn unavailable(message: impl Into) -> Self { + Self::new(503, "backend_unavailable", message) + } + + fn new(status: u16, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + } + } +} + +impl PluginHostBackendBridge { + fn new(client: PluginHostClient) -> Self { + Self { + client, + streams: PluginHostStreamRegistry::default(), + draining: AtomicBool::new(false), + active_requests: AtomicUsize::new(0), + requests_drained: Notify::new(), + } + } + + fn admit(&self) -> Option> { + if self.draining.load(Ordering::Acquire) { + return None; + } + self.active_requests.fetch_add(1, Ordering::AcqRel); + if self.draining.load(Ordering::Acquire) { + if self.active_requests.fetch_sub(1, Ordering::AcqRel) == 1 { + self.requests_drained.notify_waiters(); + } + return None; + } + Some(ActiveRequest { bridge: self }) + } + + async fn handle_http(self: Arc, params: Value) -> Result { + let request: BackendHttpRequest = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.http.request", error))?; + if request.instance_id.trim().is_empty() + || request.instance_id.len() > 256 + || request.request_id.trim().is_empty() + || request.request_id.len() > 256 + || request.method.len() > 16 + || request.headers.len() > 64 + { + return Err(RpcHandlerError::new( + -32602, + "Invalid request identity, method, or header count for backend.http.request", + )); + } + let started_at = Instant::now(); + let path = request.path.clone(); + let method = request.method.clone(); + let instance_id = request.instance_id.clone(); + let request_id = request.request_id.clone(); + let Some(_active) = self.admit() else { + return self + .http_error( + &instance_id, + 503, + "host_draining", + "Plugin host is shutting down", + &path, + ) + .await; + }; + + let route_match = match match_http_route(&method, &path) { + Ok(route_match) => route_match, + Err(HttpRouteError::InvalidPath) => { + return self + .http_error( + &instance_id, + 400, + "invalid_request", + "Request path is invalid", + &path, + ) + .await + } + Err(HttpRouteError::NotFound) => { + return self + .http_error( + &instance_id, + 404, + "route_not_found", + "OpenCode client route was not found", + &path, + ) + .await + } + Err(HttpRouteError::MethodNotAllowed) => { + return self + .http_error( + &instance_id, + 405, + "method_not_allowed", + "HTTP method is not allowed for this route", + &path, + ) + .await + } + }; + let operation = route_match.route.operation(); + let context = match crate::plugin_host::plugin_host_instance_by_id(&instance_id).await { + Some(context) => context, + None => { + return self + .http_error( + &instance_id, + 404, + "instance_not_found", + "Plugin host instance was not found", + &path, + ) + .await + } + }; + if !context.is_ready() { + log::debug!( + "Plugin client request admitted during activation: instance_id={}, request_id={}, operation={}", + instance_id, + request_id, + operation + ); + } + if let Some(directory) = route_match.query_first("directory") { + if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { + return self + .http_error( + &instance_id, + 403, + "instance_scope_denied", + "Request directory does not belong to this plugin instance", + &path, + ) + .await; + } + } + if let Some(directory) = request.headers.iter().find_map(|(name, value)| { + name.eq_ignore_ascii_case("x-opencode-directory") + .then_some(value.as_str()) + }) { + if !crate::plugin_host::instance_directories_equal(directory, &context.directory) { + return self + .http_error( + &instance_id, + 403, + "instance_scope_denied", + "Request directory does not belong to this plugin instance", + &path, + ) + .await; + } + } + let body = match request.body.as_ref() { + Some(descriptor) => match read_host_stream( + &self.client, + &instance_id, + descriptor, + MAX_HTTP_BODY_BYTES, + HTTP_REQUEST_TIMEOUT, + ) + .await + { + Ok(body) => body, + Err(HostStreamReadError::BodyTooLarge) => { + return self + .http_error( + &instance_id, + 413, + "request_too_large", + "Request body exceeds the configured limit", + &path, + ) + .await + } + Err(error) => { + return self + .http_error( + &instance_id, + 502, + "backend_failure", + &format!("Failed to read request body: {error}"), + &path, + ) + .await + } + }, + None => Vec::new(), + }; + + let outcome = tokio::time::timeout( + HTTP_REQUEST_TIMEOUT, + dispatch_route(&context, route_match.route, &route_match.query, &body), + ) + .await; + let response = match outcome { + Ok(Ok(value)) => self.json_response(&instance_id, 200, value).await, + Ok(Err(error)) => { + self.http_error( + &instance_id, + error.status, + error.code, + &error.message, + &path, + ) + .await + } + Err(_) => { + self.http_error( + &instance_id, + 504, + "backend_timeout", + "Backend route timed out", + &path, + ) + .await + } + }; + let status = response + .as_ref() + .ok() + .and_then(|value| value.get("status")) + .and_then(Value::as_u64) + .unwrap_or(500); + log::info!( + "Plugin client request completed: instance_id={}, request_id={}, method={}, path={}, status={}, duration_ms={}, route_status=A, operation={}", + instance_id, + request_id, + method, + path, + status, + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + operation + ); + response + } + + async fn json_response( + &self, + instance_id: &str, + status: u16, + value: Value, + ) -> Result { + let bytes = serde_json::to_vec(&value).map_err(|error| { + RpcHandlerError::new( + -32603, + format!("Failed to serialize HTTP response: {error}"), + ) + })?; + self.bytes_response(instance_id, status, "application/json", bytes) + .await + } + + async fn http_error( + &self, + instance_id: &str, + status: u16, + code: &str, + message: &str, + route: &str, + ) -> Result { + self.bytes_response( + instance_id, + status, + "application/json", + json_error_body(code, message, route), + ) + .await + } + + async fn bytes_response( + &self, + instance_id: &str, + status: u16, + content_type: &str, + bytes: Vec, + ) -> Result { + let body = self + .streams + .add(instance_id, bytes) + .await + .map_err(stream_rpc_error)?; + serde_json::to_value(BackendHttpResponse { + status, + status_text: None, + headers: vec![("content-type".to_string(), content_type.to_string())], + body: Some(body), + }) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + + pub(crate) async fn begin_draining(&self) { + self.draining.store(true, Ordering::Release); + let active_requests = self.active_requests.load(Ordering::Acquire); + let active_streams = self.streams.active_count().await; + log::info!( + "Plugin client bridge draining started: active_requests={}, active_streams={}", + active_requests, + active_streams + ); + let wait = async { + loop { + let notified = self.requests_drained.notified(); + if self.active_requests.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + }; + if tokio::time::timeout(HTTP_DRAIN_TIMEOUT, wait) + .await + .is_err() + { + log::warn!( + "Plugin client bridge request drain timed out: active_requests={}", + self.active_requests.load(Ordering::Acquire) + ); + } + let streams_drained = self.streams.wait_until_empty(HTTP_DRAIN_TIMEOUT).await; + if !streams_drained { + log::warn!( + "Plugin client bridge response stream drain timed out: active_streams={}", + self.streams.active_count().await + ); + } + let cancelled = self.streams.cancel_all().await; + log::info!( + "Plugin client bridge draining completed: active_requests={}, cancelled_streams={}", + self.active_requests.load(Ordering::Acquire), + cancelled + ); + } + + pub(crate) async fn cancel_instance_streams(&self, instance_id: &str) { + let cancelled = self.streams.cancel_instance(instance_id).await; + if cancelled > 0 { + log::debug!( + "Plugin client response streams cancelled: instance_id={}, stream_count={}", + instance_id, + cancelled + ); + } + } +} + +pub(crate) async fn register_plugin_host_backend_handlers( + client: PluginHostClient, +) -> crate::BitFunResult> { + let bridge = Arc::new(PluginHostBackendBridge::new(client.clone())); + let http_bridge = bridge.clone(); + client + .register_handler("backend.http.request", move |params| { + let bridge = http_bridge.clone(); + async move { bridge.handle_http(params).await } + }) + .await + .map_err(plugin_host_handler_error)?; + let read_bridge = bridge.clone(); + client + .register_handler("backend.stream.read", move |params| { + let bridge = read_bridge.clone(); + async move { + let params: StreamReadParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.stream.read", error))?; + serde_json::to_value( + bridge + .streams + .read(params) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await + .map_err(plugin_host_handler_error)?; + let cancel_bridge = bridge.clone(); + client + .register_handler("backend.stream.cancel", move |params| { + let bridge = cancel_bridge.clone(); + async move { + let params: StreamCancelParams = serde_json::from_value(params) + .map_err(|error| invalid_rpc_params("backend.stream.cancel", error))?; + serde_json::to_value( + bridge + .streams + .cancel(params) + .await + .map_err(stream_rpc_error)?, + ) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string())) + } + }) + .await + .map_err(plugin_host_handler_error)?; + PLUGIN_HOST_BACKEND_BRIDGE + .set(bridge.clone()) + .map_err(|_| { + crate::BitFunError::ProcessError( + "Plugin host backend bridge is already initialized".to_string(), + ) + })?; + Ok(bridge) +} + +pub(crate) fn plugin_host_backend_bridge() -> Option> { + PLUGIN_HOST_BACKEND_BRIDGE.get().cloned() +} + +fn invalid_rpc_params(method: &str, error: serde_json::Error) -> RpcHandlerError { + RpcHandlerError::new(-32602, format!("Invalid parameters for {method}: {error}")) +} + +fn plugin_host_handler_error( + error: bitfun_opencode_plugin_host::PluginHostError, +) -> crate::BitFunError { + crate::BitFunError::ProcessError(format!( + "Failed to register plugin host backend handler: {error}" + )) +} + +fn stream_rpc_error(error: StreamRegistryError) -> RpcHandlerError { + match error { + StreamRegistryError::InstanceMismatch => RpcHandlerError::new(-32003, error.to_string()), + StreamRegistryError::InvalidMaxBytes => RpcHandlerError::new(-32602, error.to_string()), + StreamRegistryError::Capacity | StreamRegistryError::BodyTooLarge => { + RpcHandlerError::new(-32000, error.to_string()) + } + } +} + +fn parse_body(body: &[u8]) -> Result { + if body.is_empty() { + serde_json::from_value(json!({})) + .map_err(|error| RouteFailure::bad_request(error.to_string())) + } else { + serde_json::from_slice(body).map_err(|error| RouteFailure::bad_request(error.to_string())) + } +} + +async fn dispatch_route( + context: &crate::plugin_host::PluginHostInstance, + route: OpenCodeClientRoute, + query: &std::collections::HashMap>, + body: &[u8], +) -> Result { + crate::plugin_host_http_routes::dispatch_route(context, route, query, body).await +} + +pub(crate) fn body_as(body: &[u8]) -> Result { + parse_body(body) +} + +pub(crate) type RouteResult = Result; +pub(crate) use RouteFailure as Failure; diff --git a/src/crates/assembly/core/src/plugin_host_http_routes.rs b/src/crates/assembly/core/src/plugin_host_http_routes.rs new file mode 100644 index 000000000..08c216dfc --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http_routes.rs @@ -0,0 +1,841 @@ +use crate::plugin_host::PluginHostInstance; +use crate::plugin_host_http::{body_as, Failure, RouteResult}; +use bitfun_opencode_plugin_host::OpenCodeClientRoute; +use serde_json::{json, Value}; +use std::collections::HashMap; + +pub(crate) async fn dispatch_route( + context: &PluginHostInstance, + route: OpenCodeClientRoute, + query: &HashMap>, + body: &[u8], +) -> RouteResult { + match route { + OpenCodeClientRoute::ProjectList => project_list(context).await, + OpenCodeClientRoute::ProjectCurrent => Ok(project_value(context)), + OpenCodeClientRoute::PathGet => path_get(context), + OpenCodeClientRoute::VcsGet => vcs_get(context).await, + OpenCodeClientRoute::ConfigGet => config_get().await, + OpenCodeClientRoute::ConfigProviders => config_providers().await, + OpenCodeClientRoute::ProviderList => provider_list().await, + OpenCodeClientRoute::ToolIds => tool_ids().await, + OpenCodeClientRoute::ToolList => tool_list(query).await, + OpenCodeClientRoute::AppLog => app_log(context, body), + OpenCodeClientRoute::AgentList => agent_list(context).await, + OpenCodeClientRoute::CommandList => command_list(context).await, + OpenCodeClientRoute::SessionList => session_list(context).await, + OpenCodeClientRoute::SessionCreate => session_create(context, body).await, + OpenCodeClientRoute::SessionStatus => session_status(context).await, + OpenCodeClientRoute::SessionDelete { session_id } => { + session_delete(context, &session_id).await + } + OpenCodeClientRoute::SessionGet { session_id } => session_get(context, &session_id).await, + OpenCodeClientRoute::SessionUpdate { session_id } => { + session_update(context, &session_id, body).await + } + OpenCodeClientRoute::SessionChildren { session_id } => { + session_children(context, &session_id).await + } + OpenCodeClientRoute::SessionTodo { session_id } => session_todo(context, &session_id).await, + OpenCodeClientRoute::SessionFork { session_id } => { + session_fork(context, &session_id, body).await + } + OpenCodeClientRoute::SessionAbort { session_id } => { + session_abort(context, &session_id).await + } + OpenCodeClientRoute::SessionDiff { session_id } => { + session_diff(context, &session_id, query).await + } + OpenCodeClientRoute::SessionMessages { session_id } => { + session_messages(context, &session_id, query).await + } + OpenCodeClientRoute::SessionMessage { + session_id, + message_id, + } => session_message(context, &session_id, &message_id).await, + OpenCodeClientRoute::PtyList => pty_list(context).await, + OpenCodeClientRoute::PtyCreate => pty_create(context, body).await, + OpenCodeClientRoute::PtyDelete { pty_id } => pty_delete(context, &pty_id).await, + OpenCodeClientRoute::PtyGet { pty_id } => pty_get(context, &pty_id).await, + OpenCodeClientRoute::PtyUpdate { pty_id } => pty_update(context, &pty_id, body).await, + OpenCodeClientRoute::FindText => find_text(context, query).await, + OpenCodeClientRoute::FindFiles => find_files(context, query).await, + OpenCodeClientRoute::FileList => file_list(context, query).await, + OpenCodeClientRoute::FileRead => file_read(context, query).await, + OpenCodeClientRoute::FileStatus => file_status(context).await, + OpenCodeClientRoute::McpStatus => mcp_status().await, + OpenCodeClientRoute::LspStatus => lsp_status(context).await, + } +} + +fn query_first<'a>(query: &'a HashMap>, key: &str) -> Option<&'a str> { + query + .get(key) + .and_then(|values| values.first()) + .map(String::as_str) +} + +fn required_query<'a>( + query: &'a HashMap>, + key: &str, +) -> Result<&'a str, Failure> { + query_first(query, key) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| Failure::bad_request(format!("Missing required query parameter: {key}"))) +} + +fn project_value(context: &PluginHostInstance) -> Value { + json!({ + "id": context.project_id, + "worktree": context.worktree.to_string_lossy(), + "vcsDir": context.worktree.to_string_lossy(), + "vcs": "git", + "time": {"created": context.created_at_ms}, + }) +} + +async fn project_list(context: &PluginHostInstance) -> RouteResult { + Ok(json!([project_value(context)])) +} + +fn path_get(context: &PluginHostInstance) -> RouteResult { + let path_manager = crate::infrastructure::try_get_path_manager_arc() + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!({ + "state": path_manager.project_runtime_root(&context.directory).to_string_lossy(), + "config": path_manager.project_internal_config_dir(&context.directory).to_string_lossy(), + "worktree": context.worktree.to_string_lossy(), + "directory": context.directory.to_string_lossy(), + })) +} + +async fn vcs_get(context: &PluginHostInstance) -> RouteResult { + let repository = crate::service::git::GitService::get_repository_basic(&context.worktree) + .await + .map_err(|error| Failure::not_found(format!("Git repository is unavailable: {error}")))?; + Ok(json!({"branch": repository.current_branch})) +} + +async fn config_get() -> RouteResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + let service = get_global_config_service() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let config: GlobalConfig = service + .get_config(None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let plugins = config + .plugin + .iter() + .map(|plugin| plugin.spec().to_string()) + .filter(|spec| !spec.trim().is_empty()) + .collect::>(); + Ok(json!({ + "plugin": plugins, + "logLevel": config.app.logging.level.trim().to_ascii_uppercase(), + })) +} + +fn provider_projection( + models: &[crate::service::config::AIModelConfig], + catalog: &bitfun_core_types::ProviderCatalog, + full_model_dto: bool, +) -> Vec { + let mut grouped = + std::collections::BTreeMap::>::new(); + for model in models.iter().filter(|model| model.enabled) { + grouped + .entry(model.provider.clone()) + .or_default() + .push(model); + } + grouped + .into_iter() + .map(|(provider_id, models)| { + let model_values = models + .iter() + .map(|model| { + let attachment = model.capabilities.contains(&crate::service::config::ModelCapability::ImageUnderstanding); + let tool_call = model.capabilities.contains(&crate::service::config::ModelCapability::FunctionCalling); + let context = model.context_window.unwrap_or(crate::service::config::DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS); + let output = model.max_tokens.unwrap_or_else(|| crate::service::config::automatic_max_output_tokens(context)); + let catalog_model = matching_catalog_model(catalog, model); + let input_modalities = catalog_model + .map(|entry| entry.capabilities.input_modalities.clone()) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| if attachment { vec!["text".to_string(), "image".to_string()] } else { vec!["text".to_string()] }); + let output_modalities = catalog_model + .map(|entry| entry.capabilities.output_modalities.clone()) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| vec!["text".to_string()]); + let release_date = catalog_model.and_then(|entry| entry.release_date.clone()).unwrap_or_default(); + let status = catalog_model + .and_then(|entry| entry.status.as_deref()) + .filter(|status| matches!(*status, "alpha" | "beta" | "deprecated" | "active")) + .unwrap_or("active"); + let model_context = catalog_model + .and_then(|entry| entry.limits.as_ref()) + .and_then(|limits| limits.context) + .unwrap_or(context); + let model_output = catalog_model + .and_then(|entry| entry.limits.as_ref()) + .and_then(|limits| limits.output) + .unwrap_or(output); + let npm = provider_npm(&model.provider); + let api_url = model.request_url.as_deref().unwrap_or(&model.base_url); + let mut value = if full_model_dto { + json!({ + "id": model.id, + "providerID": model.provider, + "api": {"id": model.model_name, "url": api_url, "npm": npm}, + "name": model.name, + "capabilities": { + "temperature": model.temperature.is_some(), + "reasoning": model.reasoning.is_some() || catalog_model.is_some_and(|entry| entry.capabilities.reasoning), + "attachment": attachment, + "toolcall": tool_call, + "input": modality_flags(&input_modalities), + "output": modality_flags(&output_modalities), + }, + "cost": model_cost(catalog_model), + "limit": {"context": model_context, "output": model_output}, + "status": status, + "options": {}, + "headers": {}, + }) + } else { + json!({ + "id": model.id, + "name": model.name, + "release_date": release_date, + "attachment": attachment, + "reasoning": model.reasoning.is_some() || catalog_model.is_some_and(|entry| entry.capabilities.reasoning), + "temperature": model.temperature.is_some(), + "tool_call": tool_call, + "limit": {"context": model_context, "output": model_output}, + "modalities": {"input": input_modalities, "output": output_modalities}, + "status": status, + "options": {}, + "provider": {"npm": npm}, + }) + }; + if !full_model_dto { + if let Some(cost) = optional_model_cost(catalog_model) { + value["cost"] = cost; + } + } + (model.id.clone(), value) + }) + .collect::>(); + let api = models.first().map(|model| model.base_url.clone()); + json!({ + "id": provider_id, + "name": provider_id, + "env": [], + "api": api, + "npm": provider_npm(&provider_id), + "models": model_values, + }) + }) + .collect() +} + +fn provider_npm(provider: &str) -> &'static str { + match provider.trim().to_ascii_lowercase().as_str() { + "anthropic" => "@ai-sdk/anthropic", + "gemini" | "google" | "gemini-code-assist" => "@ai-sdk/google", + "openai-responses" => "@ai-sdk/openai", + _ => "@ai-sdk/openai-compatible", + } +} + +fn modality_flags(modalities: &[String]) -> Value { + let supports = |value: &str| { + modalities + .iter() + .any(|entry| entry.eq_ignore_ascii_case(value)) + }; + json!({ + "text": supports("text"), + "audio": supports("audio"), + "image": supports("image"), + "video": supports("video"), + "pdf": supports("pdf"), + }) +} + +fn matching_catalog_model<'a>( + catalog: &'a bitfun_core_types::ProviderCatalog, + model: &crate::service::config::AIModelConfig, +) -> Option<&'a bitfun_core_types::ProviderCatalogModel> { + let mut matches = catalog + .providers + .iter() + .flat_map(|provider| provider.models.iter()) + .filter(|entry| { + entry.id.eq_ignore_ascii_case(&model.model_name) + || entry.id.eq_ignore_ascii_case(&model.id) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) +} + +fn price(value: Option<&str>) -> Option { + value + .and_then(|value| value.parse::().ok()) + .filter(|value| value.is_finite() && *value >= 0.0) +} + +fn optional_model_cost(model: Option<&bitfun_core_types::ProviderCatalogModel>) -> Option { + let pricing = model?.pricing.as_ref()?; + let input = price(pricing.input.as_deref())?; + let output = price(pricing.output.as_deref())?; + let mut cost = json!({"input": input, "output": output}); + if let Some(cache_read) = price(pricing.cache_read.as_deref()) { + cost["cache_read"] = json!(cache_read); + } + if let Some(cache_write) = price(pricing.cache_write.as_deref()) { + cost["cache_write"] = json!(cache_write); + } + Some(cost) +} + +fn model_cost(model: Option<&bitfun_core_types::ProviderCatalogModel>) -> Value { + let pricing = model.and_then(|entry| entry.pricing.as_ref()); + json!({ + "input": price(pricing.and_then(|entry| entry.input.as_deref())).unwrap_or(0.0), + "output": price(pricing.and_then(|entry| entry.output.as_deref())).unwrap_or(0.0), + "cache": { + "read": price(pricing.and_then(|entry| entry.cache_read.as_deref())).unwrap_or(0.0), + "write": price(pricing.and_then(|entry| entry.cache_write.as_deref())).unwrap_or(0.0), + }, + }) +} + +async fn load_models() -> Result< + ( + Vec, + HashMap, + bitfun_core_types::ProviderCatalog, + ), + Failure, +> { + use crate::service::config::{get_global_config_service, GlobalConfig}; + let service = get_global_config_service() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let config: GlobalConfig = service + .get_config(None) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let primary = config.ai.default_models.primary; + let mut defaults = HashMap::new(); + for model in config.ai.models.iter().filter(|model| model.enabled) { + defaults + .entry(model.provider.clone()) + .or_insert_with(|| model.id.clone()); + } + if let Some(primary) = primary { + if let Some(model) = config + .ai + .models + .iter() + .find(|model| model.enabled && model.id == primary) + { + defaults.insert(model.provider.clone(), model.id.clone()); + } + } + let catalog = crate::get_ai_model_catalog() + .await + .map_err(Failure::backend)? + .provider_catalog; + Ok((config.ai.models, defaults, catalog)) +} + +async fn config_providers() -> RouteResult { + let (models, defaults, catalog) = load_models().await?; + let providers = provider_projection(&models, &catalog, true) + .into_iter() + .map(|provider| { + let id = provider["id"].as_str().unwrap_or_default(); + let models = provider["models"].clone(); + json!({ + "id": id, + "name": id, + "source": "config", + "env": [], + "options": {}, + "models": models, + }) + }) + .collect::>(); + Ok(json!({"providers": providers, "default": defaults})) +} + +async fn provider_list() -> RouteResult { + let (models, defaults, catalog) = load_models().await?; + let all = provider_projection(&models, &catalog, false); + let connected = all + .iter() + .filter_map(|provider| { + provider + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect::>(); + Ok(json!({"all": all, "default": defaults, "connected": connected})) +} + +async fn enabled_tools() -> Vec> { + let mut tools = Vec::new(); + for tool in crate::agentic::tools::registry::get_all_registered_tools().await { + if tool.is_enabled().await { + tools.push(tool); + } + } + tools +} + +async fn tool_ids() -> RouteResult { + Ok(json!(enabled_tools() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>())) +} + +async fn tool_list(query: &HashMap>) -> RouteResult { + required_query(query, "provider")?; + required_query(query, "model")?; + let mut output = Vec::new(); + for tool in enabled_tools().await { + output.push(json!({ + "id": tool.name(), + "description": tool.description().await.unwrap_or_else(|_| tool.short_description()), + "parameters": tool.input_schema(), + })); + } + Ok(Value::Array(output)) +} + +#[derive(serde::Deserialize)] +struct AppLogBody { + service: String, + level: String, + message: String, +} + +fn app_log(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: AppLogBody = body_as(body)?; + if input.service.trim().is_empty() + || input.message.trim().is_empty() + || input.message.len() > 16 * 1024 + { + return Err(Failure::bad_request( + "Log service and message must be non-empty and bounded", + )); + } + let message = input.message.replace(['\r', '\n'], " "); + match input.level.to_ascii_lowercase().as_str() { + "debug" => log::debug!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "info" => log::info!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "warn" => log::warn!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + "error" => log::error!( + "Plugin app log: instance_id={}, service={}, message={}", + context.instance_id, + input.service, + message + ), + _ => return Err(Failure::bad_request("Unsupported log level")), + } + Ok(json!(true)) +} + +async fn agent_list(context: &PluginHostInstance) -> RouteResult { + let registry = crate::agentic::agents::get_agent_registry(); + let mut entries = registry + .get_modes_info_for_workspace(Some(&context.directory), true) + .await + .into_iter() + .map(|agent| (agent, "primary")) + .collect::>(); + entries.extend( + registry + .get_subagents_info(Some(&context.directory)) + .await + .into_iter() + .filter(|agent| agent.effective_enabled) + .map(|agent| (agent, "subagent")), + ); + Ok(Value::Array( + entries + .into_iter() + .map(|(agent, mode)| { + let tools = agent + .default_tools + .into_iter() + .map(|tool| (tool, Value::Bool(true))) + .collect::>(); + json!({ + "name": agent.id, + "description": agent.description, + "mode": mode, + "builtIn": matches!(agent.source, crate::agentic::agents::AgentSource::Builtin), + "permission": {"edit": "ask", "bash": {}, "webfetch": "ask"}, + "tools": tools, + "options": {}, + }) + }) + .collect(), + )) +} + +async fn command_list(context: &PluginHostInstance) -> RouteResult { + let snapshot = + crate::external_sources::external_source_snapshot(Some(&context.directory), false) + .await + .map_err(Failure::backend)?; + Ok(Value::Array( + snapshot + .commands + .into_iter() + .filter_map(|entry| { + if !matches!( + entry.definition.availability, + crate::external_sources::PromptCommandAvailability::Available + ) { + return None; + } + Some(json!({ + "name": entry.definition.name, + "description": entry.definition.description, + "template": entry.definition.template, + "subtask": !entry.definition.execution_target.is_inline(), + })) + }) + .collect(), + )) +} + +// Session, PTY, filesystem, MCP, and LSP route implementations follow below. + +include!("plugin_host_http_routes_impl.rs"); + +#[cfg(test)] +mod tests { + use super::{ + app_log, assistant_parts, file_list, file_read, find_files, find_text, parse_pty_shell, + project_list, project_value, provider_projection, pty_create, pty_value, + resolve_scoped_path, search_line_offsets, session_create, session_update, tool_list, + }; + use crate::plugin_host::PluginHostInstance; + use crate::service::session::{ModelRoundData, ToolCallData, ToolItemData, ToolResultData}; + use bitfun_services_core::filesystem::{FileSearchResult, SearchMatchType}; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + use terminal_core::{SessionResponse, SessionSource, ShellType}; + + fn instance(directory: PathBuf, instance_id: &str, project_id: &str) -> PluginHostInstance { + PluginHostInstance { + canonical_directory: directory.to_string_lossy().into_owned(), + directory: directory.clone(), + worktree: directory, + project_id: project_id.to_string(), + created_at_ms: 1, + instance_id: instance_id.to_string(), + open_result: json!({}), + ready: true, + } + } + + #[tokio::test] + async fn project_list_isolated_to_current_instance() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + let value = project_list(&context).await.expect("project list"); + + assert_eq!(value.as_array().map(Vec::len), Some(1)); + assert_eq!(value[0]["id"], "project-a"); + } + + #[test] + fn project_current_projects_only_instance_bound_workspace_data() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + let value = project_value(&context); + + assert_eq!(value["id"], "project-a"); + assert_eq!( + value["worktree"], + directory.path().to_string_lossy().as_ref() + ); + assert_eq!(value["vcsDir"], directory.path().to_string_lossy().as_ref()); + assert_eq!(value["vcs"], "git"); + assert_eq!(value["time"]["created"], 1); + assert!(value.get("directory").is_none()); + } + + #[test] + fn scoped_path_rejects_traversal_and_sibling_prefixes() { + let directory = tempfile::tempdir().expect("temporary root"); + let workspace = directory.path().join("project"); + let sibling = directory.path().join("project-sibling"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&sibling).expect("sibling"); + let context = instance(workspace, "instance-a", "project-a"); + + assert!(resolve_scoped_path(&context, "../project-sibling").is_err()); + assert!(resolve_scoped_path(&context, &sibling.to_string_lossy()).is_err()); + } + + #[test] + fn provider_projection_omits_credentials() { + let model = crate::service::config::AIModelConfig { + id: "model-a".to_string(), + name: "Model A".to_string(), + provider: "provider-a".to_string(), + model_name: "upstream-model-a".to_string(), + base_url: "https://example.invalid/v1".to_string(), + api_key: "secret-api-key".to_string(), + custom_headers: Some(std::collections::HashMap::from([( + "authorization".to_string(), + "secret-header".to_string(), + )])), + enabled: true, + ..Default::default() + }; + + let value = serde_json::to_string(&provider_projection( + &[model], + &bitfun_core_types::ProviderCatalog::default(), + true, + )) + .expect("provider projection"); + + assert!(!value.contains("secret-api-key")); + assert!(!value.contains("secret-header")); + assert!(!value.to_ascii_lowercase().contains("authorization")); + } + + #[test] + fn app_log_validates_body_and_supported_levels() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + + for level in ["debug", "info", "warn", "error"] { + let body = serde_json::to_vec(&json!({ + "service": "route-test", + "level": level, + "message": "line one\nline two", + })) + .expect("log body"); + assert_eq!(app_log(&context, &body).expect("accepted log"), json!(true)); + } + + for body in [ + json!({"service": "", "level": "info", "message": "message"}), + json!({"service": "route-test", "level": "trace", "message": "message"}), + json!({"service": "route-test", "level": "info", "message": ""}), + ] { + let body = serde_json::to_vec(&body).expect("invalid log body"); + assert!(app_log(&context, &body).is_err()); + } + assert!(app_log(&context, b"not-json").is_err()); + } + + #[tokio::test] + async fn handlers_reject_missing_required_inputs_before_service_access() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + let query = HashMap::new(); + + assert!(tool_list(&query).await.is_err()); + assert!(find_text(&context, &query).await.is_err()); + assert!(find_files(&context, &query).await.is_err()); + assert!(file_list(&context, &query).await.is_err()); + assert!(file_read(&context, &query).await.is_err()); + assert!( + session_create(&context, br#"{"parentID":"session-parent"}"#) + .await + .is_err() + ); + assert!(session_update(&context, "session-a", b"{}").await.is_err()); + assert!(pty_create(&context, br#"{"args":["--version"]}"#) + .await + .is_err()); + assert!(parse_pty_shell("unsupported-plugin-shell").is_err()); + } + + #[tokio::test] + async fn file_read_returns_workspace_text_content() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let file = directory.path().join("fixture.txt"); + tokio::fs::write(&file, "plugin route fixture") + .await + .expect("write fixture"); + let context = instance(directory.path().to_path_buf(), "instance-a", "project-a"); + let query = HashMap::from([("path".to_string(), vec!["fixture.txt".to_string()])]); + + let value = file_read(&context, &query).await.expect("file response"); + + assert_eq!(value["type"], "text"); + assert_eq!(value["content"], "plugin route fixture"); + } + + #[test] + fn assistant_parts_project_completed_tool_state() { + let tool = ToolItemData { + id: "tool-item".to_string(), + tool_name: "demo_tool".to_string(), + tool_call: ToolCallData { + input: json!({"value": 7}), + id: "call-1".to_string(), + }, + tool_result: Some(ToolResultData { + result: json!({"echo": 7}), + success: true, + result_for_assistant: Some("echoed".to_string()), + image_attachments: None, + error: None, + duration_ms: Some(2), + }), + ai_intent: Some("Echo value".to_string()), + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(2), + order_index: Some(0), + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_dialog_turn_id: None, + attempt_id: None, + attempt_index: None, + subagent_model_id: None, + subagent_model_display_name: None, + status: Some("completed".to_string()), + interruption_reason: None, + }; + let turn = crate::service::session::DialogTurnData { + turn_id: "assistant-message".to_string(), + turn_index: 0, + session_id: "session-a".to_string(), + timestamp: 10, + kind: Default::default(), + agent_type: Some("agentic".to_string()), + user_message: crate::service::session::UserMessageData { + id: "user-message".to_string(), + content: "hello".to_string(), + timestamp: 9, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-a".to_string(), + turn_id: "assistant-message".to_string(), + round_index: 0, + round_group_id: None, + timestamp: 10, + text_items: Vec::new(), + tool_items: vec![tool], + thinking_items: Vec::new(), + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + provider_id: Some("provider-a".to_string()), + model_config_id: Some("model-a".to_string()), + effective_model_name: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + attempt_diagnostics: Vec::new(), + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 10, + end_time: Some(12), + duration_ms: Some(2), + token_usage: None, + finish_reason: Some("stop".to_string()), + has_final_response: Some(true), + error: None, + error_detail: None, + status: crate::service::session::TurnStatus::Completed, + }; + + let parts = assistant_parts(&turn, "assistant-message"); + + assert_eq!(parts.len(), 1); + assert_eq!(parts[0]["type"], "tool"); + assert_eq!(parts[0]["state"]["status"], "completed"); + assert_eq!(parts[0]["state"]["input"]["value"], 7); + assert_eq!(parts[0]["state"]["output"], "echoed"); + } + + #[test] + fn pty_running_states_are_projected_as_running() { + for status in ["Starting", "Active", "Orphaned", "Restoring", "Terminating"] { + let value = pty_value(&SessionResponse { + id: "pty-a".to_string(), + name: "PTY A".to_string(), + shell_type: ShellType::Bash, + cwd: "/workspace".to_string(), + pid: Some(42), + status: status.to_string(), + cols: 80, + rows: 24, + source: SessionSource::default(), + }); + assert_eq!(value["status"], "running"); + } + } + + #[tokio::test] + async fn search_offsets_use_file_byte_positions() { + let directory = tempfile::tempdir().expect("temporary workspace"); + let file = directory.path().join("search.txt"); + tokio::fs::write(&file, "abc\nxx needle yy\n") + .await + .expect("search fixture"); + let path = file.to_string_lossy().into_owned(); + let offsets = search_line_offsets(&[FileSearchResult { + path: path.clone(), + name: "search.txt".to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: Some(2), + matched_content: Some("xx needle yy".to_string()), + preview_before: None, + preview_inside: None, + preview_after: None, + }]) + .await + .expect("line offsets"); + + assert_eq!(offsets.get(&(path, 2)), Some(&4)); + } +} diff --git a/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs new file mode 100644 index 000000000..3dd358599 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_host_http_routes_impl.rs @@ -0,0 +1,687 @@ +use crate::service::session::ToolItemIdentityExt; +use bitfun_runtime_ports::{GitPort, WorkspaceDiffFileStatus}; +use bitfun_services_core::filesystem::{FileSearchOptions, FileSearchResult, FileTreeNode}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use terminal_core::{CloseSessionRequest, CreateSessionRequest, ResizeRequest, SessionResponse, ShellType, TerminalApi}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +fn resolve_scoped_path(context: &PluginHostInstance, value: &str) -> Result { + let requested = PathBuf::from(value); + let path = if requested.is_absolute() { requested } else { context.directory.join(requested) }; + let canonical = dunce::canonicalize(&path).map_err(|_| Failure::not_found("Path does not exist"))?; + if !canonical.starts_with(&context.directory) { + return Err(Failure::forbidden("Path is outside the plugin instance workspace")); + } + Ok(canonical) +} + +fn relative_path(context: &PluginHostInstance, path: &Path) -> String { + path.strip_prefix(&context.directory) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn session_value(context: &PluginHostInstance, metadata: &crate::service::session::SessionMetadata) -> Value { + let project_id = context.project_id.clone(); + let mut value = json!({ + "id": metadata.session_id, + "projectID": project_id, + "directory": context.directory.to_string_lossy(), + "title": metadata.session_name, + "version": env!("CARGO_PKG_VERSION"), + "time": {"created": metadata.created_at, "updated": metadata.last_active_at}, + }); + if let Some(parent_id) = metadata.relationship.as_ref().and_then(|relationship| relationship.parent_session_id.as_ref()) { + value["parentID"] = json!(parent_id); + } + value +} + +fn coordinator() -> Result, Failure> { + crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| Failure::unavailable("Session coordinator is not initialized")) +} + +async fn session_metadata(context: &PluginHostInstance, session_id: &str) -> Result { + bitfun_core_types::validate_session_id(session_id) + .map_err(|error| Failure::bad_request(error.to_string()))?; + let coordinator = coordinator()?; + coordinator + .get_session_manager() + .load_session_metadata(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))? + .ok_or_else(|| Failure::not_found("Session was not found in this workspace")) +} + +async fn session_list(context: &PluginHostInstance) -> RouteResult { + let coordinator = coordinator()?; + let summaries = coordinator + .list_sessions(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let persistence = coordinator.get_session_manager().persistence_manager(); + let metadata = persistence + .list_session_metadata(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let values = metadata + .into_iter() + .filter(|item| summaries.iter().any(|summary| summary.session_id == item.session_id)) + .map(|item| session_value(context, &item)) + .collect::>(); + Ok(Value::Array(values)) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SessionCreateBody { + parent_id: Option, + title: Option, +} + +async fn session_create(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: SessionCreateBody = body_as(body)?; + let coordinator = coordinator()?; + if input.parent_id.is_some() { + return Err(Failure::bad_request("parentID session creation is not supported by the BitFun session owner")); + } + let session = coordinator + .create_session_with_workspace( + None, + input.title.unwrap_or_else(|| "OpenCode Plugin Session".to_string()), + "agentic".to_string(), + crate::agentic::core::SessionConfig { + workspace_path: Some(context.directory.to_string_lossy().into_owned()), + project_workspace_path: Some(context.directory.to_string_lossy().into_owned()), + ..Default::default() + }, + context.directory.to_string_lossy().into_owned(), + ) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let metadata = coordinator + .get_session_manager() + .load_session_metadata(&context.directory, &session.session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))? + .ok_or_else(|| Failure::backend("Created session metadata is unavailable"))?; + Ok(session_value(context, &metadata)) +} + +async fn session_status(context: &PluginHostInstance) -> RouteResult { + let coordinator = coordinator()?; + let sessions = coordinator + .list_sessions(&context.directory) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let statuses = sessions.into_iter().map(|summary| { + let status = match summary.state { + crate::agentic::core::SessionState::Processing { .. } => json!({"type": "busy"}), + crate::agentic::core::SessionState::Error { error, .. } => json!({"type": "retry", "attempt": 0, "message": error, "next": 0}), + crate::agentic::core::SessionState::Idle => json!({"type": "idle"}), + }; + (summary.session_id, status) + }).collect::>(); + Ok(Value::Object(statuses)) +} + +async fn session_get(context: &PluginHostInstance, session_id: &str) -> RouteResult { + Ok(session_value(context, &session_metadata(context, session_id).await?)) +} + +async fn session_delete(context: &PluginHostInstance, session_id: &str) -> RouteResult { + let coordinator = coordinator()?; + session_metadata(context, session_id).await?; + let _ = coordinator.cancel_active_turn_for_session(session_id, std::time::Duration::from_secs(2)).await; + coordinator + .delete_session(&context.directory, session_id) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!(true)) +} + +#[derive(Deserialize)] +struct SessionUpdateBody { title: Option } + +async fn session_update(context: &PluginHostInstance, session_id: &str, body: &[u8]) -> RouteResult { + let input: SessionUpdateBody = body_as(body)?; + let title = input.title.ok_or_else(|| Failure::bad_request("Only title updates are supported"))?; + session_metadata(context, session_id).await?; + let title = coordinator()?.update_session_title(session_id, &title).await.map_err(|error| Failure::backend(error.to_string()))?; + let mut metadata = session_metadata(context, session_id).await?; + metadata.session_name = title; + Ok(session_value(context, &metadata)) +} + +async fn session_children(context: &PluginHostInstance, session_id: &str) -> RouteResult { + session_metadata(context, session_id).await?; + let persistence = coordinator()?.get_session_manager().persistence_manager(); + let metadata = persistence.list_session_metadata_including_internal(&context.directory).await.map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array(metadata.into_iter().filter(|item| item.relationship.as_ref().and_then(|r| r.parent_session_id.as_deref()) == Some(session_id)).map(|item| session_value(context, &item)).collect())) +} + +async fn session_todo(context: &PluginHostInstance, session_id: &str) -> RouteResult { + Ok(session_metadata(context, session_id).await?.todos.unwrap_or_else(|| json!([]))) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct SessionForkBody { message_id: Option } + +async fn session_fork(context: &PluginHostInstance, session_id: &str, body: &[u8]) -> RouteResult { + let input: SessionForkBody = body_as(body)?; + let result = crate::product_runtime::fork_session_for_plugin(context.directory.clone(), session_id.to_string(), input.message_id).await.map_err(Failure::backend)?; + let metadata = session_metadata(context, &result.session_id).await?; + Ok(session_value(context, &metadata)) +} + +async fn session_abort(context: &PluginHostInstance, session_id: &str) -> RouteResult { + session_metadata(context, session_id).await?; + coordinator()?.cancel_active_turn_for_session(session_id, std::time::Duration::from_secs(2)).await.map_err(|error| Failure::backend(error.to_string()))?; + Ok(json!(true)) +} + +async fn session_diff(context: &PluginHostInstance, session_id: &str, query: &HashMap>) -> RouteResult { + session_metadata(context, session_id).await?; + let Some(message_id) = query_first(query, "messageID") else { + return Ok(json!([])); + }; + let manager = crate::service::snapshot::open_snapshot_manager_for_view(&context.directory).await.map_err(|error| Failure::backend(error.to_string()))?; + let turns = coordinator()?.load_visible_persisted_session_turns(&context.directory, session_id).await.map_err(|error| Failure::backend(error.to_string()))?; + let turn = turns + .iter() + .find(|turn| turn.user_message.id == message_id) + .ok_or_else(|| Failure::not_found("Message was not found in this session"))?; + let files = manager.get_turn_files(session_id, turn.turn_index).await.map_err(|error| Failure::backend(error.to_string()))?; + let max_turn_exclusive = Some(turn.turn_index + 1); + let mut result = Vec::new(); + for file in files { + let file_path = file.to_string_lossy(); + let diff = manager.get_file_diff_before(session_id, &file_path, None, max_turn_exclusive).await.map_err(|error| Failure::backend(error.to_string()))?; + let before = diff + .get("original_content") + .and_then(Value::as_str) + .ok_or_else(|| Failure::backend("Session diff did not contain string original content"))?; + let after = diff + .get("modified_content") + .and_then(Value::as_str) + .ok_or_else(|| Failure::backend("Session diff did not contain string modified content"))?; + let stats = manager.get_session_file_diff_stats_before(session_id, &file_path, max_turn_exclusive).await.map_err(|error| Failure::backend(error.to_string()))?; + result.push(json!({"file": relative_path(context, &file), "before": before, "after": after, "additions": stats.lines_added, "deletions": stats.lines_removed})); + } + Ok(Value::Array(result)) +} + +struct MessageProjectionContext<'a> { + instance: &'a PluginHostInstance, + session: &'a crate::service::session::SessionMetadata, + models: &'a [crate::service::config::AIModelConfig], + catalog: &'a bitfun_core_types::ProviderCatalog, +} + +fn message_model_identity<'a>( + context: &'a MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> (String, String, Option<&'a crate::service::config::AIModelConfig>) { + let round = turn.model_rounds.last(); + let configured = round + .and_then(|round| round.model_config_id.as_deref()) + .and_then(|id| context.models.iter().find(|model| model.id == id)) + .or_else(|| context.models.iter().find(|model| model.id == context.session.model_name)); + let provider_id = round + .and_then(|round| round.provider_id.clone()) + .or_else(|| configured.map(|model| model.provider.clone())) + .unwrap_or_else(|| "unknown".to_string()); + let model_id = round + .and_then(|round| round.model_config_id.clone().or_else(|| round.effective_model_name.clone())) + .or_else(|| configured.map(|model| model.id.clone())) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + (provider_id, model_id, configured) +} + +fn user_message_projection( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Value { + let (provider_id, model_id, _) = message_model_identity(context, turn); + json!({ + "info": { + "id": turn.user_message.id, + "sessionID": turn.session_id, + "role": "user", + "time": {"created": turn.user_message.timestamp}, + "agent": turn.agent_type.as_deref().unwrap_or(&context.session.agent_type), + "model": {"providerID": provider_id, "modelID": model_id}, + }, + "parts": [{ + "id": format!("{}:text", turn.user_message.id), + "sessionID": turn.session_id, + "messageID": turn.user_message.id, + "type": "text", + "text": crate::agentic::core::strip_prompt_markup(&turn.user_message.content), + "time": {"start": turn.user_message.timestamp}, + }], + }) +} + +fn token_detail(turn: &crate::service::session::DialogTurnData, key: &str) -> u64 { + turn.model_rounds + .iter() + .filter_map(|round| round.token_details.as_ref()) + .filter_map(|details| details.get(key)) + .filter_map(Value::as_u64) + .sum() +} + +fn assistant_cost( + context: &MessageProjectionContext<'_>, + configured: Option<&crate::service::config::AIModelConfig>, + input: u64, + output: u64, + cache_read: u64, + cache_write: u64, +) -> f64 { + let pricing = configured + .and_then(|model| matching_catalog_model(context.catalog, model)) + .and_then(|model| model.pricing.as_ref()); + let input_price = price(pricing.and_then(|value| value.input.as_deref())).unwrap_or(0.0); + let output_price = price(pricing.and_then(|value| value.output.as_deref())).unwrap_or(0.0); + let cache_read_price = price(pricing.and_then(|value| value.cache_read.as_deref())).unwrap_or(0.0); + let cache_write_price = price(pricing.and_then(|value| value.cache_write.as_deref())).unwrap_or(0.0); + ((input as f64 * input_price) + + (output as f64 * output_price) + + (cache_read as f64 * cache_read_price) + + (cache_write as f64 * cache_write_price)) + / 1_000_000.0 +} + +fn tool_part(session_id: &str, message_id: &str, item: &crate::service::session::ToolItemData) -> Value { + let input = item.effective_input().clone(); + let tool = item.effective_name(); + let state = match item.tool_result.as_ref() { + Some(result) if result.success => json!({ + "status": "completed", + "input": input, + "output": result.result_for_assistant.clone().unwrap_or_else(|| result.result.to_string()), + "title": item.ai_intent.as_deref().unwrap_or(tool), + "metadata": {}, + "time": {"start": item.start_time, "end": item.end_time.unwrap_or(item.start_time)}, + }), + Some(result) => json!({ + "status": "error", + "input": input, + "error": result.error.clone().unwrap_or_else(|| result.result.to_string()), + "metadata": {}, + "time": {"start": item.start_time, "end": item.end_time.unwrap_or(item.start_time)}, + }), + None => json!({ + "status": "running", + "input": input, + "title": item.ai_intent.as_deref().unwrap_or(tool), + "metadata": {}, + "time": {"start": item.start_time}, + }), + }; + json!({ + "id": item.id, + "sessionID": session_id, + "messageID": message_id, + "type": "tool", + "callID": item.tool_call.id, + "tool": tool, + "state": state, + }) +} + +fn assistant_parts(turn: &crate::service::session::DialogTurnData, message_id: &str) -> Vec { + let mut parts = Vec::<(usize, usize, Value)>::new(); + let mut sequence = 0usize; + for round in &turn.model_rounds { + for item in &round.thinking_items { + parts.push((item.order_index.unwrap_or(usize::MAX), sequence, json!({ + "id": item.id, + "sessionID": turn.session_id, + "messageID": message_id, + "type": "reasoning", + "text": item.content, + "time": {"start": item.timestamp}, + }))); + sequence += 1; + } + for item in &round.text_items { + parts.push((item.order_index.unwrap_or(usize::MAX), sequence, json!({ + "id": item.id, + "sessionID": turn.session_id, + "messageID": message_id, + "type": "text", + "text": item.content, + "time": {"start": item.timestamp}, + }))); + sequence += 1; + } + for item in &round.tool_items { + parts.push((item.order_index.unwrap_or(usize::MAX), sequence, tool_part(&turn.session_id, message_id, item))); + sequence += 1; + } + } + parts.sort_by_key(|(order, sequence, _)| (*order, *sequence)); + parts.into_iter().map(|(_, _, part)| part).collect() +} + +fn assistant_message_projection( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Option { + if turn.model_rounds.is_empty() { + return None; + } + let (provider_id, model_id, configured) = message_model_identity(context, turn); + let message_id = turn.turn_id.clone(); + let input_tokens = turn.token_usage.as_ref().map_or(0, |usage| usage.input_tokens); + let output_tokens = turn.token_usage.as_ref().and_then(|usage| usage.output_tokens).unwrap_or(0); + let reasoning_tokens = token_detail(turn, "reasoningTokenCount"); + let cache_read = token_detail(turn, "cachedContentTokenCount"); + let cache_write = token_detail(turn, "cacheCreationTokenCount"); + let mut info = json!({ + "id": message_id, + "sessionID": turn.session_id, + "role": "assistant", + "time": {"created": turn.start_time, "completed": turn.end_time}, + "parentID": turn.user_message.id, + "modelID": model_id, + "providerID": provider_id, + "mode": turn.agent_type.as_deref().unwrap_or(&context.session.agent_type), + "path": {"cwd": context.instance.directory, "root": context.instance.worktree}, + "cost": assistant_cost(context, configured, input_tokens, output_tokens, cache_read, cache_write), + "tokens": { + "input": input_tokens, + "output": output_tokens, + "reasoning": reasoning_tokens, + "cache": {"read": cache_read, "write": cache_write}, + }, + "finish": turn.finish_reason, + }); + if let Some(error) = turn.error.as_ref() { + info["error"] = json!({"name": "UnknownError", "data": {"message": error}}); + } + Some(json!({"info": info, "parts": assistant_parts(turn, &message_id)})) +} + +fn project_turn_messages( + context: &MessageProjectionContext<'_>, + turn: &crate::service::session::DialogTurnData, +) -> Vec { + let mut messages = vec![user_message_projection(context, turn)]; + if let Some(assistant) = assistant_message_projection(context, turn) { + messages.push(assistant); + } + messages +} + +async fn session_messages(context: &PluginHostInstance, session_id: &str, query: &HashMap>) -> RouteResult { + let metadata = session_metadata(context, session_id).await?; + let limit = query_first(query, "limit") + .map(|value| value.parse::().map_err(|_| Failure::bad_request("limit must be a positive integer"))) + .transpose()? + .unwrap_or(100) + .clamp(1, 1000); + let turns = coordinator()?.load_visible_persisted_session_turns(&context.directory, session_id).await.map_err(|error| Failure::backend(error.to_string()))?; + let (models, _, catalog) = load_models().await?; + let projection = MessageProjectionContext { instance: context, session: &metadata, models: &models, catalog: &catalog }; + let messages = turns.iter().flat_map(|turn| project_turn_messages(&projection, turn)).collect::>(); + Ok(Value::Array(messages.into_iter().rev().take(limit).collect::>().into_iter().rev().collect())) +} + +async fn session_message(context: &PluginHostInstance, session_id: &str, message_id: &str) -> RouteResult { + let metadata = session_metadata(context, session_id).await?; + let turns = coordinator()?.load_visible_persisted_session_turns(&context.directory, session_id).await.map_err(|error| Failure::backend(error.to_string()))?; + let (models, _, catalog) = load_models().await?; + let projection = MessageProjectionContext { instance: context, session: &metadata, models: &models, catalog: &catalog }; + turns + .iter() + .flat_map(|turn| project_turn_messages(&projection, turn)) + .find(|message| message["info"]["id"].as_str() == Some(message_id)) + .ok_or_else(|| Failure::not_found("Message was not found in this session")) +} + +fn pty_value(session: &SessionResponse) -> Value { + let status = if matches!( + session.status.to_ascii_lowercase().as_str(), + "starting" | "active" | "orphaned" | "restoring" | "terminating" | "running" + ) { + "running" + } else { + "exited" + }; + json!({"id": session.id, "title": session.name, "command": session.shell_type.default_executable(), "args": [], "cwd": session.cwd, "status": status, "pid": session.pid.unwrap_or(0)}) +} + +async fn pty_list(context: &PluginHostInstance) -> RouteResult { + let api = TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let sessions = api.list_sessions().await.map_err(|error| Failure::backend(error.to_string()))?; + let mut values = Vec::new(); + let mut live_ids = std::collections::HashSet::new(); + for session in sessions { + live_ids.insert(session.id.clone()); + if crate::plugin_host::plugin_host_pty_owned_by(&session.id, &context.instance_id).await { + values.push(pty_value(&session)); + } + } + for pty_id in crate::plugin_host::plugin_host_pty_ids_for_instance(&context.instance_id).await { + if !live_ids.contains(&pty_id) { + crate::plugin_host::prune_plugin_host_pty(&pty_id, &context.instance_id).await; + } + } + Ok(Value::Array(values)) +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct PtyCreateBody { command: Option, args: Option>, cwd: Option, title: Option, env: Option> } + +async fn pty_create(context: &PluginHostInstance, body: &[u8]) -> RouteResult { + let input: PtyCreateBody = body_as(body)?; + if input.args.as_ref().is_some_and(|args| !args.is_empty()) { return Err(Failure::bad_request("BitFun terminal sessions do not support arbitrary PTY arguments")); } + let cwd = input.cwd.as_deref().map(|value| resolve_scoped_path(context, value)).transpose()?.unwrap_or_else(|| context.directory.clone()); + let shell_type = input.command.as_deref().map(parse_pty_shell).transpose()?; + let api = TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let session = api.create_session(CreateSessionRequest { session_id: None, name: input.title, shell_type, shell_id: None, working_directory: Some(cwd.to_string_lossy().into_owned()), env: input.env, cols: None, rows: None, remote_connection_id: None, source: None }).await.map_err(|error| Failure::backend(error.to_string()))?; + crate::plugin_host::register_plugin_host_pty(&session.id, &context.instance_id).await; + Ok(pty_value(&session)) +} + +fn parse_pty_shell(command: &str) -> Result { + let shell = ShellType::from_executable(command.trim()); + if matches!(shell, ShellType::Custom(_)) { + return Err(Failure::bad_request( + "PTY command must select a supported BitFun shell", + )); + } + Ok(shell) +} + +async fn pty_get(context: &PluginHostInstance, pty_id: &str) -> RouteResult { + if !crate::plugin_host::plugin_host_pty_owned_by(pty_id, &context.instance_id).await { + return Err(Failure::not_found("PTY was not found in this plugin instance")); + } + let api = TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + let session = match api.get_session(pty_id).await { + Ok(session) => session, + Err(error) => { + crate::plugin_host::prune_plugin_host_pty(pty_id, &context.instance_id).await; + return Err(Failure::not_found(error.to_string())); + } + }; + Ok(pty_value(&session)) +} + +async fn pty_delete(context: &PluginHostInstance, pty_id: &str) -> RouteResult { + if !crate::plugin_host::plugin_host_pty_owned_by(pty_id, &context.instance_id).await { + return Err(Failure::not_found("PTY was not found in this plugin instance")); + } + let api = TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?; + if let Err(error) = api.get_session(pty_id).await { + crate::plugin_host::prune_plugin_host_pty(pty_id, &context.instance_id).await; + return Err(Failure::not_found(error.to_string())); + } + api.close_session(CloseSessionRequest { session_id: pty_id.to_string(), immediate: Some(false) }).await.map_err(|error| Failure::backend(error.to_string()))?; + crate::plugin_host::unregister_plugin_host_pty(pty_id, &context.instance_id).await; + Ok(json!(true)) +} + +#[derive(Deserialize)] +struct PtyUpdateBody { title: Option, size: Option } +#[derive(Deserialize)] +struct PtySize { rows: u16, cols: u16 } + +async fn pty_update(context: &PluginHostInstance, pty_id: &str, body: &[u8]) -> RouteResult { + let input: PtyUpdateBody = body_as(body)?; + pty_get(context, pty_id).await?; + if input.title.is_some() { return Err(Failure::bad_request("PTY title updates are not supported by the BitFun terminal owner")); } + if let Some(size) = input.size { TerminalApi::from_singleton().map_err(|error| Failure::unavailable(error.to_string()))?.resize(ResizeRequest { session_id: pty_id.to_string(), cols: size.cols, rows: size.rows }).await.map_err(|error| Failure::backend(error.to_string()))?; } + pty_get(context, pty_id).await +} + +async fn find_text(context: &PluginHostInstance, query: &HashMap>) -> RouteResult { + let pattern = required_query(query, "pattern")?; + let options = FileSearchOptions { include_content: true, case_sensitive: false, use_regex: false, whole_word: false, max_results: Some(1000), file_extensions: None, include_directories: false }; + let outcome = crate::service::filesystem::FileSystemService::default().search_file_contents(&context.directory.to_string_lossy(), pattern, options, None).await.map_err(|error| Failure::backend(error.to_string()))?; + let matcher = regex::RegexBuilder::new(®ex::escape(pattern)) + .case_insensitive(true) + .build() + .map_err(|error| Failure::bad_request(error.to_string()))?; + let offsets = search_line_offsets(&outcome.results).await?; + let mut results = Vec::new(); + for result in outcome.results { + let Some(line_number) = result.line_number else { continue }; + let Some(line) = result.matched_content else { continue }; + let submatches = matcher + .find_iter(&line) + .map(|matched| json!({ + "match": {"text": matched.as_str()}, + "start": matched.start(), + "end": matched.end(), + })) + .collect::>(); + if submatches.is_empty() { + continue; + } + let path = Path::new(&result.path); + let relative = relative_path(context, path); + let absolute_offset = *offsets + .get(&(result.path.clone(), line_number)) + .ok_or_else(|| Failure::backend("Search result line was outside the indexed file"))?; + results.push(json!({ + "path": {"text": relative}, + "lines": {"text": line}, + "line_number": line_number, + "absolute_offset": absolute_offset, + "submatches": submatches, + })); + } + Ok(Value::Array(results)) +} + +async fn search_line_offsets( + results: &[FileSearchResult], +) -> Result, Failure> { + let mut requested = BTreeMap::>::new(); + for result in results { + if let Some(line_number) = result.line_number { + requested + .entry(result.path.clone()) + .or_default() + .insert(line_number); + } + } + + let mut offsets = HashMap::new(); + for (path, line_numbers) in requested { + let file = tokio::fs::File::open(&path) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + let mut reader = BufReader::new(file); + let last_line = line_numbers.iter().next_back().copied().unwrap_or(0); + let mut line_number = 1usize; + let mut offset = 0u64; + let mut buffer = Vec::new(); + while line_number <= last_line { + buffer.clear(); + let bytes = reader + .read_until(b'\n', &mut buffer) + .await + .map_err(|error| Failure::backend(error.to_string()))?; + if bytes == 0 { + break; + } + if line_numbers.contains(&line_number) { + offsets.insert((path.clone(), line_number), offset); + } + offset = offset.saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX)); + line_number += 1; + } + } + Ok(offsets) +} + +async fn find_files(context: &PluginHostInstance, query: &HashMap>) -> RouteResult { + let pattern = required_query(query, "query")?; + let options = FileSearchOptions { include_content: false, case_sensitive: false, use_regex: false, whole_word: false, max_results: Some(1000), file_extensions: None, include_directories: query_first(query, "dirs") == Some("true") }; + let outcome = crate::service::filesystem::FileSystemService::default().search_file_names(&context.directory.to_string_lossy(), pattern, options, None).await.map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array(outcome.results.into_iter().map(|result| Value::String(relative_path(context, Path::new(&result.path)))).collect())) +} + +fn file_node(context: &PluginHostInstance, node: FileTreeNode) -> Value { + json!({"name": node.name, "path": relative_path(context, Path::new(&node.path)), "absolute": node.path, "type": if node.is_directory {"directory"} else {"file"}, "ignored": false}) +} + +async fn file_list(context: &PluginHostInstance, query: &HashMap>) -> RouteResult { + let value = required_query(query, "path")?; + let path = resolve_scoped_path(context, value)?; + let nodes = crate::service::filesystem::FileSystemService::default().get_directory_contents(&path.to_string_lossy()).await.map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array(nodes.into_iter().map(|node| file_node(context, node)).collect())) +} + +async fn file_read(context: &PluginHostInstance, query: &HashMap>) -> RouteResult { + let path = resolve_scoped_path(context, required_query(query, "path")?)?; + let result = crate::service::filesystem::FileSystemService::default().read_file(&path.to_string_lossy()).await.map_err(|error| Failure::backend(error.to_string()))?; + if result.is_binary { Ok(json!({"type": "binary", "content": result.content, "encoding": "base64"})) } else { Ok(json!({"type": "text", "content": result.content})) } +} + +async fn file_status(context: &PluginHostInstance) -> RouteResult { + let snapshot = bitfun_services_integrations::git::GitWorkspaceDiffPort::new(&context.directory) + .workspace_diff() + .await + .map_err(|error| Failure::backend(error.to_string()))?; + Ok(Value::Array(snapshot.files.into_iter().map(|file| json!({ + "path": file.path, + "added": file.additions, + "removed": file.deletions, + "status": match file.status { + WorkspaceDiffFileStatus::Added => "added", + WorkspaceDiffFileStatus::Deleted => "deleted", + WorkspaceDiffFileStatus::Modified | WorkspaceDiffFileStatus::Renamed | WorkspaceDiffFileStatus::Conflicted => "modified", + }, + })).collect())) +} + +async fn mcp_status() -> RouteResult { + let service = crate::service::mcp::get_global_mcp_service().ok_or_else(|| Failure::unavailable("MCP service is not initialized"))?; + let statuses = service.server_manager().get_all_server_statuses().await.into_iter().map(|(name, status)| { + let value = match status { crate::service::mcp::MCPServerStatus::Connected | crate::service::mcp::MCPServerStatus::Healthy => json!({"status": "connected"}), crate::service::mcp::MCPServerStatus::NeedsAuth => json!({"status": "needs_auth"}), crate::service::mcp::MCPServerStatus::Failed => json!({"status": "failed", "error": "MCP server failed"}), _ => json!({"status": "disabled"}) }; + (name, value) + }).collect::>(); + Ok(Value::Object(statuses)) +} + +async fn lsp_status(context: &PluginHostInstance) -> RouteResult { + let manager = crate::service::lsp::get_workspace_manager(context.directory.clone()).await.map_err(|error| Failure::unavailable(error.to_string()))?; + let states = manager.get_all_server_states().await; + Ok(Value::Array(states.into_iter().map(|(id, state)| json!({"id": id, "name": state.language, "root": context.directory, "status": if matches!(state.status, crate::service::lsp::ServerStatus::Running) {"connected"} else {"error"}})).collect())) +} diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index af3332354..b4f2cd1be 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -502,6 +502,61 @@ impl LocalWorkspaceSnapshotPort for CoreLocalWorkspaceSnapshot { /// harnesses; plugin runtime bindings are deliberately not part of this API. pub struct CoreProductAgentRuntime; +pub(crate) async fn fork_session_for_plugin( + workspace_path: PathBuf, + source_session_id: String, + source_message_id: Option, +) -> Result { + let coordinator = crate::agentic::coordination::get_global_coordinator() + .ok_or_else(|| "Session coordinator is not initialized".to_string())?; + let scheduler = crate::agentic::coordination::get_global_scheduler() + .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; + let path_manager = + crate::infrastructure::try_get_path_manager_arc().map_err(|error| error.to_string())?; + let token_usage_service = Arc::new( + TokenUsageService::new(path_manager) + .await + .map_err(|error| error.to_string())?, + ); + let operations = + CoreSessionOperationsPort::new(coordinator.clone(), scheduler, token_usage_service); + match source_message_id { + Some(message_id) => { + let source_turn_id = coordinator + .get_messages(&source_session_id) + .await + .map_err(|error| error.to_string())? + .into_iter() + .find(|message| message.id == message_id) + .and_then(|message| message.metadata.turn_id) + .ok_or_else(|| format!("Source message was not found: {message_id}"))?; + AgentSessionForkPort::fork_session_at_turn( + &operations, + AgentSessionForkAtTurnRequest { + workspace_path: workspace_path.to_string_lossy().into_owned(), + source_session_id, + source_turn_id, + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await + .map_err(|error| error.to_string()) + } + None => AgentSessionForkPort::fork_session( + &operations, + AgentSessionForkRequest { + workspace_path: workspace_path.to_string_lossy().into_owned(), + source_session_id, + remote_connection_id: None, + remote_ssh_host: None, + }, + ) + .await + .map_err(|error| error.to_string()), + } +} + impl CoreProductAgentRuntime { /// Build a narrow session and interaction facade for an existing product /// owner. This does not assemble runtime services, harnesses, events, or a diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index f847d4060..0ba986502 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -72,6 +72,9 @@ pub struct GlobalConfig { /// ACP client configuration (stored as `{ "acpClients": { ... } }`). #[serde(skip_serializing_if = "Option::is_none")] pub acp_clients: Option, + /// OpenCode-compatible plugin declarations loaded by the process-wide plugin host. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub plugin: Vec, /// Web UI appearance selection. The full package contract is owned by the frontend. pub appearance: AppearanceConfig, /// Web UI font size preferences (`get_config` / `set_config` path `font`). @@ -86,6 +89,44 @@ pub struct GlobalConfig { pub last_modified: chrono::DateTime, } +impl GlobalConfig { + pub fn has_configured_plugins(&self) -> bool { + self.plugin + .iter() + .any(PluginDeclarationConfig::has_non_empty_spec) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginDeclarationConfig { + Spec(String), + Detailed(PluginDeclarationDetails), +} + +impl PluginDeclarationConfig { + pub fn spec(&self) -> &str { + match self { + Self::Spec(spec) => spec, + Self::Detailed(details) => &details.spec, + } + } + + fn has_non_empty_spec(&self) -> bool { + !self.spec().trim().is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginDeclarationDetails { + pub spec: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_directory: Option, +} + /// Project-scoped configuration overlay. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] @@ -1679,6 +1720,7 @@ impl Default for GlobalConfig { tool_permissions: ToolPermissionConfig::default(), mcp_servers: None, acp_clients: None, + plugin: Vec::new(), appearance: AppearanceConfig::default(), font: None, schema_version: CURRENT_CONFIG_SCHEMA_VERSION, @@ -2170,6 +2212,43 @@ mod tests { ); } + #[test] + fn plugin_config_defaults_to_empty_when_missing() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({})) + .expect("empty global config should default"); + + assert!(config.plugin.is_empty()); + assert!(!config.has_configured_plugins()); + } + + #[test] + fn non_empty_plugin_config_requests_runtime_startup() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "plugin": [ + "file:///C:/plugins/demo.mjs", + { + "spec": "@my-org/custom-plugin", + "options": { "mode": "strict" }, + "baseDirectory": "C:/workspace" + } + ] + })) + .expect("plugin config should deserialize"); + + assert_eq!(config.plugin.len(), 2); + assert!(config.has_configured_plugins()); + } + + #[test] + fn empty_plugin_specs_do_not_request_runtime_startup() { + let config: GlobalConfig = serde_json::from_value(serde_json::json!({ + "plugin": ["", " ", { "spec": "" }] + })) + .expect("empty plugin declarations should deserialize"); + + assert!(!config.has_configured_plugins()); + } + #[test] fn permission_request_notifications_default_to_enabled() { assert!(NotificationConfig::default().permission_request_notify); diff --git a/src/crates/contracts/product-domains/src/external_integration_policy.rs b/src/crates/contracts/product-domains/src/external_integration_policy.rs index 11f139c90..b55ca7749 100644 --- a/src/crates/contracts/product-domains/src/external_integration_policy.rs +++ b/src/crates/contracts/product-domains/src/external_integration_policy.rs @@ -55,7 +55,6 @@ impl ExternalIntegrationMode { } } - impl Serialize for ExternalIntegrationMode { fn serialize(&self, serializer: S) -> Result where @@ -136,7 +135,6 @@ impl ExternalIntegrationAccess { } } - impl Serialize for ExternalIntegrationAccess { fn serialize(&self, serializer: S) -> Result where @@ -188,7 +186,6 @@ pub struct ExternalIntegrationPolicySettings { pub extensions: BTreeMap, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default, rename_all = "camelCase")] pub struct ExternalEcosystemPolicyOverride { @@ -377,7 +374,6 @@ impl ExternalIntegrationPolicyStatus { } } - impl Serialize for ExternalIntegrationPolicyStatus { fn serialize(&self, serializer: S) -> Result where diff --git a/src/crates/contracts/product-domains/src/miniapp/market.rs b/src/crates/contracts/product-domains/src/miniapp/market.rs index 35fda4630..d21261b9b 100644 --- a/src/crates/contracts/product-domains/src/miniapp/market.rs +++ b/src/crates/contracts/product-domains/src/miniapp/market.rs @@ -57,7 +57,6 @@ pub enum MarketSort { Rating, } - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MarketUserSummary { diff --git a/src/crates/execution/tool-execution/src/search/glob_search.rs b/src/crates/execution/tool-execution/src/search/glob_search.rs index 506e8eba1..d94bd3e89 100644 --- a/src/crates/execution/tool-execution/src/search/glob_search.rs +++ b/src/crates/execution/tool-execution/src/search/glob_search.rs @@ -179,7 +179,6 @@ fn create_command(program: &str) -> Command { #[cfg(not(windows))] fn create_command(program: &str) -> Command { - Command::new(program) } diff --git a/src/crates/execution/tool-execution/src/web_readable.rs b/src/crates/execution/tool-execution/src/web_readable.rs index 4e8c4ff37..0a5a0753b 100644 --- a/src/crates/execution/tool-execution/src/web_readable.rs +++ b/src/crates/execution/tool-execution/src/web_readable.rs @@ -75,7 +75,10 @@ pub fn extract_markdown_with_text_fallback( // article, documentation, wiki, and forum pages showed `legible` gives the // best current quality/latency balance, with readability-js as fallback. #[cfg(not(target_env = "ohos"))] - let extractors: &[ExtractorFn] = &[attempt_legible as ExtractorFn, attempt_readability_js as ExtractorFn]; + let extractors: &[ExtractorFn] = &[ + attempt_legible as ExtractorFn, + attempt_readability_js as ExtractorFn, + ]; #[cfg(target_env = "ohos")] let extractors: &[ExtractorFn] = &[attempt_legible as ExtractorFn]; diff --git a/src/crates/services/services-integrations/src/hook_import.rs b/src/crates/services/services-integrations/src/hook_import.rs index 3ab7b7b50..dd098f6cb 100644 --- a/src/crates/services/services-integrations/src/hook_import.rs +++ b/src/crates/services/services-integrations/src/hook_import.rs @@ -558,14 +558,14 @@ async fn publish_bundle( && validate_bundle_content(root, final_path, content_digest) .await .is_ok() - { - return Ok(BundlePublication { - root: root.to_path_buf(), - final_path: final_path.to_path_buf(), - retired_path: None, - changed: false, - }); - } + { + return Ok(BundlePublication { + root: root.to_path_buf(), + final_path: final_path.to_path_buf(), + retired_path: None, + changed: false, + }); + } let staging = root .join(".staging") .join(format!("import-{}", uuid::Uuid::new_v4())); diff --git a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs index b21779e36..7bb2cbfcf 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs @@ -17,6 +17,9 @@ pub fn create_mcp_client_info( .enable_sampling() .enable_elicitation() .build(); - ClientInfo::new(capabilities, Implementation::new(client_name, client_version)) - .with_protocol_version(ProtocolVersion::LATEST) + ClientInfo::new( + capabilities, + Implementation::new(client_name, client_version), + ) + .with_protocol_version(ProtocolVersion::LATEST) } diff --git a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs index 13f518ada..5231c015b 100644 --- a/src/crates/services/services-integrations/src/remote_connect/page_upload.rs +++ b/src/crates/services/services-integrations/src/remote_connect/page_upload.rs @@ -529,8 +529,7 @@ pub async fn list_pages_from_relay(relay_url: &str, token: &str) -> Result io::Result, ) -> io::Result { - let mut store = self.inner.lock().map_err(|_| { - io::Error::other( - "terminal transcript recorder lock is poisoned", - ) - })?; + let mut store = self + .inner + .lock() + .map_err(|_| io::Error::other("terminal transcript recorder lock is poisoned"))?; operation(&mut store) } } @@ -684,9 +683,7 @@ impl TranscriptStore { }); let index = TranscriptIndex { sessions }; let serialized = serde_json::to_vec_pretty(&index).map_err(|error| { - io::Error::other( - format!("serialize terminal transcript index: {error}"), - ) + io::Error::other(format!("serialize terminal transcript index: {error}")) })?; let temporary_path = self.root.join(INDEX_TEMP_FILE_NAME);