fix(opencode): retain host version selection across relay restarts

This commit is contained in:
Neil
2026-10-02 18:33:05 -07:00
parent 18b4892711
commit 1679ae3bb6
8 changed files with 288 additions and 7 deletions
@@ -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'
@@ -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'
+3
View File
@@ -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', () => {
@@ -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()
})
+17 -1
View File
@@ -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<string, { expiresAt: number; result: Promise<OpenCodeCliCapabilities> }>()
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<OpenCodeCliCapabilities> {
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)
+39
View File
@@ -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<string, string>,
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'])
}
}
@@ -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<string, unknown> {
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<string> {
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<void> {
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'])
})
+17 -6
View File
@@ -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