From 1679ae3bb610151a782bf6eec0dbb58bb085aa14 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 18:33:05 -0700 Subject: [PATCH] fix(opencode): retain host version selection across relay restarts --- .../opencode/hook-plugin-dispose-host.test.ts | 2 + .../hook-plugin-opencode2-setup.test.ts | 2 + src/main/opencode/hook-service.test.ts | 3 + .../opencode/opencode-cli-version.test.ts | 66 ++++++++ src/main/opencode/opencode-cli-version.ts | 18 ++- src/relay/opencode-plugin-selection.ts | 39 +++++ src/relay/pty-handler-opencode-revive.test.ts | 142 ++++++++++++++++++ src/relay/pty-handler.ts | 23 ++- 8 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 src/relay/opencode-plugin-selection.ts create mode 100644 src/relay/pty-handler-opencode-revive.test.ts 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-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 ff124eb0b95..2c904c948a2 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 index b0886517da9..23f6b9eea3f 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -63,3 +63,69 @@ it('shares a bounded execution-host callback without probing the native machine' 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 index 00d5cefe6a3..06804bad2ea 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -1,5 +1,9 @@ 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, @@ -17,15 +21,27 @@ export type OpenCodeCliVersionProbe = { 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).sort(([left], [right]) => left.localeCompare(right)) + 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) 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 e68c12e2a8e..ec670ff4ba6 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -5,6 +5,10 @@ import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-sign 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' @@ -458,6 +462,7 @@ type PtyProcessSummary = { } type SerializedPtyEntry = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string pid: number cols: number @@ -2015,12 +2020,12 @@ export class PtyHandler { resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) }) - if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { - spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi - if (isRelayWslShell(shell)) { - addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) - } - } + applyOpenCodePluginSelection( + spawnEnv, + envToDelete, + openCodeCapabilities, + isRelayWslShell(shell) + ) await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -3090,6 +3095,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 @@ -3184,6 +3192,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 && @@ -3259,6 +3269,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