diff --git a/src/main/agent-hooks/opencode-plugin-settings.ts b/src/main/agent-hooks/opencode-plugin-settings.ts new file mode 100644 index 00000000000..7f1f884e331 --- /dev/null +++ b/src/main/agent-hooks/opencode-plugin-settings.ts @@ -0,0 +1,26 @@ +import type { PluginSources } from '../../relay/plugin-overlay' +import { isTuiAgentEnabled } from '../../shared/tui-agent-selection' +import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls' +import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' + +function enabled(settings: ManagedHookDetectionSettings, agent: 'opencode' | 'opencode2'): boolean { + return ( + isAgentStatusHooksEnabled(settings) && isTuiAgentEnabled(agent, settings?.disabledTuiAgents) + ) +} + +export function openCodePluginSettingsKey(settings: ManagedHookDetectionSettings): string { + return `${enabled(settings, 'opencode')}:${enabled(settings, 'opencode2')}` +} + +export function selectOpenCodePluginSources( + sources: PluginSources, + settings: ManagedHookDetectionSettings +): PluginSources { + // Older relays retain omitted sources; an empty string replaces the cache and cannot write a plugin. + return { + ...sources, + opencodePluginSource: enabled(settings, 'opencode') ? sources.opencodePluginSource : '', + opencode2PluginSource: enabled(settings, 'opencode2') ? sources.opencode2PluginSource : '' + } +} diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.ts b/src/main/agent-hooks/wsl-guest-plugin-install.ts index 80b98bed6f4..40bf43ce024 100644 --- a/src/main/agent-hooks/wsl-guest-plugin-install.ts +++ b/src/main/agent-hooks/wsl-guest-plugin-install.ts @@ -4,11 +4,14 @@ // Mirrors the SSH relay's installPluginsOnRelay swallow list. import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { selectOpenCodePluginSources } from './opencode-plugin-settings' +import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import type { PluginSources } from '../../relay/plugin-overlay' /** Structural, not the deps type itself, so this stays free of the deps module. */ type GuestPluginInstallDeps = { pluginSources: () => PluginSources + managedHookSettings?: () => ManagedHookDetectionSettings warn: (message: string) => void } @@ -29,7 +32,7 @@ export async function requestGuestOpenCodeOverlayDir( try { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime validation or the local test fixture establishes the asserted shape. const res = (await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { - ...deps.pluginSources(), + ...selectOpenCodePluginSources(deps.pluginSources(), deps.managedHookSettings?.() ?? null), ...(launchKind ? { launchKind } : {}) })) as { overlayDirs?: { opencode?: unknown; opencode2?: unknown; pi?: unknown; omp?: unknown } diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts index 103a5e9909b..87e29f2d414 100644 --- a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -8,6 +8,7 @@ import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { installWslGuestHooks } from './wsl-hook-fs-adapter' import { REINSTALL_MIN_INTERVAL_MS, type WslHookRelayManagerDeps } from './wsl-hook-relay-deps' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { openCodePluginSettingsKey } from './opencode-plugin-settings' import type { PluginSources } from '../../relay/plugin-overlay' /** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ @@ -30,6 +31,10 @@ type GuestInstallState = { piAgentDir?: string ompStatusExtension?: string lastInstallAt?: number + lastOpenCodeSettings?: string + // Failed attempts throttle retries without claiming the guest accepted those settings. + lastAttemptOpenCodeSettings?: string + lastInstallMux?: SshChannelMultiplexer launchKinds?: Set<'pi' | 'omp'> installation?: Promise } @@ -52,11 +57,17 @@ export async function runWslRelayGuestInstall( if (state.installation) { return state.installation } - state.installation = installGuestHooksAndPlugins(deps, state, mux, guestHome) + // Publish ownership before an installer can re-enter the ensure path. + const installation = Promise.resolve().then(() => + installGuestHooksAndPlugins(deps, state, mux, guestHome) + ) + state.installation = installation try { - await state.installation + await installation } finally { - state.installation = undefined + if (state.installation === installation) { + state.installation = undefined + } } } @@ -66,7 +77,15 @@ async function installGuestHooksAndPlugins( mux: SshChannelMultiplexer, guestHome: string ): Promise { + if (state.mux !== mux || mux.isDisposed()) { + return + } + if (state.lastInstallMux !== mux) { + state.lastOpenCodeSettings = undefined + } + state.lastInstallMux = mux state.lastInstallAt = Date.now() + state.lastAttemptOpenCodeSettings = openCodePluginSettingsKey(deps.managedHookSettings()) await installWslGuestHooks({ mux, guestHome, @@ -81,11 +100,27 @@ async function installGuestHooksAndPlugins( // PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown. const kinds = requestedKinds(state) for (const kind of kinds) { - const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro, kind) - if (state.mux !== mux) { + if (state.mux !== mux || mux.isDisposed()) { + return + } + const currentSettings = deps.managedHookSettings() + const settings = currentSettings && { + ...currentSettings, + disabledTuiAgents: currentSettings.disabledTuiAgents?.slice() + } + const settingsKey = openCodePluginSettingsKey(settings) + state.lastAttemptOpenCodeSettings = settingsKey + const overlay = await requestGuestOpenCodeOverlayDir( + mux, + { ...deps, managedHookSettings: () => settings }, + state.distro, + kind + ) + if (state.mux !== mux || mux.isDisposed()) { return } if (overlay.kind !== 'unavailable') { + state.lastOpenCodeSettings = settingsKey state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined state.opencode2OverlayDir = overlay.kind === 'dir' ? overlay.dir2 : undefined if (kind === 'pi') { @@ -102,25 +137,34 @@ export async function maybeRerunWslRelayGuestInstall( deps: GuestInstallDeps, state: GuestInstallState ): Promise { - if (state.installation) { - await state.installation - return - } - const mux = state.mux - const guestHome = state.guestHome - if ( - !mux || - !guestHome || - mux.isDisposed() || - Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS - ) { - return - } - try { - // Why: the pass also re-ships the plugin source, so a mid-session Orca upgrade refreshes it. - await runWslRelayGuestInstall(deps, state, mux, guestHome) - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) - deps.warn(`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${detail}`) + let waited = false + // Every settled pass can expose newer settings or another waiter's task. + for (;;) { + try { + if (state.installation) { + waited = true + await state.installation + continue + } + const mux = state.mux + const guestHome = state.guestHome + const settingsKey = openCodePluginSettingsKey(deps.managedHookSettings()) + if ( + !mux || + !guestHome || + mux.isDisposed() || + (state.lastInstallMux === mux && + (waited || Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS) && + (state.lastOpenCodeSettings === settingsKey || + state.lastAttemptOpenCodeSettings === settingsKey)) + ) { + return + } + waited = true + await runWslRelayGuestInstall(deps, state, mux, guestHome) + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + deps.warn(`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${detail}`) + } } } diff --git a/src/main/agent-hooks/wsl-hook-relay-state.ts b/src/main/agent-hooks/wsl-hook-relay-state.ts index 509231b97d9..2648280addd 100644 --- a/src/main/agent-hooks/wsl-hook-relay-state.ts +++ b/src/main/agent-hooks/wsl-hook-relay-state.ts @@ -21,4 +21,7 @@ export type WslRelayDistroState = { restartTimer?: ReturnType reinstallTimer?: ReturnType lastInstallAt?: number + lastOpenCodeSettings?: string + lastAttemptOpenCodeSettings?: string + lastInstallMux?: SshChannelMultiplexer } diff --git a/src/main/agent-hooks/wsl-opencode-install-race.test.ts b/src/main/agent-hooks/wsl-opencode-install-race.test.ts new file mode 100644 index 00000000000..ccb8e79a445 --- /dev/null +++ b/src/main/agent-hooks/wsl-opencode-install-race.test.ts @@ -0,0 +1,326 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TuiAgent } from '../../shared/tui-agent' +import { RelayDispatcher } from '../../relay/dispatcher' +import { PluginOverlayManager } from '../../relay/plugin-overlay' +import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { + maybeRerunWslRelayGuestInstall, + runWslRelayGuestInstall +} from './wsl-hook-relay-guest-install' +import type { WslRelayDistroState } from './wsl-hook-relay-state' +import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' + +function heldRequest() { + const reached = Promise.withResolvers() + const release = Promise.withResolvers() + return { + reached: reached.promise, + release: release.resolve, + wait: async () => { + reached.resolve() + await release.promise + } + } +} + +let root: string +let state: WslRelayDistroState +let settings: { disabledTuiAgents: TuiAgent[] } +let guests: { mux: SshChannelMultiplexer; dispatcher: RelayDispatcher }[] +const sources = { opencodePluginSource: '// v1', opencode2PluginSource: '// v2' } +const deps = { + pluginSources: () => sources, + managedHookSettings: () => settings, + installHooks: vi.fn(async () => []), + installCodex: async () => null, + warn: vi.fn<(message: string) => void>() +} + +function createGuest(name: string, beforeInstall?: (index: number) => Promise) { + const home = join(root, name) + mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) + const callbacks: ((data: Buffer) => void)[] = [] + const dispatcher = new RelayDispatcher((data) => callbacks.forEach((callback) => callback(data))) + const mux = new SshChannelMultiplexer({ + write: (data) => dispatcher.feed(data), + onData: (callback) => { + callbacks.push(callback) + }, + onClose: () => {} + }) + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + ORCA_WSL_HOOK_INSTANCE: name + }) + const requests: Record[] = [] + dispatcher.onRequest('preflight.detectAgents', async () => ({ agents: [] })) + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + requests.push(params) + await beforeInstall?.(requests.length) + return install(params) + }) + const guest = { home, mux, dispatcher, requests } + guests.push(guest) + return guest +} + +function connect(guest: ReturnType) { + state.mux = guest.mux + state.guestHome = guest.home +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-wsl-install-race-')) + guests = [] + settings = { disabledTuiAgents: [] } + deps.installHooks.mockReset().mockResolvedValue([]) + deps.warn.mockReset() + state = { + distro: 'fixture', + phase: 'running', + launchKinds: new Set(), + failures: 0, + cooldownUntil: 0 + } +}) +afterEach(() => { + vi.restoreAllMocks() + for (const guest of guests) { + guest.mux.dispose() + guest.dispatcher.dispose() + } + rmSync(root, { recursive: true, force: true }) +}) + +it.each([true, false])('coalesces waiters through a held %s enablement toggle', async (enabled) => { + const first = heldRequest() + const second = heldRequest() + const guest = createGuest('guest', (index) => (index === 1 ? first.wait() : second.wait())) + connect(guest) + settings.disabledTuiAgents = enabled ? [] : ['opencode', 'opencode2'] + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await first.reached + settings.disabledTuiAgents = enabled ? ['opencode', 'opencode2'] : [] + let settled = 0 + const waiters = Array.from({ length: 4 }, () => + maybeRerunWslRelayGuestInstall(deps, state).then(() => { + settled++ + }) + ) + first.release() + await vi.waitFor(() => expect(guest.requests).toHaveLength(2), { timeout: 1000 }) + await second.reached + expect(settled).toBe(0) + expect(guest.requests).toHaveLength(2) + second.release() + await Promise.all([initial, ...waiters]) + expect(guest.requests).toHaveLength(2) + expect(guest.requests[1]).toMatchObject( + enabled ? { opencodePluginSource: '', opencode2PluginSource: '' } : sources + ) + if (enabled) { + expect(state.opencodeOverlayDir).toBeUndefined() + expect(state.opencode2OverlayDir).toBeUndefined() + } else { + expect( + readFileSync(join(state.opencodeOverlayDir!, 'plugins', 'orca-opencode-status.js'), 'utf8') + ).toBe('// v1') + expect( + readFileSync(join(state.opencode2OverlayDir!, 'plugins', 'orca-opencode2-status.js'), 'utf8') + ).toBe('// v2') + } + await maybeRerunWslRelayGuestInstall(deps, state) + expect(guest.requests).toHaveLength(2) + expect(state.installation).toBeUndefined() +}) + +it('drains reentrant toggles during successive requests without duplicate passes', async () => { + const waiters: Promise[] = [] + const guest = createGuest('guest', async (index) => { + if (index <= 2) { + settings.disabledTuiAgents = index === 1 ? ['opencode'] : ['opencode', 'opencode2'] + waiters.push(maybeRerunWslRelayGuestInstall(deps, state)) + } + }) + connect(guest) + await maybeRerunWslRelayGuestInstall(deps, state) + await Promise.all(waiters) + expect( + guest.requests.map((params) => [params.opencodePluginSource, params.opencode2PluginSource]) + ).toEqual([ + ['// v1', '// v2'], + ['', '// v2'], + ['', ''] + ]) + expect(state.opencodeOverlayDir).toBeUndefined() + expect(state.opencode2OverlayDir).toBeUndefined() +}) + +it('does not reinstall for rapid toggles that return to the in-flight settings', async () => { + const held = heldRequest() + const guest = createGuest('guest', () => held.wait()) + connect(guest) + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await held.reached + settings.disabledTuiAgents = ['opencode', 'opencode2'] + const disabled = maybeRerunWslRelayGuestInstall(deps, state) + settings.disabledTuiAgents = [] + const enabled = maybeRerunWslRelayGuestInstall(deps, state) + held.release() + await Promise.all([initial, disabled, enabled]) + expect(guest.requests).toHaveLength(1) + expect(state.opencodeOverlayDir).toBeTruthy() + expect(state.opencode2OverlayDir).toBeTruthy() +}) + +it('coalesces an ensure re-entering before the first asynchronous hook operation', async () => { + const guest = createGuest('guest') + connect(guest) + const waiters: Promise[] = [] + vi.spyOn(deps, 'managedHookSettings').mockImplementationOnce(() => { + waiters.push(maybeRerunWslRelayGuestInstall(deps, state)) + return settings + }) + await runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await Promise.all(waiters) + expect(guest.requests).toHaveLength(1) + expect(state.opencodeOverlayDir).toBeTruthy() +}) + +it('finishes a slow pass once, leaving time-based refresh to a later ensure', async () => { + let now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + const held = heldRequest() + const guest = createGuest('guest', async (index) => { + if (index === 1) { + await held.wait() + } + }) + connect(guest) + const initial = maybeRerunWslRelayGuestInstall(deps, state) + await held.reached + now += REINSTALL_MIN_INTERVAL_MS + 1 + held.release() + await initial + expect(guest.requests).toHaveLength(1) + await maybeRerunWslRelayGuestInstall(deps, state) + expect(guest.requests).toHaveLength(2) +}) + +it.each([true, false])( + 'handles a rejected joined task with changed settings %s', + async (changed) => { + const reached = Promise.withResolvers() + const rejected = Promise.withResolvers() + const guest = createGuest('guest') + connect(guest) + guest.dispatcher.onRequest('preflight.detectAgents', async () => ({ agents: ['claude'] })) + deps.installHooks.mockImplementationOnce(async () => { + reached.resolve() + await rejected.promise + return [] + }) + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home).catch( + (error) => error + ) + await reached.promise + if (changed) { + settings.disabledTuiAgents = ['opencode', 'opencode2'] + } + const waiter = maybeRerunWslRelayGuestInstall(deps, state) + rejected.reject(new Error('fixture installation failed')) + expect(await initial).toMatchObject({ message: 'fixture installation failed' }) + await waiter + expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('fixture installation failed')) + expect(guest.requests).toHaveLength(changed ? 1 : 0) + if (changed) { + expect(guest.requests[0]).toMatchObject({ + opencodePluginSource: '', + opencode2PluginSource: '' + }) + } + expect(state.installation).toBeUndefined() + expect(state.opencodeOverlayDir).toBeUndefined() + } +) + +it('does not publish a held response or reinstall after ownership is removed', async () => { + const held = heldRequest() + const guest = createGuest('retired', () => held.wait()) + connect(guest) + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await held.reached + settings.disabledTuiAgents = ['opencode', 'opencode2'] + const waiter = maybeRerunWslRelayGuestInstall(deps, state) + state.mux = undefined + held.release() + await Promise.all([initial, waiter]) + expect(guest.requests).toHaveLength(1) + expect(state.opencodeOverlayDir).toBeUndefined() + expect(state.opencode2OverlayDir).toBeUndefined() +}) + +it('leaves the replacement task and its paths owned by the new mux', async () => { + const oldHeld = heldRequest() + const nextHeld = heldRequest() + const old = createGuest('old', () => oldHeld.wait()) + connect(old) + const initial = runWslRelayGuestInstall(deps, state, old.mux, old.home) + await oldHeld.reached + const waiter = maybeRerunWslRelayGuestInstall(deps, state) + const next = createGuest('next', () => nextHeld.wait()) + connect(next) + state.installation = undefined + const replacement = runWslRelayGuestInstall(deps, state, next.mux, next.home) + await nextHeld.reached + const ownedTask = state.installation + oldHeld.release() + await initial + expect(state.installation).toBe(ownedTask) + expect(state.opencodeOverlayDir).toBeUndefined() + expect(state.opencode2OverlayDir).toBeUndefined() + nextHeld.release() + await Promise.all([waiter, replacement]) + expect(state.opencodeOverlayDir).toContain(next.home) + expect(state.opencode2OverlayDir).toContain(next.home) + expect(old.requests).toHaveLength(1) + expect(next.requests).toHaveLength(1) +}) + +it('does not dispatch plugins after teardown during hook detection', async () => { + const held = heldRequest() + const guest = createGuest('retired') + connect(guest) + guest.dispatcher.onRequest('preflight.detectAgents', async () => { + await held.wait() + return { agents: [] } + }) + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await held.reached + state.mux = undefined + held.release() + await initial + expect(guest.requests).toHaveLength(0) + expect(state.opencodeOverlayDir).toBeUndefined() +}) + +it('does not dispatch a recheck on a disposed mux', async () => { + const held = heldRequest() + const guest = createGuest('disposed', () => held.wait()) + connect(guest) + const initial = runWslRelayGuestInstall(deps, state, guest.mux, guest.home) + await held.reached + settings.disabledTuiAgents = ['opencode', 'opencode2'] + const waiter = maybeRerunWslRelayGuestInstall(deps, state) + guest.mux.dispose() + held.release() + await Promise.all([initial, waiter]) + expect(guest.requests).toHaveLength(1) + expect(state.opencodeOverlayDir).toBeUndefined() +}) diff --git a/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts b/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts new file mode 100644 index 00000000000..29cf139ace5 --- /dev/null +++ b/src/main/agent-hooks/wsl-opencode-settings-lifetime.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { RelayDispatcher } from '../../relay/dispatcher' +import { PluginOverlayManager } from '../../relay/plugin-overlay' +import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { WslHookRelayManager } from './wsl-hook-relay-manager' +import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' +import type { MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' +import type { TuiAgent } from '../../shared/tui-agent' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD +} from '../../shared/agent-hook-relay' +import { WSL_HOOK_FS_METHODS } from '../../shared/wsl-hook-relay-contract' + +function gate() { + const reached = Promise.withResolvers() + const release = Promise.withResolvers() + return { + reached: reached.promise, + release: release.resolve, + wait: async () => { + reached.resolve() + await release.promise + } + } +} + +function fakeChild(): ChildProcessWithoutNullStreams { + const child = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: { write: () => true, end: () => {}, on: () => {} }, + kill: () => true + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The injected launcher and link only use these emitter, stream, and kill members; no process is spawned. + return child as unknown as ChildProcessWithoutNullStreams +} + +// Guest materialization uses POSIX home semantics; native Windows needs the WSL rig. +describe.skipIf(process.platform === 'win32')('WSL public-manager settings lifetime', () => { + let root: string + let manager: WslHookRelayManager + let guests: ReturnType[] + let activeGuest: ReturnType + let settings: { disabledTuiAgents: TuiAgent[] } + let requests: Record[] + let now: number + let detectionCount: number + let hookCount: number + let pause: 'detect' | 'hooks' | 'plugin' + let rejectRequest: boolean + let rejectHooks: boolean + let onPluginRequest: ((index: number) => Promise) | undefined + let beforeSources: ReturnType + let pendingRequest: ReturnType + const sources = { + opencodePluginSource: '// independent v1', + opencode2PluginSource: '// independent v2', + piExtensionSource: '// pi', + ompExtensionSource: '// omp' + } + + function createGuest(name: string): { + home: string + transport: MultiplexerTransport + dispatcher: RelayDispatcher + } { + const home = join(root, name) + mkdirSync(join(home, '.config', 'opencode'), { recursive: true }) + writeFileSync(join(home, '.config', 'opencode', 'opencode.json'), '{"model":"fixture"}') + const callbacks: ((data: Buffer) => void)[] = [] + const transport: MultiplexerTransport = { + write: (data) => dispatcher.feed(data), + onData: (callback) => { + callbacks.push(callback) + }, + onClose: () => {} + } + const dispatcher = new RelayDispatcher((data) => + callbacks.forEach((callback) => callback(data)) + ) + dispatcher.onRequest(WSL_HOOK_FS_METHODS.home, async () => ({ ok: true, home })) + dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({ replayed: 0 })) + dispatcher.onRequest('preflight.detectAgents', async () => { + if (++detectionCount === 2 && pause === 'detect') { + await beforeSources.wait() + } + return { agents: ['claude'] } + }) + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + XDG_CONFIG_HOME: join(home, '.config'), + ORCA_WSL_HOOK_INSTANCE: 'independent-manager' + }) + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (params) => { + requests.push(params) + if (onPluginRequest) { + await onPluginRequest(requests.length) + } else if (requests.length === 2) { + await pendingRequest.wait() + if (rejectRequest) { + throw new Error('fixture plugin request rejected') + } + } + return install(params) + }) + const guest = { home, transport, dispatcher } + guests.push(guest) + return guest + } + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-wsl-manager-boundary-')) + settings = { disabledTuiAgents: [] } + requests = [] + now = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => now) + rejectRequest = false + rejectHooks = false + onPluginRequest = undefined + detectionCount = 0 + hookCount = 0 + beforeSources = gate() + pendingRequest = gate() + guests = [] + activeGuest = createGuest('first') + manager = new WslHookRelayManager({ + platform: () => 'win32', + remoteHooksEnabled: () => true, + hookCoordsEnv: () => ({ ORCA_AGENT_HOOK_PORT: '43117', ORCA_AGENT_HOOK_TOKEN: 'fixture' }), + instanceKey: () => 'independent', + resolveBundle: () => ({ jsPath: join(root, 'relay.js'), version: 'fixture' }), + readBundle: () => Buffer.from('// fixture'), + listDistros: async () => ['fixture'], + isDistroRunning: async () => true, + spawnRelay: () => fakeChild(), + runInstall: async () => ({ code: 0, stderr: '' }), + waitForSentinel: async () => activeGuest.transport, + ingest: () => {}, + installHooks: async () => { + if (++hookCount === 2 && pause === 'hooks') { + await beforeSources.wait() + } + if (rejectHooks) { + throw new Error('fixture hook failure') + } + return [] + }, + installCodex: async () => null, + managedHookSettings: () => settings, + pluginSources: () => sources, + warn: vi.fn(), + transientRetryDelayMs: 1 + }) + }) + afterEach(() => { + manager.disposeAll() + for (const guest of guests) { + guest.dispatcher.dispose() + } + vi.restoreAllMocks() + rmSync(root, { recursive: true, force: true }) + }) + + const cases = (['detect', 'hooks', 'plugin'] as const).flatMap((stage) => + [true, false].flatMap((initialEnabled) => + [true, false].map((toggleBack) => ({ stage, initialEnabled, toggleBack })) + ) + ) + + it.each(cases)( + 'manager settings across $stage: initially enabled $initialEnabled, toggle back $toggleBack', + async ({ stage, initialEnabled, toggleBack }) => { + pause = stage + const setEnabled = (enabled: boolean) => { + settings = { disabledTuiAgents: enabled ? [] : ['opencode', 'opencode2'] } + } + setEnabled(initialEnabled) + await manager.ensureForDistro('fixture') + expect(requests).toHaveLength(1) + now += REINSTALL_MIN_INTERVAL_MS + 1 + const refresh = manager.ensureForDistro('fixture') + const waiters: Promise[] = [refresh] + if (stage !== 'plugin') { + await beforeSources.reached + setEnabled(!initialEnabled) + waiters.push(manager.ensureForDistro('fixture')) + beforeSources.release() + } + await pendingRequest.reached + const sentEnabled = stage === 'plugin' ? initialEnabled : !initialEnabled + expect(requests[1]).toMatchObject( + sentEnabled + ? sources + : { + opencodePluginSource: '', + opencode2PluginSource: '' + } + ) + if (stage === 'plugin') { + setEnabled(!initialEnabled) + waiters.push(manager.ensureForDistro('fixture')) + } + const finalEnabled = toggleBack ? initialEnabled : !initialEnabled + setEnabled(finalEnabled) + waiters.push(...Array.from({ length: 4 }, () => manager.ensureForDistro('fixture'))) + pendingRequest.release() + await Promise.all(waiters) + expect(requests).toHaveLength(sentEnabled === finalEnabled ? 2 : 3) + expect(requests.at(-1)).toMatchObject( + finalEnabled + ? sources + : { + opencodePluginSource: '', + opencode2PluginSource: '' + } + ) + for (const agent of ['opencode', 'opencode2'] as const) { + const dir = manager.getOpenCodeOverlayDir('fixture', agent) + if (finalEnabled) { + expect(dir).toBeTruthy() + if (!dir) { + throw new Error('missing enabled overlay') + } + expect(readFileSync(join(dir, 'plugins', `orca-${agent}-status.js`), 'utf8')).toBe( + agent === 'opencode' ? sources.opencodePluginSource : sources.opencode2PluginSource + ) + } else { + expect(dir).toBeNull() + } + } + const settledCount = requests.length + await manager.ensureForDistro('fixture') + expect(requests).toHaveLength(settledCount) + } + ) + + it('preserves known pointers after plugin rejection without treating it as revocation', async () => { + pause = 'detect' + await manager.ensureForDistro('fixture') + const oldV1 = manager.getOpenCodeOverlayDir('fixture') + const oldV2 = manager.getOpenCodeOverlayDir('fixture', 'opencode2') + now += REINSTALL_MIN_INTERVAL_MS + 1 + const refresh = manager.ensureForDistro('fixture') + await beforeSources.reached + settings = { disabledTuiAgents: ['opencode', 'opencode2'] } + const disabled = manager.ensureForDistro('fixture') + beforeSources.release() + await pendingRequest.reached + rejectRequest = true + settings = { disabledTuiAgents: [] } + const restored = manager.ensureForDistro('fixture') + pendingRequest.release() + await Promise.all([refresh, disabled, restored]) + expect(requests).toHaveLength(2) + expect(manager.getOpenCodeOverlayDir('fixture')).toBe(oldV1) + expect(manager.getOpenCodeOverlayDir('fixture', 'opencode2')).toBe(oldV2) + await manager.ensureForDistro('fixture') + expect(requests).toHaveLength(2) + }) + + it('tracks the settings sent by successive Pi and OMP requests', async () => { + pause = 'plugin' + await manager.ensureForDistro('fixture', undefined, 'pi') + const secondKind = gate() + onPluginRequest = async (index) => { + if (index === 2) { + await pendingRequest.wait() + } + if (index === 3) { + await secondKind.wait() + } + } + const refresh = manager.ensureForDistro('fixture', undefined, 'omp') + await pendingRequest.reached + settings.disabledTuiAgents = ['opencode', 'opencode2'] + const disabled = manager.ensureForDistro('fixture') + pendingRequest.release() + await secondKind.reached + expect(requests[2]).toMatchObject({ + launchKind: 'omp', + opencodePluginSource: '', + opencode2PluginSource: '' + }) + settings.disabledTuiAgents = [] + const restored = manager.ensureForDistro('fixture') + secondKind.release() + await Promise.all([refresh, disabled, restored]) + expect(requests.map((request) => request.launchKind)).toEqual(['pi', 'pi', 'omp', 'pi', 'omp']) + expect(requests.at(-1)).toMatchObject(sources) + expect(manager.getOpenCodeOverlayDir('fixture')).toBeTruthy() + expect(manager.getOpenCodeOverlayDir('fixture', 'opencode2')).toBeTruthy() + const pi = manager.getGuestAgentPath('fixture', 'pi') + const omp = manager.getGuestAgentPath('fixture', 'omp') + if (!pi || !omp) { + throw new Error('missing guest agent paths') + } + expect(readFileSync(join(pi, 'extensions', 'orca-agent-status.ts'), 'utf8')).toContain('// pi') + expect(readFileSync(omp, 'utf8')).toContain('// omp') + await manager.ensureForDistro('fixture') + expect(requests).toHaveLength(5) + }) + + it('bounds repeated pre-plugin failures and retries after the cooldown', async () => { + pause = 'hooks' + await manager.ensureForDistro('fixture') + const previous = manager.getOpenCodeOverlayDir('fixture') + now += REINSTALL_MIN_INTERVAL_MS + 1 + const refresh = manager.ensureForDistro('fixture') + await beforeSources.reached + settings.disabledTuiAgents = ['opencode', 'opencode2'] + rejectHooks = true + const waiters = Array.from({ length: 4 }, () => manager.ensureForDistro('fixture')) + beforeSources.release() + await Promise.all([refresh, ...waiters]) + expect(hookCount).toBe(3) + expect(requests).toHaveLength(1) + expect(manager.getOpenCodeOverlayDir('fixture')).toBe(previous) + await manager.ensureForDistro('fixture') + expect(hookCount).toBe(3) + now += REINSTALL_MIN_INTERVAL_MS + 1 + rejectHooks = false + pendingRequest.release() + await manager.ensureForDistro('fixture') + expect(hookCount).toBe(4) + expect(requests.at(-1)).toMatchObject({ opencodePluginSource: '', opencode2PluginSource: '' }) + expect(manager.getOpenCodeOverlayDir('fixture')).toBeNull() + }) + + it('keeps a disposed generation from publishing into a reconnected manager', async () => { + pause = 'plugin' + await manager.ensureForDistro('fixture') + now += REINSTALL_MIN_INTERVAL_MS + 1 + const refresh = manager.ensureForDistro('fixture') + await pendingRequest.reached + const waiters = Array.from({ length: 4 }, () => manager.ensureForDistro('fixture')) + manager.disposeAll({ permanent: false }) + activeGuest = createGuest('replacement') + await manager.ensureForDistro('fixture') + const current = manager.getOpenCodeOverlayDir('fixture') + expect(current).toContain(activeGuest.home) + pendingRequest.release() + await Promise.all([refresh, ...waiters]) + expect(requests).toHaveLength(3) + expect(manager.getOpenCodeOverlayDir('fixture')).toBe(current) + expect(manager.getOpenCodeOverlayDir('fixture', 'opencode2')).toContain(activeGuest.home) + }) +}) diff --git a/src/main/agent-hooks/wsl-opencode-settings.test.ts b/src/main/agent-hooks/wsl-opencode-settings.test.ts new file mode 100644 index 00000000000..0e868635ea2 --- /dev/null +++ b/src/main/agent-hooks/wsl-opencode-settings.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TuiAgent } from '../../shared/tui-agent' +import { RelayDispatcher } from '../../relay/dispatcher' +import { PluginOverlayManager } from '../../relay/plugin-overlay' +import { createInstallPluginsHandler } from '../../relay/wsl-install-plugins-handler' +import { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' +import { + maybeRerunWslRelayGuestInstall, + runWslRelayGuestInstall +} from './wsl-hook-relay-guest-install' +import type { WslRelayDistroState } from './wsl-hook-relay-state' + +let home: string +let dispatcher: RelayDispatcher +let mux: SshChannelMultiplexer +let state: WslRelayDistroState +let settings: { disabledTuiAgents: TuiAgent[] } +let params: Record[] +const sources = { + opencodePluginSource: '// v1', + opencode2PluginSource: '// v2', + piExtensionSource: '// pi' +} +const deps = { + pluginSources: () => sources, + managedHookSettings: () => settings, + installHooks: async () => [], + installCodex: async () => null, + warn: (message: string) => { + throw new Error(message) + } +} +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-wsl-opencode-settings-')) + const xdg = join(home, 'xdg') + mkdirSync(join(xdg, 'opencode'), { recursive: true }) + writeFileSync(join(xdg, 'opencode', 'opencode.json'), '{"model":"guest-fixture"}') + settings = { disabledTuiAgents: [] } + params = [] + const callbacks: ((data: Buffer) => void)[] = [] + dispatcher = new RelayDispatcher((data) => { + for (const callback of callbacks) { + callback(data) + } + }) + mux = new SshChannelMultiplexer({ + write: (data) => { + dispatcher.feed(data) + }, + onData: (callback) => { + callbacks.push(callback) + }, + onClose: () => {} + }) + const install = createInstallPluginsHandler(new PluginOverlayManager({ homeDir: home }), { + HOME: home, + XDG_CONFIG_HOME: xdg, + ORCA_WSL_HOOK_INSTANCE: 'settings-fixture' + }) + dispatcher.onRequest('preflight.detectAgents', async () => ({ agents: [] })) + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async (request) => { + params.push(request) + return install(request) + }) + state = { + distro: 'fixture', + phase: 'running', + mux, + guestHome: home, + launchKinds: new Set(), + failures: 0, + cooldownUntil: 0 + } +}) +afterEach(() => { + mux.dispose() + dispatcher.dispose() + rmSync(home, { recursive: true, force: true }) +}) + +describe('WSL settings over the existing plugin RPC into real fixture overlays', () => { + const cases: TuiAgent[][] = [[], ['opencode'], ['opencode2'], ['opencode', 'opencode2']] + it.each(cases.map((disabled) => ({ disabled })))( + 'honors disabled $disabled', + async ({ disabled }) => { + settings.disabledTuiAgents = disabled + const result = await requestGuestOpenCodeOverlayDir(mux, deps, 'fixture') + for (const agent of ['opencode', 'opencode2'] as const) { + const key = agent === 'opencode' ? 'opencodePluginSource' : 'opencode2PluginSource' + expect(params[0][key]).toBe(disabled.includes(agent) ? '' : sources[key]) + } + if (result.kind === 'dir') { + expect(Boolean(result.dir)).toBe(!disabled.includes('opencode')) + expect(Boolean(result.dir2)).toBe(!disabled.includes('opencode2')) + const dir = result.dir ?? result.dir2 + if (!dir) { + throw new Error('missing overlay') + } + expect(readFileSync(join(dir, 'opencode.json'), 'utf8')).toBe('{"model":"guest-fixture"}') + } else { + expect(disabled).toEqual(['opencode', 'opencode2']) + } + expect(params[0].piExtensionSource).toBe('// pi') + } + ) + + it('revokes cached sources before the reinstall interval and re-enables the same service', async () => { + await runWslRelayGuestInstall(deps, state, mux, home) + const dir = state.opencodeOverlayDir + if (!dir) { + throw new Error('missing original overlay') + } + const path = join(dir, 'plugins', 'orca-opencode-status.js') + writeFileSync(path, '// installed sentinel') + settings.disabledTuiAgents = ['opencode'] + await maybeRerunWslRelayGuestInstall(deps, state) + expect(state.opencodeOverlayDir).toBeUndefined() + expect(state.opencode2OverlayDir).toBeTruthy() + expect(readFileSync(path, 'utf8')).toBe('// installed sentinel') + const omitted = await requestGuestOpenCodeOverlayDir( + mux, + { ...deps, pluginSources: () => ({}) }, + 'fixture' + ) + expect(omitted.kind === 'dir' ? omitted.dir : undefined).toBeUndefined() + settings.disabledTuiAgents = [] + await maybeRerunWslRelayGuestInstall(deps, state) + expect(state.opencodeOverlayDir).toBe(dir) + expect(existsSync(path)).toBe(true) + }) +}) diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index 32281f38618..6fdf7b70da6 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -1,3 +1,4 @@ +import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' import { removeAppImageRuntimeEnv } from '../../pty/appimage-terminal-env' @@ -57,6 +58,22 @@ function deleteRequestedDaemonEnvKeys( keys?.includes('ORCA_CODEX_HOME') === true && env.ORCA_CODEX_HOME !== undefined && env.CODEX_HOME === env.ORCA_CODEX_HOME + // A merged caller config can supersede the daemon's recorded overlay source. + if ( + keys?.includes('ORCA_OPENCODE_CONFIG_DIR') && + (env.OPENCODE_CONFIG_DIR === undefined || + env.OPENCODE_CONFIG_DIR === env.ORCA_OPENCODE_CONFIG_DIR) + ) { + restoreOrStripOverlayEnv( + env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + }, + {} + ) + } for (const key of keys ?? []) { delete env[key] } diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index b5daa5bed7f..b6690b1cb83 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -1,5 +1,5 @@ import { resolveSetupAgentSequenceLaunchCommand } from '../../../../shared/setup-agent-sequencing' -import { isOpenCode2LaunchCommand } from '../../../../shared/opencode-launch-command' +import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command' import { detectExplicitPiAgentKindFromCommand, isPiCompatibleAgentType @@ -48,12 +48,11 @@ export function buildPtyHostEnv( // Why: local path's baseEnv includes process.env but the daemon path doesn't (fork inheritance, not IPC); check both sources so guards stay in lock-step across spawn paths. const preexistingOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) - // Typed launches do not carry the picker identity; infer the beta binary so - // it receives the OpenCode 2 hook endpoint and isolated plugin overlay. - const openCodeAgent = - opts.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) - ? 'opencode2' - : 'opencode' + const openCodeAgent = selectOpenCodeHookAgent( + opts.launchAgent, + launchCommandHint, + (agent) => opts.agentStatusHooksEnabled && isTuiAgentEnabled(agent, opts.disabledTuiAgents) + ) const explicitPiAgentKind = isPiCompatibleAgentType(opts.launchAgent) ? opts.launchAgent : opts.launchAgent === undefined @@ -88,12 +87,22 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - if (opts.agentStatusHooksEnabled) { + restoreOrStripOverlayEnv(baseEnv, { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }) + delete baseEnv.ORCA_OPENCODE_AGENT + if (openCodeAgent) { // Why: OPENCODE_CONFIG_DIR is a single path, not a colon-list; mirror the user's value into an overlay so their plugins and Orca's status plugin coexist. See docs/opencode-config-dir-collision.md. const openCodeStatusService = openCodeAgent === 'opencode2' ? openCode2HookService : openCodeHookService baseEnv.ORCA_OPENCODE_AGENT = openCodeAgent - Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + // WSL owns its config writes; only the guest overlay may enter a WSL pane. + if (!opts.isWsl) { + Object.assign(baseEnv, openCodeStatusService.buildPtyEnv(id, preexistingOpenCodeConfigDir)) + } if (baseEnv.OPENCODE_CONFIG_DIR) { // Why: ~/.zshrc can re-export the user's default after spawn; shell-ready wrappers restore this PTY-scoped value. baseEnv.ORCA_OPENCODE_CONFIG_DIR = baseEnv.OPENCODE_CONFIG_DIR @@ -104,6 +113,8 @@ export function buildPtyHostEnv( delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } } + } + if (opts.agentStatusHooksEnabled) { if (isMimoLaunchCommand(launchCommandHint)) { const preexistingMimocodeHome = resolveMimocodeSourceHome(baseEnv) Object.assign(baseEnv, mimoCodeHookService.buildPtyEnv(id, preexistingMimocodeHome)) @@ -117,11 +128,6 @@ export function buildPtyHostEnv( } } } else { - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' - }) restoreOrStripOverlayEnv(baseEnv, { primary: 'MIMOCODE_HOME', overlay: 'ORCA_MIMOCODE_HOME', @@ -148,13 +154,15 @@ export function buildPtyHostEnv( baseEnv.ORCA_AGENT_HOOK_ENDPOINT = guestEndpoint } // Why: OpenCode loads its status plugin from a guest config overlay, so point OPENCODE_CONFIG_DIR at the guest dir the relay materialized. - const opencodeOverlayDir = wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) + const opencodeOverlayDir = openCodeAgent + ? wslHookRelayManager.getOpenCodeOverlayDir(distro, openCodeAgent) + : null if (opencodeOverlayDir) { baseEnv.OPENCODE_CONFIG_DIR = opencodeOverlayDir baseEnv.ORCA_OPENCODE_CONFIG_DIR = opencodeOverlayDir delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR } else { - // Why: relay not connected yet (or older guest bundle) — never cross the Windows overlay path into WSL; drop it so in-guest OpenCode uses its own config (pre-fix behavior, no status but no regression). + // Only guest overlays belong in WSL; otherwise let OpenCode use its guest config. delete baseEnv.OPENCODE_CONFIG_DIR delete baseEnv.ORCA_OPENCODE_CONFIG_DIR delete baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts new file mode 100644 index 00000000000..eb6b1faacd7 --- /dev/null +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -0,0 +1,367 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + createDaemonPtyEnvironment, + rescrubDaemonPtyEnvironment +} from '../../../daemon/pty-subprocess/spawn-environment' +import { getInheritedAgentHookEnvKeysToDelete } from './pi-agent' +import { buildPtyHostEnv } from './assembly' +import type { BuildPtyHostEnvOptions } from './types' + +const fixture = vi.hoisted(() => ({ userData: '', guestOverlay: '' })) +vi.mock('../../../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => fixture.userData }) +})) +vi.mock('../../../agent-hooks/server', () => ({ + agentHookServer: { buildPtyEnv: () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) } +})) +vi.mock('../../../agent-hooks/wsl-hook-relay-manager', () => ({ + wslHookRelayManager: { + ensureForDistro: vi.fn(), + getGuestEndpointFilePath: () => '/guest/endpoint.json', + getOpenCodeOverlayDir: () => fixture.guestOverlay, + getGuestAgentPath: () => null + } +})) +vi.mock('../../../pi/titlebar-extension-service', () => ({ + piTitlebarExtensionService: { buildPtyEnv: () => ({}), buildFreshOmpEnv: () => ({}) } +})) +vi.mock('../../../cli/orca-cli-child-path', () => ({ prependOrcaCliDirToChildPath: () => {} })) +vi.mock('../../../cli/wsl-managed-cli', () => ({ + getManagedWslCliDir: () => undefined, + getWslCliCommandName: () => 'orca-ide' +})) + +let root: string +let config: string +let custom: string +let options: BuildPtyHostEnvOptions +const plugin = (dir: string, agent: string) => join(dir, 'plugins', `orca-${agent}-status.js`) + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-opencode-disabled-')) + const home = join(root, 'home') + const xdg = join(root, 'xdg') + mkdirSync(home) + fixture.userData = join(root, 'user-data') + fixture.guestOverlay = join(root, 'guest-overlay') + config = join(xdg, 'opencode') + custom = join(root, 'custom') + mkdirSync(join(custom, 'plugins'), { recursive: true }) + writeFileSync(join(custom, 'opencode.json'), '{"model":"fixture"}') + writeFileSync(join(custom, 'plugins', 'user.js'), '// user plugin') + vi.stubEnv('HOME', home) + vi.stubEnv('USERPROFILE', home) + vi.stubEnv('XDG_CONFIG_HOME', xdg) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + 'ORCA_OPENCODE_AGENT', + 'ZDOTDIR' + ]) { + vi.stubEnv(key, undefined) + } + options = { + isPackaged: true, + userDataPath: fixture.userData, + selectedCodexHomePath: null, + agentStatusHooksEnabled: true + } +}) +afterEach(() => { + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) + +describe('OpenCode installation uses the current enabled agents', () => { + const combinations = [ + { disabled: [], fallback: 'opencode' }, + { disabled: ['opencode'], fallback: 'opencode2' }, + { disabled: ['opencode2'], fallback: 'opencode' }, + { disabled: ['opencode', 'opencode2'], fallback: undefined } + ] + for (const { disabled, fallback } of combinations) { + it.each(['default', 'custom'])(`bare %s config with disabled ${disabled.join(',')}`, (kind) => { + const env = buildPtyHostEnv( + 'pane', + kind === 'custom' ? { OPENCODE_CONFIG_DIR: custom } : {}, + { + ...options, + disabledTuiAgents: disabled + } + ) + expect(env.ORCA_OPENCODE_AGENT).toBe(fallback) + const selected = env.OPENCODE_CONFIG_DIR ?? config + for (const agent of ['opencode', 'opencode2']) { + expect(existsSync(plugin(selected, agent))).toBe(agent === fallback) + } + if (kind === 'custom') { + expect(readFileSync(join(selected, 'plugins', 'user.js'), 'utf8')).toBe('// user plugin') + expect(readFileSync(join(selected, 'opencode.json'), 'utf8')).toBe('{"model":"fixture"}') + expect(existsSync(plugin(custom, 'opencode'))).toBe(false) + } + }) + it.each(['opencode', 'opencode2'] as const)( + `explicit %s with disabled ${disabled.join(',')}`, + (agent) => { + for (const selection of [ + { launchCommand: `${agent} --session fixture` }, + { launchAgent: agent } + ]) { + const env = buildPtyHostEnv( + 'pane', + {}, + { ...options, ...selection, disabledTuiAgents: disabled } + ) + const selected = disabled.includes(agent) ? undefined : agent + expect(env.ORCA_OPENCODE_AGENT).toBe(selected) + expect(existsSync(plugin(config, agent))).toBe(selected === agent) + expect(existsSync(plugin(config, agent === 'opencode' ? 'opencode2' : 'opencode'))).toBe( + false + ) + } + } + ) + } + + it('reads enable/disable changes on the same services without deleting installed files', () => { + buildPtyHostEnv('first', {}, options) + const installed = plugin(config, 'opencode') + writeFileSync(installed, '// already installed sentinel') + const env = buildPtyHostEnv('second', {}, { ...options, disabledTuiAgents: ['opencode'] }) + expect(env.ORCA_OPENCODE_AGENT).toBe('opencode2') + expect(readFileSync(installed, 'utf8')).toBe('// already installed sentinel') + buildPtyHostEnv('third', {}, options) + expect(readFileSync(installed, 'utf8')).toContain('/hook/opencode') + }) + + it.each([true, false])( + 'restores inherited source and clears markers with hooks %s', + (enabled) => { + const first = buildPtyHostEnv('first', { OPENCODE_CONFIG_DIR: custom }, options) + const original = readFileSync(plugin(first.OPENCODE_CONFIG_DIR, 'opencode'), 'utf8') + const env = buildPtyHostEnv( + 'second', + { ...first }, + { + ...options, + agentStatusHooksEnabled: enabled, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(readFileSync(plugin(first.OPENCODE_CONFIG_DIR, 'opencode'), 'utf8')).toBe(original) + expect(env.ORCA_AGENT_HOOK_PORT).toBe(enabled ? '12345' : undefined) + } + ) + + it('drops an inherited overlay without a source and preserves an unrelated explicit config', () => { + for (const primary of [fixture.guestOverlay, custom]) { + const env = buildPtyHostEnv( + 'pane', + { + OPENCODE_CONFIG_DIR: primary, + ORCA_OPENCODE_CONFIG_DIR: fixture.guestOverlay, + ORCA_OPENCODE_AGENT: 'opencode' + }, + { ...options, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + expect(env.OPENCODE_CONFIG_DIR).toBe(primary === custom ? custom : undefined) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + } + }) + + it('does not write a native plugin for WSL or inject a disabled guest overlay', () => { + const enabled = buildPtyHostEnv( + 'wsl-enabled', + {}, + { ...options, isWsl: true, launchAgent: 'opencode2' } + ) + expect(enabled.OPENCODE_CONFIG_DIR).toBe(fixture.guestOverlay) + expect(existsSync(config)).toBe(false) + const disabled = buildPtyHostEnv( + 'wsl-disabled', + {}, + { + ...options, + isWsl: true, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + expect(disabled.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(disabled.ORCA_AGENT_HOOK_ENDPOINT).toBe('/guest/endpoint.json') + expect(existsSync(config)).toBe(false) + }) +}) + +it.each(['source', 'no-source', 'user-config'])( + 'carries sparse host cleanup into daemon %s env', + (kind) => { + const prepared = buildPtyHostEnv( + 'daemon-pane', + {}, + { + ...options, + disabledTuiAgents: ['opencode', 'opencode2'] + } + ) + vi.stubEnv('OPENCODE_CONFIG_DIR', kind === 'user-config' ? custom : fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_AGENT', 'opencode') + if (kind === 'source') { + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', custom) + } + const result = createDaemonPtyEnvironment({ + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + }) + expect(result.OPENCODE_CONFIG_DIR).toBe(kind === 'no-source' ? undefined : custom) + expect(result.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(result.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + } +) + +it.each([ + { primary: 'overlay', source: true, explicit: true, expected: 'current' }, + { primary: 'current', source: true, explicit: false, expected: 'current' }, + { primary: 'overlay', source: true, explicit: false, expected: 'previous' }, + { primary: 'absent', source: true, explicit: false, expected: 'previous' }, + { primary: 'overlay', source: false, explicit: false, expected: undefined }, + { primary: 'overlay', source: false, explicit: true, expected: 'current' }, + { primary: 'absent', source: false, explicit: false, expected: undefined } +])('daemon config precedence: $primary, source $source, explicit $explicit', (scenario) => { + const prepared = buildPtyHostEnv( + 'daemon-pane', + scenario.explicit ? { OPENCODE_CONFIG_DIR: custom } : {}, + { ...options, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + const previous = join(root, 'previous') + mkdirSync(previous) + writeFileSync(join(previous, 'opencode.json'), '{"model":"previous"}') + vi.stubEnv( + 'OPENCODE_CONFIG_DIR', + scenario.primary === 'absent' + ? undefined + : scenario.primary === 'current' + ? custom + : fixture.guestOverlay + ) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', scenario.source ? previous : undefined) + vi.stubEnv('ORCA_OPENCODE_AGENT', 'opencode') + const request = { + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + } + const result = createDaemonPtyEnvironment(request) + rescrubDaemonPtyEnvironment(result, request) + if (scenario.expected) { + expect(readFileSync(join(result.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + scenario.expected === 'current' ? '{"model":"fixture"}' : '{"model":"previous"}' + ) + } else { + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() + } + expect(result.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(result.ORCA_OPENCODE_AGENT).toBeUndefined() +}) + +it.each(['opencode', 'opencode2'] as const)( + 'keeps the freshly selected %s overlay through a stale daemon', + (agent) => { + const prepared = buildPtyHostEnv( + 'pane', + { OPENCODE_CONFIG_DIR: custom }, + { + ...options, + launchAgent: agent + } + ) + vi.stubEnv('OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', fixture.guestOverlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', join(root, 'stale-source')) + const result = createDaemonPtyEnvironment({ + sessionId: 'fixture', + cols: 80, + rows: 24, + env: prepared, + envToDelete: getInheritedAgentHookEnvKeysToDelete(prepared) + }) + expect(readFileSync(join(result.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(existsSync(plugin(result.OPENCODE_CONFIG_DIR, agent))).toBe(true) + expect(result.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(custom) + expect(result.ORCA_OPENCODE_AGENT).toBe(agent) + } +) + +it.each([true, false])( + 'preserves explicit host config over inherited markers with hooks %s', + (enabled) => { + const stale = join(root, 'stale-source') + mkdirSync(stale) + writeFileSync(join(stale, 'opencode.json'), '{"model":"stale"}') + for (const inheritedFromProcess of [true, false]) { + const markers = { + ORCA_OPENCODE_CONFIG_DIR: join(root, 'old-overlay'), + ORCA_OPENCODE_SOURCE_CONFIG_DIR: stale + } + for (const [key, value] of Object.entries(markers)) { + vi.stubEnv(key, inheritedFromProcess ? value : undefined) + } + const env = buildPtyHostEnv( + 'explicit-config', + { + ...(inheritedFromProcess ? {} : markers), + OPENCODE_CONFIG_DIR: custom + }, + { ...options, agentStatusHooksEnabled: enabled } + ) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + expect(existsSync(plugin(stale, 'opencode'))).toBe(false) + } + } +) + +it.each([true, false])( + 'drops host config for disabled WSL variants with explicit primary %s', + (explicit) => { + const overlay = 'C:\\Users\\fixture\\Orca\\opencode-overlays\\old' + const source = 'C:\\Users\\fixture\\config\\opencode' + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', overlay) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', source) + const env = buildPtyHostEnv( + 'wsl-disabled-inherited', + { + OPENCODE_CONFIG_DIR: explicit ? source : overlay, + ORCA_OPENCODE_CONFIG_DIR: overlay, + ORCA_OPENCODE_SOURCE_CONFIG_DIR: source + }, + { ...options, isWsl: true, disabledTuiAgents: ['opencode', 'opencode2'] } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBe('/guest/endpoint.json') + expect(existsSync(config)).toBe(false) + } +) diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 545d04d063d..3c33c499c89 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -130,7 +130,12 @@ export function getInheritedAgentHookEnvKeysToDelete( ): string[] { const env = spawnEnv ?? {} // Why: providers merge process.env after cleanup; delete stale hook keys without dropping fresh coordinates buildPtyHostEnv set. - return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) + return [ + ...AGENT_HOOK_RUNTIME_ENV_KEYS, + 'ORCA_OPENCODE_AGENT', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' + ].filter((key) => env[key] === undefined) } export function getInheritedClaudeSessionStampEnvKeysToDelete( @@ -142,25 +147,7 @@ export function getInheritedClaudeSessionStampEnvKeysToDelete( return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) } -// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. -export function restoreOrStripOverlayEnv( - baseEnv: Record, - keys: { - primary: string - overlay: string - source: string - } -): void { - const sourceValue = baseEnv[keys.source] ?? process.env[keys.source] - const overlayValue = baseEnv[keys.overlay] ?? process.env[keys.overlay] - if (sourceValue) { - baseEnv[keys.primary] = sourceValue - } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { - delete baseEnv[keys.primary] - } - delete baseEnv[keys.overlay] - delete baseEnv[keys.source] -} +export { restoreOrStripOverlayEnv } from '../../../../shared/agent-overlay-env' export function isMimoLaunchCommand(launchCommand: string | undefined): boolean { const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) @@ -185,14 +172,17 @@ export function resolveMimocodeSourceHome(baseEnv: Record): stri export function resolveOpenCodeSourceConfigDir( baseEnv: Record ): string | undefined { + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR + if (configDir && orcaConfigDir && configDir !== orcaConfigDir) { + return configDir + } const sourceDir = baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR if (sourceDir) { return sourceDir } - const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? process.env.OPENCODE_CONFIG_DIR - const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? process.env.ORCA_OPENCODE_CONFIG_DIR // Why: with no recorded source dir, an inherited OPENCODE_CONFIG_DIR is Orca-owned, not user config; treating it as user config makes child Orcas mirror the hook dir. if (configDir && orcaConfigDir && configDir === orcaConfigDir) { return undefined diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 67351e08102..da10d8841a1 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -3,6 +3,8 @@ import { AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' +import { getDefaultSettings } from '../../shared/constants' +import type { Store } from '../persistence' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' @@ -159,4 +161,45 @@ describe('SshRelaySession managed hooks', () => { }) ) }) + it('refreshes OpenCode sources on settings changes and releases its subscription', async () => { + muxRequestMock.mockResolvedValue({ agents: [] }) + const { mockStore, mockConn, mockPortForward, getMainWindow } = createMockDeps() + const settings = getDefaultSettings('/synthetic-home') + settings.disabledTuiAgents = ['opencode'] + mockStore.getSettings = () => settings + let listener: Parameters[0] | undefined + const cleanup = vi.fn(() => { + listener = undefined + }) + mockStore.onSettingsChanged = (callback) => { + listener = callback + return cleanup + } + const session = new SshRelaySession( + 'target-settings', + getMainWindow, + mockStore, + mockPortForward + ) + await session.establish(mockConn) + const lastSources = () => + muxRequestMock.mock.calls.findLast( + ([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD + )?.[1] + expect(lastSources()).toMatchObject({ + opencodePluginSource: '', + opencode2PluginSource: expect.stringContaining('/hook/opencode2') + }) + settings.disabledTuiAgents = ['opencode2'] + listener?.({ disabledTuiAgents: settings.disabledTuiAgents }, settings) + expect(lastSources()).toMatchObject({ + opencodePluginSource: expect.stringContaining('/hook/opencode'), + opencode2PluginSource: '' + }) + settings.agentStatusHooksEnabled = false + listener?.({ agentStatusHooksEnabled: false }, settings) + expect(lastSources()).toMatchObject({ opencodePluginSource: '', opencode2PluginSource: '' }) + session.dispose() + expect(cleanup).toHaveBeenCalledOnce() + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 3892781dfab..76b7bfb238c 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -23,6 +23,7 @@ import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' import { SshGitProvider } from '../providers/ssh-git-provider' +import { selectOpenCodePluginSources } from '../agent-hooks/opencode-plugin-settings' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' import { @@ -318,6 +319,7 @@ export class SshRelaySession { private muxDisposeCleanup: (() => void) | null = null // Why: hold the notification-handler disposer so teardownProviders can release it on reconnect/shutdown (symmetric with muxDisposeCleanup). private muxNotificationCleanup: (() => void) | null = null + private pluginSettingsCleanup: (() => void) | null = null // Why: onStateChange never fires when the relay channel closes but SSH stays up; this callback lets ssh.ts drive relay-level reconnect. private _onRelayLost: ((targetId: string) => void) | null = null // Why: a version mismatch or a blocked owner admission is terminal, so it needs a separate callback @@ -1087,6 +1089,13 @@ export class SshRelaySession { return false } + this.pluginSettingsCleanup?.() + this.pluginSettingsCleanup = + this.store.onSettingsChanged?.((updates) => { + if ('disabledTuiAgents' in updates || 'agentStatusHooksEnabled' in updates) { + void this.installPluginsOnRelay(mux) + } + }) ?? null await this.installPluginsOnRelay(mux) if (shouldContinue && !shouldContinue()) { return false @@ -1539,17 +1548,28 @@ export class SshRelaySession { // Why: ship plugin/extension source from Orca so agent-event changes don't force a relay redeploy — the relay is versioned independently. Best-effort: failure only costs agent status on this host. private async installPluginsOnRelay(mux: SshChannelMultiplexer): Promise { - if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) { + if (!isRemoteAgentHooksEnabled()) { return } try { - await mux.request(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { - opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), - opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), - piExtensionSource: getPiAgentStatusExtensionSource('pi'), - ompExtensionSource: getPiAgentStatusExtensionSource('omp'), - primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') - }) + const hooksEnabled = this.areAgentStatusHooksEnabled() + await mux.request( + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + selectOpenCodePluginSources( + { + opencodePluginSource: openCodeInternals.getOpenCodePluginSource(), + opencode2PluginSource: openCodeInternals.getOpenCode2PluginSource(), + ...(hooksEnabled + ? { + piExtensionSource: getPiAgentStatusExtensionSource('pi'), + ompExtensionSource: getPiAgentStatusExtensionSource('omp'), + primeAgentExtensionSource: getPiAgentStatusExtensionSource('prime-agent') + } + : {}) + }, + this.store.getSettings?.() ?? null + ) + ) } catch (err) { // Why: -32601 = older relay without the handler; CONNECTION_LOST/DISPOSED = routine mid-flight teardown — swallow both. const code = (err as { code?: unknown })?.code @@ -1675,6 +1695,8 @@ export class SshRelaySession { this.openCodeRuntimePreparation?.controller.abort() this.openCodeRuntimePreparation = null this.releaseRelayLossWatcher() + this.pluginSettingsCleanup?.() + this.pluginSettingsCleanup = null this.muxNotificationCleanup?.() this.muxNotificationCleanup = null for (const cleanup of this.ptyRecoveryNotificationCleanups) { diff --git a/src/relay/omp-fresh-launch-environment.test.ts b/src/relay/omp-fresh-launch-environment.test.ts index 5f7cd57681f..8697368567f 100644 --- a/src/relay/omp-fresh-launch-environment.test.ts +++ b/src/relay/omp-fresh-launch-environment.test.ts @@ -91,7 +91,7 @@ it('prepares the execution host OMP config and status extension for a guarded la source.mockReturnValue(false) expect( await augment.mock.calls[1][0]({ id: 'other', shell: '/bin/bash', env: {}, command: 'codex' }) - ).toEqual({ ORCA_OPENCODE_AGENT: 'opencode' }) + ).toEqual({}) } finally { runtime.stop() dispatcher.dispose() diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts new file mode 100644 index 00000000000..336db284a47 --- /dev/null +++ b/src/relay/opencode-hook-selection.test.ts @@ -0,0 +1,188 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beginPtyHandlerTest, endPtyHandlerTest } from './pty-handler-test-harness' +import { RelayAgentHookRuntime } from './relay-agent-hook-runtime' +import type { RelayDispatcher } from './dispatcher' +import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../shared/agent-hook-relay' + +const mocks = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockCreateShellPromptReadinessProbe: 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/pty/posix-pty-process-groups', () => ({ + forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback()) +})) +vi.mock('./agent-hook-server', () => ({ + RelayAgentHookServer: class { + start = async () => {} + stop = () => {} + buildPtyEnv = () => ({ ORCA_AGENT_HOOK_PORT: '12345' }) + clearPaneState = () => {} + } +})) +let root: string +let harness: ReturnType +let runtime: RelayAgentHookRuntime +let custom: string +const plugin = (dir: string, agent: string) => join(dir, 'plugins', `orca-${agent}-status.js`) + +beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), 'orca-relay-disabled-')) + custom = join(root, 'custom') + mkdirSync(custom) + writeFileSync(join(custom, 'opencode.json'), '{"model":"fixture"}') + vi.stubEnv('HOME', root) + vi.stubEnv('XDG_CONFIG_HOME', join(root, 'xdg')) + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + 'ORCA_OPENCODE_AGENT', + 'ZDOTDIR' + ]) { + vi.stubEnv(key, undefined) + } + harness = beginPtyHandlerTest(mocks) + runtime = new RelayAgentHookRuntime( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this harness implements the request registration used by the runtime; PTY and disk paths are real. + harness.dispatcher as unknown as RelayDispatcher, + harness.handler, + join(root, 'relay.sock') + ) + await runtime.start() +}) +afterEach(async () => { + runtime.stop() + await endPtyHandlerTest(harness.handler, harness.originalPlatform) + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) +}) +async function install(v1: string, v2: string): Promise { + await harness.dispatcher.callRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, { + opencodePluginSource: v1, + opencode2PluginSource: v2 + }) +} +async function spawn(params: Record = {}): Promise> { + await harness.dispatcher.callRequest('pty.spawn', { + cwd: root, + shell: '/bin/bash', + cols: 80, + rows: 24, + command: 'echo fixture', + ...params + }) + return mocks.mockPtySpawn.mock.calls.at(-1)?.[2].env +} + +describe('relay OpenCode source selection on real fixture files', () => { + it('leaves a standalone relay without supplied sources unconfigured', async () => { + const env = await spawn() + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) + }) + it.each([ + { v1: '// v1', v2: '// v2', selected: 'opencode' }, + { v1: '', v2: '// v2', selected: 'opencode2' }, + { v1: '// v1', v2: '', selected: 'opencode' }, + { v1: '', v2: '', selected: undefined } + ])('ordinary terminal with sources $v1 / $v2', async ({ v1, v2, selected }) => { + await install(v1, v2) + const env = await spawn() + expect(env.ORCA_OPENCODE_AGENT).toBe(selected) + for (const agent of ['opencode', 'opencode2']) { + expect(existsSync(plugin(join(root, 'xdg', 'opencode'), agent))).toBe(agent === selected) + } + }) + it.each(['opencode', 'opencode2'] as const)( + 'does not substitute another plugin for disabled explicit %s', + async (agent) => { + await install(agent === 'opencode' ? '' : '// v1', agent === 'opencode2' ? '' : '// v2') + for (const params of [{ command: `${agent} --session fixture` }, { launchAgent: agent }]) { + const env = await spawn(params) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(existsSync(join(root, 'xdg', 'opencode'))).toBe(false) + } + } + ) + it('updates cached sources on one runtime and leaves old plugin files intact', async () => { + await install('// v1', '// v2') + await spawn() + const original = plugin(join(root, 'xdg', 'opencode'), 'opencode') + writeFileSync(original, '// sentinel') + await install('', '// v2') + expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode2') + expect(readFileSync(original, 'utf8')).toBe('// sentinel') + await install('// refreshed v1', '// v2') + expect((await spawn()).ORCA_OPENCODE_AGENT).toBe('opencode') + expect(readFileSync(original, 'utf8')).toBe('// refreshed v1') + }) + it('restores the real custom source when all OpenCode sources are revoked', async () => { + await install('// v1', '// v2') + const first = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + const path = plugin(first.OPENCODE_CONFIG_DIR, 'opencode') + expect(readFileSync(path, 'utf8')).toBe('// v1') + await install('', '') + const env = await spawn({ env: first }) + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + expect(env.ORCA_OPENCODE_AGENT).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(readFileSync(path, 'utf8')).toBe('// v1') + expect(env.ORCA_AGENT_HOOK_PORT).toBe('12345') + }) +}) + +it.each([true, false])( + 'preserves explicit config over inherited relay markers with hooks %s', + async (enabled) => { + await install(enabled ? '// v1' : '', '') + const stale = join(root, 'stale-source') + mkdirSync(stale) + writeFileSync(join(stale, 'opencode.json'), '{"model":"stale"}') + vi.stubEnv('ORCA_OPENCODE_CONFIG_DIR', join(root, 'old-overlay')) + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', stale) + const env = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + expect(existsSync(plugin(stale, 'opencode'))).toBe(false) + } +) + +it.each([true, false])( + 'restores legacy source-only overlay markers with hooks %s', + async (enabled) => { + await install('// v1', '') + const parent = await spawn({ env: { OPENCODE_CONFIG_DIR: custom } }) + delete parent.ORCA_OPENCODE_CONFIG_DIR + if (!enabled) { + await install('', '') + } + const env = await spawn({ env: parent }) + expect(env.OPENCODE_CONFIG_DIR).toEqual(enabled ? expect.any(String) : custom) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(enabled ? custom : undefined) + } +) diff --git a/src/relay/plugin-overlay.ts b/src/relay/plugin-overlay.ts index 1c1c5200033..001309c1f93 100644 --- a/src/relay/plugin-overlay.ts +++ b/src/relay/plugin-overlay.ts @@ -79,7 +79,7 @@ function isUsableId(id: string): boolean { return typeof id === 'string' && id.length > 0 && id.length <= 1024 } export type PluginSources = { - /** Source body of `orca-opencode-status.js` to drop into /plugins/. */ + /** Empty string revokes future installs; omission preserves the cached source. */ opencodePluginSource?: string /** Source body of OpenCode 2's status plugin. */ opencode2PluginSource?: string @@ -158,7 +158,7 @@ export class PluginOverlayManager { } } hasOpenCodeSource(agent: 'opencode' | 'opencode2' = 'opencode'): boolean { - return (agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource) !== null + return Boolean(agent === 'opencode2' ? this.opencode2PluginSource : this.opencodePluginSource) } hasPiSource(kind?: PiAgentKind): boolean { if (kind) { diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 7ab4bf01162..bc1cef97137 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -19,8 +19,9 @@ import { isPiCompatibleAgentType } from '../shared/pi-agent-kind' import { resolveSetupAgentSequenceLaunchCommand } from '../shared/setup-agent-sequencing' -import { isOpenCode2LaunchCommand } from '../shared/opencode-launch-command' +import { selectOpenCodeHookAgent } from '../shared/opencode-launch-command' import { relayLogLine } from './relay-diagnostic-log' +import { restoreOrStripOverlayEnv } from '../shared/agent-overlay-env' import { registerManagedHookInstaller } from './managed-hook-installer' export class RelayAgentHookRuntime { @@ -85,12 +86,22 @@ export class RelayAgentHookRuntime { const env: Record = {} const overlayId = context.paneKey ?? context.id const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(context.env, context.command) - const opencodeAgent = - context.launchAgent === 'opencode2' || isOpenCode2LaunchCommand(launchCommandHint) - ? 'opencode2' - : 'opencode' - env.ORCA_OPENCODE_AGENT = opencodeAgent - if (this.pluginOverlay.hasOpenCodeSource(opencodeAgent)) { + const opencodeAgent = selectOpenCodeHookAgent(context.launchAgent, launchCommandHint, (agent) => + this.pluginOverlay.hasOpenCodeSource(agent) + ) + restoreOrStripOverlayEnv( + context.env, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + {} + ) + delete context.env.ORCA_OPENCODE_AGENT + if (opencodeAgent) { + env.ORCA_OPENCODE_AGENT = opencodeAgent const sourceDir = resolveOpenCodeSourceConfigDir(context.env, context.shell) const inheritedRelayOverlay = sourceDir ? this.pluginOverlay.isRelayOverlayPath(sourceDir) diff --git a/src/shared/agent-overlay-env.ts b/src/shared/agent-overlay-env.ts new file mode 100644 index 00000000000..91e1f85b6ab --- /dev/null +++ b/src/shared/agent-overlay-env.ts @@ -0,0 +1,27 @@ +// Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. +export function restoreOrStripOverlayEnv( + baseEnv: Record, + keys: { + primary: string + overlay: string + source: string + preserveExplicitPrimary?: boolean + }, + inheritedEnv: NodeJS.ProcessEnv = process.env +): void { + const sourceValue = baseEnv[keys.source] ?? inheritedEnv[keys.source] + const overlayValue = baseEnv[keys.overlay] ?? inheritedEnv[keys.overlay] + // Source-only markers from older launches still identify an inherited overlay. + const preservePrimary = + keys.preserveExplicitPrimary && + Boolean(overlayValue) && + baseEnv[keys.primary] !== undefined && + baseEnv[keys.primary] !== overlayValue + if (sourceValue && !preservePrimary) { + baseEnv[keys.primary] = sourceValue + } else if (overlayValue && baseEnv[keys.primary] === overlayValue) { + delete baseEnv[keys.primary] + } + delete baseEnv[keys.overlay] + delete baseEnv[keys.source] +} diff --git a/src/shared/opencode-launch-command.test.ts b/src/shared/opencode-launch-command.test.ts index a422633f4bf..d9457de42e0 100644 --- a/src/shared/opencode-launch-command.test.ts +++ b/src/shared/opencode-launch-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isOpenCode2LaunchCommand } from './opencode-launch-command' +import { isOpenCode2LaunchCommand, selectOpenCodeHookAgent } from './opencode-launch-command' describe('isOpenCode2LaunchCommand', () => { it.each(['opencode2', '/usr/local/bin/opencode2', 'opencode2.exe', 'opencode2.cmd'])( @@ -13,3 +13,19 @@ describe('isOpenCode2LaunchCommand', () => { expect(isOpenCode2LaunchCommand(command)).toBe(false) }) }) + +describe('OpenCode hook selection', () => { + it.each([ + 'opencode --session fixture', + '"C:\\tools\\opencode.exe" --session fixture', + '/usr/local/bin/opencode' + ])('preserves disabled v1 identity for %s', (command) => { + expect(selectOpenCodeHookAgent(undefined, command, (agent) => agent === 'opencode2')).toBeNull() + }) + it('trusts the supplied identity for wrapped or renamed commands', () => { + expect(selectOpenCodeHookAgent('opencode', 'opencode2', () => true)).toBe('opencode') + expect( + selectOpenCodeHookAgent('opencode2', 'my-wrapper', (agent) => agent === 'opencode2') + ).toBe('opencode2') + }) +}) diff --git a/src/shared/opencode-launch-command.ts b/src/shared/opencode-launch-command.ts index 44dd3038e6b..92c4896b094 100644 --- a/src/shared/opencode-launch-command.ts +++ b/src/shared/opencode-launch-command.ts @@ -1,8 +1,31 @@ import { getCommandTokenPathBasename, getFirstCommandToken } from './command-token-scanner' +import type { TuiAgent } from './tui-agent' -export function isOpenCode2LaunchCommand(launchCommand: string | undefined): boolean { +function openCodeCommandAgent(launchCommand: string | undefined): 'opencode' | 'opencode2' | null { const binary = getCommandTokenPathBasename(getFirstCommandToken(launchCommand ?? '')) .toLowerCase() .replace(/\.(?:cmd|exe|sh)$/, '') - return binary === 'opencode2' + return binary === 'opencode' || binary === 'opencode2' ? binary : null +} + +export function isOpenCode2LaunchCommand(launchCommand: string | undefined): boolean { + return openCodeCommandAgent(launchCommand) === 'opencode2' +} + +export function selectOpenCodeHookAgent( + launchAgent: TuiAgent | undefined, + launchCommand: string | undefined, + isEnabled: (agent: 'opencode' | 'opencode2') => boolean +): 'opencode' | 'opencode2' | null { + const explicit = + launchAgent === 'opencode' || launchAgent === 'opencode2' + ? launchAgent + : launchAgent === undefined + ? openCodeCommandAgent(launchCommand) + : null + // An explicit version selects its own integration; disabling it never substitutes the other. + if (explicit) { + return isEnabled(explicit) ? explicit : null + } + return isEnabled('opencode') ? 'opencode' : isEnabled('opencode2') ? 'opencode2' : null }