mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
fix(opencode): retain host version selection across relay restarts
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user