From 5a2aa87f1c541653cb20814410d870d8c082cb5b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:13:06 -0700 Subject: [PATCH] Select OpenCode plugin exports from the execution host version (#24662) * fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> * feat(opencode): probe execution-host CLI capabilities * fix(opencode): select plugin default for execution host loader * fix(opencode): limit prompt prefill capability to verified release * feat(opencode): probe launch capabilities on the execution host * fix(opencode): select plugin loader for the launched host binary * fix(opencode): match WSL probe cwd and declared guest environment * fix(opencode): preserve launch environment deletion boundaries * STRICT launch CI contract correction * CAPS launch CI contract correction * test: initialize Claude prompt state in output retention fixture --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/ipc/pty/ipc/spawn-options.ts | 13 ++ src/main/ipc/pty/runtime/spawn-options.ts | 30 ++-- .../opencode/hook-plugin-dispose-host.test.ts | 2 + .../hook-plugin-legacy-tui-ownership.test.ts | 51 +++++- .../hook-plugin-module-contract.test.ts | 9 + .../hook-plugin-opencode2-setup.test.ts | 2 + src/main/opencode/hook-service.test.ts | 3 + .../opencode/opencode-cli-version.test.ts | 131 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 87 ++++++++++ .../opencode-launch-capabilities.test.ts | 161 ++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 99 +++++++++++ .../opencode-legacy-plugin-loader-fixture.ts | 36 ++++ src/main/opencode/opencode-pty-launch.test.ts | 158 +++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 47 +++++ .../opencode-wsl-probe-environment.test.ts | 72 ++++++++ .../opencode/status-plugin-factory-source.ts | 7 +- src/main/providers/provider-dispatch.test.ts | 1 + src/main/providers/pty-spawn-result.ts | 3 + src/relay/opencode-plugin-selection.ts | 39 +++++ src/relay/pty-handler-opencode-revive.test.ts | 142 +++++++++++++++ src/relay/pty-handler.ts | 40 ++++- src/shared/opencode-cli-version.test.ts | 34 ++++ src/shared/opencode-cli-version.ts | 26 +++ 23 files changed, 1173 insertions(+), 20 deletions(-) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts create mode 100644 src/main/opencode/opencode-wsl-probe-environment.test.ts create mode 100644 src/relay/opencode-plugin-selection.ts create mode 100644 src/relay/pty-handler-opencode-revive.test.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef1..cf2d1c53b6a 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -64,6 +65,18 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index d413c0556f0..2dfdf2d0811 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -35,6 +35,7 @@ import { import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -85,20 +86,12 @@ export async function buildRuntimePtySpawnOptions( ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) - ) - if (ctx.skipCodexHomeEnv) { - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( - ctx.spawnOptions.envToDelete, - CODEX_HOME_ENV_KEYS - ) - } else if (ctx.stripInheritedOrcaCodexHome) { + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env), + ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the daemon owns a persistent inherited environment that may // differ from main. ORCA_CODEX_HOME asks it to compare/delete the pair. - ctx.spawnOptions.envToDelete = mergePtyEnvDeletions(ctx.spawnOptions.envToDelete, [ - 'ORCA_CODEX_HOME' - ]) - } + ctx.stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] + ) if (ctx.codexResumeHomeSelected) { ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } @@ -111,6 +104,19 @@ export async function buildRuntimePtySpawnOptions( env: ctx.env, envToDelete: ctx.spawnOptions.envToDelete }) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/hook-plugin-dispose-host.test.ts b/src/main/opencode/hook-plugin-dispose-host.test.ts index 179714d8c83..52e152753a4 100644 --- a/src/main/opencode/hook-plugin-dispose-host.test.ts +++ b/src/main/opencode/hook-plugin-dispose-host.test.ts @@ -34,6 +34,7 @@ type PluginModule = { const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -81,6 +82,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin disposal by host', } process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts index fb1f4010191..e9404da3821 100644 --- a/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts +++ b/src/main/opencode/hook-plugin-legacy-tui-ownership.test.ts @@ -6,7 +6,8 @@ import { pathToFileURL } from 'node:url' import { getOpenCodePluginSource } from './status-plugin-module-source' import { writeOpenCodeTuiPlugin } from '../../shared/opencode-tui-plugin-install' import { fakeLegacyTui, type LegacyTuiEvent } from './opencode-legacy-tui-fixture' -import { readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getLegacyPlugins, readV1Plugin } from './opencode-legacy-plugin-loader-fixture' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' type Post = { paneKey?: string @@ -23,7 +24,8 @@ const ENV_KEYS = [ 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', - 'ORCA_AGENT_HOOK_OPENCODE_TUI' + 'ORCA_AGENT_HOOK_OPENCODE_TUI', + 'ORCA_OPENCODE_PLUGIN_API' ] as const const created = (id: string, parentID?: string): LegacyTuiEvent => ({ type: 'session.created', @@ -48,6 +50,7 @@ describe('OpenCode 1 TUI API pane reporting', () => { savedArgv = process.argv savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])) process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' process.env.ORCA_OPENCODE_AGENT = 'opencode' process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'fixture' @@ -129,6 +132,50 @@ describe('OpenCode 1 TUI API pane reporting', () => { expect(tui.listenerCount()).toBeGreaterThan(0) }) + it('loads the selected v1 factory through the release fallback once and retains the separate TUI', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = getOpenCodeCliCapabilities('1.18.30').pluginApi + process.argv = ['opencode', 'serve'] + process.env.ORCA_PANE_KEY = PANE_A + const path = join(dir, 'selected-v1-server.mjs') + writeFileSync(path, getOpenCodePluginSource()) + const plugin: unknown = await import(pathToFileURL(path).href) + if (typeof plugin !== 'object' || !plugin || !('default' in plugin)) { + throw new Error('No server plugin') + } + expect(readV1Plugin(plugin, path, 'server', 'detect')).toBeUndefined() + const factories = getLegacyPlugins(plugin) + expect(factories).toHaveLength(1) + expect(factories[0]).toBe(plugin.default) + const hooks: unknown = await factories[0]({ + client: { session: { get: async () => ({ data: { id: 'ses_root' } }) } } + }) + if ( + !hooks || + typeof hooks !== 'object' || + !('event' in hooks) || + typeof hooks.event !== 'function' || + !('dispose' in hooks) || + typeof hooks.dispose !== 'function' + ) { + throw new Error('No legacy event hooks') + } + const dispose = hooks.dispose + cleanups.push(async () => { + await dispose() + }) + await hooks.event({ + event: { + type: 'session.status', + properties: { sessionID: 'ses_root', status: { type: 'busy' } } + } + }) + await settle('SessionBusy:ses_root') + expect(posts.at(-1)).toMatchObject({ opencodeSharedServer: 1 }) + process.argv = ['opencode', 'attach', 'http://127.0.0.1:4096'] + const tui = await start() + expect(tui.listenerCount()).toBeGreaterThan(0) + }) + it('reproduces the release loader rejection of a combined server and TUI entry', () => { expect(() => readV1Plugin({ default: { server() {}, tui() {} } }, 'orca-opencode-status-tui/tui.js', 'tui') diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a..f6d07361abe 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/hook-plugin-opencode2-setup.test.ts b/src/main/opencode/hook-plugin-opencode2-setup.test.ts index 1ada5ea1a53..0fa86f3d70d 100644 --- a/src/main/opencode/hook-plugin-opencode2-setup.test.ts +++ b/src/main/opencode/hook-plugin-opencode2-setup.test.ts @@ -49,6 +49,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag const ENV_KEYS = [ 'ORCA_PANE_KEY', 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN' @@ -66,6 +67,7 @@ describe.each(['opencode', 'opencode2'] as const)('%s plugin on OpenCode 2', (ag savedEnv[key] = process.env[key] } process.env.ORCA_OPENCODE_AGENT = agent + process.env.ORCA_OPENCODE_PLUGIN_API = 'v2' delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index e1400f58a57..482df3671f7 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -32,6 +32,7 @@ import { import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' beforeEach(() => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v2') setAppEnvironment({ getPath: getPathMock, getAppPath: () => process.cwd(), @@ -43,6 +44,8 @@ beforeEach(() => { }) }) +afterEach(() => vi.unstubAllEnvs()) + const { isUsableId, toSafeDirName } = _internals describe('OpenCode id safety guard', () => { diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 00000000000..23f6b9eea3f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) + +it('shares probes across pane identities while preserving launch-affecting environment keys', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '2.0.16', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/pane-cache/opencode', + cwd: '/workspace', + env: { + PATH: '/runtime', + OPENCODE_CONFIG_DIR: '/config', + ORCA_PANE_KEY: 'pane-1', + ORCA_TAB_ID: 'tab-1', + ORCA_TERMINAL_HANDLE: 'term-1', + ORCA_WORKTREE_ID: 'workspace-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_AGENT_PANE: 'pane-1', + ORCA_AGENT_LAUNCH: 'launch-1' + }, + hostIdentity: 'pane-cache-host' + } + await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion({ + ...options, + env: { + ...options.env, + ORCA_PANE_KEY: 'pane-2', + ORCA_TAB_ID: 'tab-2', + ORCA_TERMINAL_HANDLE: 'term-2', + ORCA_WORKTREE_ID: 'workspace-2', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-2', + ORCA_AGENT_PANE: 'pane-2', + ORCA_AGENT_LAUNCH: 'launch-2' + } + }) + ]) + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, PATH: '/other-runtime' } }) + await probeOpenCodeCliVersion({ + ...options, + env: { ...options.env, OPENCODE_CONFIG_DIR: '/other-config' } + }) + await probeOpenCodeCliVersion({ ...options, cwd: '/other-workspace' }) + expect(runProcess).toHaveBeenCalledTimes(4) +}) + +it('separates native and host-callback backends even with the same host identity', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.18.32', + stderr: '', + timedOut: false + }) + const options = { executablePath: '/backend/opencode', env: {}, hostIdentity: 'backend-host' } + expect((await probeOpenCodeCliVersion(options)).pluginApi).toBe('v1') + const execute = vi.fn(async () => ({ code: 0, timedOut: false, stdout: '2.0.16' })) + expect((await probeOpenCodeCliVersion({ ...options, execute })).pluginApi).toBe('v2') + expect(execute).toHaveBeenCalledOnce() + expect(runProcess).toHaveBeenCalledOnce() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 00000000000..06804bad2ea --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,87 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 +const PANE_IDENTITY_ENV_KEYS = new Set([ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV +]) + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.execute ? 'host-callback' : 'native-process', + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env) + .filter(([key]) => !PANE_IDENTITY_ENV_KEYS.has(key)) + .sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = options.execute + ? await options.execute() + : await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts new file mode 100644 index 00000000000..12f6a8fec64 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getOpenCodeLaunchExecutable, + probeOpenCodeLaunchCapabilities +} from './opencode-launch-capabilities' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' + +const mocks = vi.hoisted(() => ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + cwd: '/home/user/repo', + env: guestEnv + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + cwd: '/home/user/repo', + program: 'opencode', + args: ['--version'], + env: guestEnv, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: { WSLENV: '' } }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 00000000000..ee056ee9d4b --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,99 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + // An empty carrier prevents the runner from restoring deleted ambient imports. + const guestEnv: Record = { WSLENV: '' } + const wslEnv = options.env.WSLENV?.split(':') + .filter((token) => options.env[token.split('/')[0]] !== undefined) + .join(':') + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + } + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + cwd, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: async () => { + const result = await runWslProcess({ + distro, + loginPath: 'preferred', + cwd, + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return result.environmentResolved ? result : { ...result, code: null } + } + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} diff --git a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts index 15c2f238a99..3a5a341a08d 100644 --- a/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts +++ b/src/main/opencode/opencode-legacy-plugin-loader-fixture.ts @@ -44,3 +44,39 @@ export function readV1Plugin( return value } + +// OpenCode v1.18.30 (5cd8e68): packages/opencode/src/plugin/index.ts. +type ServerPlugin = (...args: unknown[]) => unknown +function isServerPlugin(value: unknown): value is ServerPlugin { + return typeof value === 'function' +} + +function getServerPlugin(value: unknown) { + if (isServerPlugin(value)) { + return value + } + if (!value || typeof value !== 'object' || !('server' in value)) { + return + } + if (!isServerPlugin(value.server)) { + return + } + return value.server +} + +export function getLegacyPlugins(mod: Record) { + const seen = new Set() + const result: ServerPlugin[] = [] + for (const entry of Object.values(mod)) { + if (seen.has(entry)) { + continue + } + seen.add(entry) + const plugin = getServerPlugin(entry) + if (!plugin) { + throw new TypeError('Plugin export is not a function') + } + result.push(plugin) + } + return result +} diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 00000000000..f7813ddc969 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) + +describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + envToDelete: [], + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + envToDelete: [], + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 00000000000..5aa5adab072 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,47 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + envToDelete: string[] + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: probeEnv + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/opencode/opencode-wsl-probe-environment.test.ts b/src/main/opencode/opencode-wsl-probe-environment.test.ts new file mode 100644 index 00000000000..864321c6001 --- /dev/null +++ b/src/main/opencode/opencode-wsl-probe-environment.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { seedWslGuestEnvironmentForTests } from '../wsl/wsl-guest-environment' + +const run = vi.hoisted(() => vi.fn()) +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: run })) +vi.mock('../wsl/wsl-executable-path', () => ({ resolveWslExecutablePath: () => 'wsl.exe' })) +vi.mock('../agent-hooks/server', () => ({ agentHookServer: { endpointFilePath: null } })) + +beforeEach(() => { + run.mockReset().mockResolvedValue({ code: 0, stdout: '2.0.16', timedOut: false }) + seedWslGuestEnvironmentForTests('probe-deletion', { + path: '/usr/bin', + home: '/private/guest', + envBinary: '/usr/bin/env' + }) + vi.stubEnv('DELETED_CREDENTIAL', 'dummy-host-value') + vi.stubEnv('OPENCODE_CONFIG_DIR', 'C:\\dummy\\config') + vi.stubEnv('KEEP', 'C:\\keep') + vi.stubEnv('EMPTY', '') + vi.stubEnv('WSLENV', 'DELETED_CREDENTIAL/u:OPENCODE_CONFIG_DIR/p:KEEP/p:EMPTY/u') +}) +afterEach(() => vi.unstubAllEnvs()) + +describe('OpenCode WSL version-probe deletion boundary', () => { + it.each([ + { deleted: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], kept: ['KEEP/p', 'EMPTY/u'] }, + { deleted: ['WSLENV'], kept: [] } + ])( + 'does not restore deleted keys through the inherited carrier: $deleted', + async ({ deleted, kept }) => { + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete: [...deleted], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: `/private/${deleted.join('-')}` + }) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv).toBeDefined() + const tokens = String(hostEnv.WSLENV).split(':').filter(Boolean) + expect(tokens).not.toContain('DELETED_CREDENTIAL/u') + expect(tokens).not.toContain('OPENCODE_CONFIG_DIR/p') + expect(tokens).toEqual(expect.arrayContaining(kept)) + // The host value may exist; its carrier must not import it into the guest. + expect(hostEnv.DELETED_CREDENTIAL).toBe('dummy-host-value') + } + ) + + it('shares a sanitized concurrent probe without losing kept flags or empty values', async () => { + const options = { + command: 'opencode', + env: {}, + envToDelete: ['DELETED_CREDENTIAL', 'OPENCODE_CONFIG_DIR'], + isFreshLaunch: true, + wsl: { distro: 'probe-deletion' }, + cwd: '/private/concurrent' + } + await Promise.all([ + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }), + prepareOpenCodePtyLaunch({ ...options, envToDelete: [...options.envToDelete] }) + ]) + expect(run).toHaveBeenCalledTimes(1) + const hostEnv = run.mock.calls[0]?.[0].env + expect(hostEnv.WSLENV).toContain('KEEP/p') + expect(hostEnv.WSLENV).toContain('EMPTY/u') + expect(hostEnv.WSLENV).not.toContain('DELETED_CREDENTIAL') + expect(hostEnv.WSLENV).not.toContain('OPENCODE_CONFIG_DIR') + expect(hostEnv.EMPTY).toBe('') + }) +}) diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde81..fd6c39bf510 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fd..12b259f5b9f 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -175,6 +175,7 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b6..4eb5dc45a60 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/opencode-plugin-selection.ts b/src/relay/opencode-plugin-selection.ts new file mode 100644 index 00000000000..38c7934cfd6 --- /dev/null +++ b/src/relay/opencode-plugin-selection.ts @@ -0,0 +1,39 @@ +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../shared/opencode-cli-version' +import { addWslEnvKeys } from '../shared/wsl-env' + +export function restoreOpenCodeCapabilities(value: unknown): OpenCodeCliCapabilities | undefined { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + typeof value.version !== 'string' + ) { + return undefined + } + const capabilities = getOpenCodeCliCapabilities(value.version) + return capabilities.pluginApi === 'unknown' ? undefined : capabilities +} + +export function applyOpenCodePluginSelection( + env: Record, + envToDelete: string[], + capabilities: OpenCodeCliCapabilities | null | undefined, + wsl: boolean +): void { + delete env.ORCA_OPENCODE_PLUGIN_API + if (!capabilities || capabilities.pluginApi === 'unknown') { + return + } + env.ORCA_OPENCODE_PLUGIN_API = capabilities.pluginApi + for (let index = envToDelete.length - 1; index >= 0; index -= 1) { + if (envToDelete[index] === 'ORCA_OPENCODE_PLUGIN_API') { + envToDelete.splice(index, 1) + } + } + if (wsl) { + addWslEnvKeys(env, ['ORCA_OPENCODE_PLUGIN_API']) + } +} diff --git a/src/relay/pty-handler-opencode-revive.test.ts b/src/relay/pty-handler-opencode-revive.test.ts new file mode 100644 index 00000000000..1c73f8be1fe --- /dev/null +++ b/src/relay/pty-handler-opencode-revive.test.ts @@ -0,0 +1,142 @@ +import './mock-descendant-sweep' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { applyOpenCodePluginSelection } from './opencode-plugin-selection' +import type { PtyHandler } from './pty-handler' +import { + beginPtyHandlerTest, + createMockDispatcher, + createTestPtyHandler, + endPtyHandlerTest, + testPtyId, + type MockDispatcher +} from './pty-handler-test-harness' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: vi.fn(), + probe: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn(), + pause: vi.fn(), + resume: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn })) +vi.mock('../main/shell-prompt-readiness-probe', () => ({ + createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe +})) +vi.mock('../main/opencode/opencode-launch-capabilities', () => ({ + probeOpenCodeLaunchCapabilities: mocks.probe +})) + +let dispatcher: MockDispatcher +let handler: PtyHandler +let originalPlatform: PropertyDescriptor | undefined + +beforeEach(() => { + ;({ dispatcher, handler, originalPlatform } = beginPtyHandlerTest(mocks)) + mocks.probe.mockReset() +}) + +afterEach(async () => endPtyHandlerTest(handler, originalPlatform)) + +function spawnedEnvironment(): Record { + const options: unknown = mocks.mockPtySpawn.mock.calls.at(-1)?.[2] + if ( + !options || + typeof options !== 'object' || + !('env' in options) || + !options.env || + typeof options.env !== 'object' + ) { + throw new Error('Expected a PTY spawn environment') + } + return Object.fromEntries(Object.entries(options.env)) +} + +async function serialize(): Promise { + const state = await dispatcher.callRequest('pty.serialize', { ids: [testPtyId(1)] }) + if (typeof state !== 'string') { + throw new Error('Expected serialized PTY state') + } + return state +} + +async function restartHandler(): Promise { + await handler.dispose({ waitForPhysicalExit: false }) + dispatcher = createMockDispatcher() + handler = createTestPtyHandler(dispatcher) + mocks.mockPtySpawn.mockClear() +} + +it.each(['1.18.32', '2.0.16'])( + 'preserves host-selected plugin exports through two relay revives on %s', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + mocks.probe.mockResolvedValue(capabilities) + await dispatcher.callRequest('pty.spawn', { + cwd: tmpdir(), + command: 'opencode --standalone', + launchAgent: 'opencode', + env: { ORCA_PANE_KEY: 'tab-oc:leaf', ORCA_OPENCODE_PLUGIN_API: 'untrusted' }, + envToDelete: ['ORCA_OPENCODE_PLUGIN_API', 'ORCA_OPENCODE_PLUGIN_API'] + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const state = await serialize() + expect(JSON.parse(state)).toMatchObject([ + { openCodeCapabilities: capabilities, envToDelete: [] } + ]) + + await restartHandler() + await dispatcher.callRequest('pty.revive', { state }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + const second = await serialize() + await restartHandler() + await dispatcher.callRequest('pty.revive', { state: second }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBe(capabilities.pluginApi) + expect(mocks.probe).toHaveBeenCalledOnce() + } +) + +it.each([ + undefined, + { version: 'garbage', pluginApi: 'v1' }, + { version: '3.0.0', pluginApi: 'v1' }, + { version: 2, pluginApi: 'v1' } +])('keeps legacy or unverifiable serialized selection unset: %j', async (openCodeCapabilities) => { + handler.addEnvAugmenter(() => ({ ORCA_OPENCODE_PLUGIN_API: 'v1' })) + await dispatcher.callRequest('pty.revive', { + state: JSON.stringify([ + { + id: testPtyId(1), + pid: process.pid, + cols: 80, + rows: 24, + cwd: tmpdir(), + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) + } + ]) + }) + expect(spawnedEnvironment().ORCA_OPENCODE_PLUGIN_API).toBeUndefined() + expect(mocks.probe).not.toHaveBeenCalled() +}) + +it('carries a selected API into WSL without dropping other forwarded keys', () => { + const env = { WSLENV: 'XDG_DATA_HOME/p', ORCA_OPENCODE_PLUGIN_API: 'untrusted' } + const envToDelete = ['ORCA_OPENCODE_PLUGIN_API', 'OTHER_ENV'] + applyOpenCodePluginSelection(env, envToDelete, getOpenCodeCliCapabilities('1.18.32'), true) + expect(env).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'XDG_DATA_HOME/p:ORCA_OPENCODE_PLUGIN_API' + }) + expect(envToDelete).toEqual(['OTHER_ENV']) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 579e920269c..4f86f8a3508 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -5,6 +5,13 @@ import { restoreManagedDataAccountEnvironment } from '../shared/managed-data-acc import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { + applyOpenCodePluginSelection, + restoreOpenCodeCapabilities +} from './opencode-plugin-selection' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -218,6 +225,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -278,6 +286,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -449,6 +458,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -1954,6 +1964,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1982,6 +1995,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -2028,6 +2042,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2129,6 +2159,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2208,7 +2239,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } @@ -3097,6 +3129,9 @@ export class PtyHandler { worktreeId: managed.worktreeId, ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), envToDelete: managed.envToDelete, + ...(managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), gitCredentialPromptGuarded: managed.gitCredentialPromptGuarded, ...(managed.historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), // Why serialized: revive re-spawns the shell, and without these a WSL @@ -3191,6 +3226,8 @@ export class PtyHandler { { id: entry.id, paneKey: entry.paneKey, shell }, envToDelete ) + const openCodeCapabilities = restoreOpenCodeCapabilities(entry.openCodeCapabilities) + applyOpenCodePluginSelection(spawnEnv, envToDelete, openCodeCapabilities, wslShell) if ( historyIsolationEnabled && entry.worktreeId && @@ -3268,6 +3305,7 @@ export class PtyHandler { ...(explicitTerm !== undefined ? { explicitTerm } : {}), envToDelete, gitCredentialPromptGuarded, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(historyIsolationEnabled ? { historyIsolationEnabled: true } : {}), shellPath: shell, // Why re-stored: a revived pane can be serialized again, and losing the diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 00000000000..cc9c5987f2e --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 00000000000..2aa71b2c30f --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' + } +}