From 813aff8f8a8b265e826be573a40eb8ee635f8f23 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 28 Sep 2026 14:55:42 -0700 Subject: [PATCH] fix(opencode): stop OpenCode 2 loading a stale plugin from the retired shared hooks dir (#23500) * fix(opencode): stop OpenCode 2 loading a stale plugin from the retired shared hooks dir Before 1.4.209 Orca pointed OPENCODE_CONFIG_DIR at /opencode-hooks/shared and wrote a server()-only status plugin there. 1.4.209 moved the plugin to OpenCode's global config dir and 1.4.210 added the v2 setup() export, but nothing rewrote the old file. Shells, daemon-persisted panes and OpenCode 2 background services that still carry that OPENCODE_CONFIG_DIR load only that dir under OpenCode 2 (it replaces the global dir), so the v2 loader rejects the stale plugin with "Plugin must export a default definition with an id and an effect or setup function" and pane status dies. - Refresh the plugin in the retired shared dir (only when it already exists and its content differs) so OpenCode processes started later from old shells load the dual v1/v2 export. Runs on OpenCode pane spawns and on any spawn that inherits the retired dir, even with agent status hooks off. - Drop an inherited OPENCODE_CONFIG_DIR / ORCA_OPENCODE_* marker that points at the retired dir when building a new pane env, so new panes use global discovery. Limitation: an OpenCode 2 background service already running from an old pane keeps its cached copy of the stale module even after the file is rewritten (verified with opencode2 v2.0.18). It must be restarted (`opencode service restart`); a restart from a new Orca pane then picks up the global config because the env is stripped. * fix(opencode): harden legacy plugin repair and inherited config cleanup * fix(opencode): preserve daemon-owned user config during legacy cleanup * fix(opencode): sanitize inherited sources and repair unseen legacy copies * test(opencode): update shared PTY mocks for legacy repair * test(opencode): annotate shared repair mock signature --------- Co-authored-by: m4air --- .../pty-subprocess/spawn-environment.ts | 7 + .../pty-controller-process-inventory.test.ts | 11 +- src/main/ipc/pty-ipc-mock-registry.ts | 2 + src/main/ipc/pty/host-env/assembly.ts | 41 ++++-- .../opencode-hook-installation.test.ts | 128 ++++++++++++++++++ src/main/ipc/pty/host-env/pi-agent.ts | 9 +- src/main/ipc/pty/ipc/spawn-options.ts | 6 + .../opencode-legacy-spawn-deletions.test.ts | 64 +++++++++ src/main/ipc/pty/runtime/spawn-options.ts | 6 + src/main/opencode/hook-service.test.ts | 91 +++++++++++++ src/main/opencode/hook-service.ts | 33 ++++- .../opencode/legacy-shared-config-dir.test.ts | 38 ++++++ src/main/opencode/legacy-shared-config-dir.ts | 48 +++++++ src/main/providers/provider-dispatch.test.ts | 11 +- 14 files changed, 475 insertions(+), 20 deletions(-) create mode 100644 src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts create mode 100644 src/main/opencode/legacy-shared-config-dir.test.ts create mode 100644 src/main/opencode/legacy-shared-config-dir.ts diff --git a/src/main/daemon/pty-subprocess/spawn-environment.ts b/src/main/daemon/pty-subprocess/spawn-environment.ts index c8316bb383a..a063ffb3dd6 100644 --- a/src/main/daemon/pty-subprocess/spawn-environment.ts +++ b/src/main/daemon/pty-subprocess/spawn-environment.ts @@ -1,3 +1,4 @@ +import { getLegacyOpenCodeEnvKeysToDelete } from '../../opencode/legacy-shared-config-dir' import { restoreOrStripOverlayEnv } from '../../../shared/agent-overlay-env' import { delimiter } from 'node:path' import { dropInheritedOrcaFishHistory } from '../../fish-history-session' @@ -54,6 +55,12 @@ function deleteRequestedDaemonEnvKeys( env: Record, keys: readonly string[] | undefined ): void { + const userDataPath = process.env.ORCA_USER_DATA_PATH + if (userDataPath) { + for (const key of getLegacyOpenCodeEnvKeysToDelete(env, userDataPath, {})) { + delete env[key] + } + } // Why: persistent daemon state can differ from Electron; delete CODEX_HOME only when its Orca overlay owns it. const deleteOrcaOwnedCodexHome = keys?.includes('ORCA_CODEX_HOME') === true && diff --git a/src/main/ipc/pty-controller-process-inventory.test.ts b/src/main/ipc/pty-controller-process-inventory.test.ts index e89d888978a..004735c61cb 100644 --- a/src/main/ipc/pty-controller-process-inventory.test.ts +++ b/src/main/ipc/pty-controller-process-inventory.test.ts @@ -47,7 +47,16 @@ vi.mock('node-pty', () => ({ })) vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } + openCodeHookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + }, + openCode2HookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + } })) vi.mock('../pi/titlebar-extension-service', () => ({ diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 71b77621b75..f49978843d5 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -108,12 +108,14 @@ export const childProcessModuleMock = (original: Record) => ({ export const openCodeHookServiceModuleMock = () => ({ openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, + refreshLegacySharedPlugin: vi.fn<() => void>(), clearPty: openCodeClearPtyMock }, // Separate mock per variant: assembly.ts picks the service by variant, and a shared // mock would hide a regression that hands an OpenCode 2 pane the v1 plugin. openCode2HookService: { buildPtyEnv: openCode2BuildPtyEnvMock, + refreshLegacySharedPlugin: vi.fn<() => void>(), clearPty: openCodeClearPtyMock } }) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index b6690b1cb83..de61800c7ef 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -6,6 +6,10 @@ import { } from '../../../../shared/pi-agent-kind' import { applyTerminalGitCredentialPromptGuard } from '../../terminal-git-credential-guard' import { openCode2HookService, openCodeHookService } from '../../../opencode/hook-service' +import { + OPENCODE_CONFIG_DIR_ENV_KEYS, + isOpenCodeLegacySharedConfigDir +} from '../../../opencode/legacy-shared-config-dir' import { mimoCodeHookService } from '../../../mimo/hook-service' import { agentHookServer } from '../../../agent-hooks/server' import { wslHookRelayManager } from '../../../agent-hooks/wsl-hook-relay-manager' @@ -45,8 +49,25 @@ export function buildPtyHostEnv( mergePersistedWindowsPath(baseEnv) Object.assign(baseEnv, buildConfiguredProxyEnv(opts.networkProxySettings)) - // 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) + // Why: pre-1.4.209 panes exported Orca's retired shared hooks dir; inheriting it hides the user's global OpenCode config. + const isLegacyOpenCodeHooksDir = (dir: string | undefined): boolean => + isOpenCodeLegacySharedConfigDir(dir, opts.userDataPath) + const inheritedOpenCodeEnv: NodeJS.ProcessEnv = {} + for (const key of OPENCODE_CONFIG_DIR_ENV_KEYS) { + if (isLegacyOpenCodeHooksDir(baseEnv[key])) { + delete baseEnv[key] + } + if (!isLegacyOpenCodeHooksDir(process.env[key])) { + inheritedOpenCodeEnv[key] = process.env[key] + } + } + // A daemon or sibling shell can retain a retired path that main no longer sees. + openCodeHookService.refreshLegacySharedPlugin() + openCode2HookService.refreshLegacySharedPlugin() + const resolvedOpenCodeConfigDir = resolveOpenCodeSourceConfigDir(baseEnv, inheritedOpenCodeEnv) + const preexistingOpenCodeConfigDir = isLegacyOpenCodeHooksDir(resolvedOpenCodeConfigDir) + ? undefined + : resolvedOpenCodeConfigDir const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(baseEnv, opts.launchCommand) const openCodeAgent = selectOpenCodeHookAgent( opts.launchAgent, @@ -87,12 +108,16 @@ export function buildPtyHostEnv( ? resolvePiAgentSourceDir(baseEnv, 'prime-agent') : resolveScopedPiAgentSourceDir(baseEnv, 'prime-agent') - restoreOrStripOverlayEnv(baseEnv, { - primary: 'OPENCODE_CONFIG_DIR', - overlay: 'ORCA_OPENCODE_CONFIG_DIR', - source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', - preserveExplicitPrimary: true - }) + restoreOrStripOverlayEnv( + baseEnv, + { + primary: 'OPENCODE_CONFIG_DIR', + overlay: 'ORCA_OPENCODE_CONFIG_DIR', + source: 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + preserveExplicitPrimary: true + }, + inheritedOpenCodeEnv + ) 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. 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 index eb6b1faacd7..8a9114eb942 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -365,3 +365,131 @@ it.each([true, false])( expect(existsSync(config)).toBe(false) } ) + +// Why: pre-1.4.209 panes exported Orca's retired /opencode-hooks/shared dir. OpenCode 2 +// treats OPENCODE_CONFIG_DIR as the only config dir, so inheriting it loaded a stale plugin and hid +// the user's global config. +describe.each([ + { name: 'marked', marked: true }, + { name: 'unmarked', marked: false } +])('inherited retired shared hooks dir ($name)', ({ marked }) => { + it.each([ + { agent: 'opencode', hooksDir: 'opencode-hooks' }, + { agent: 'opencode', hooksDir: 'opencode2-hooks' }, + { agent: 'opencode2', hooksDir: 'opencode-hooks' }, + { agent: 'opencode2', hooksDir: 'opencode2-hooks' } + ] as const)('drops $hooksDir for $agent panes', ({ agent, hooksDir }) => { + const legacy = join(fixture.userData, hooksDir, 'shared') + mkdirSync(join(legacy, 'plugins'), { recursive: true }) + const env = buildPtyHostEnv( + 'pane', + marked + ? { OPENCODE_CONFIG_DIR: legacy, ORCA_OPENCODE_CONFIG_DIR: legacy } + : { OPENCODE_CONFIG_DIR: legacy }, + { ...options, launchAgent: agent } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined() + expect(existsSync(plugin(config, agent))).toBe(true) + expect(existsSync(join(fixture.userData, `${agent}-config-overlays`))).toBe(false) + }) +}) + +it('keeps a user config dir that merely sits beside the retired hooks dir', () => { + const neighbour = join(fixture.userData, 'opencode-hooks', 'mine') + mkdirSync(neighbour, { recursive: true }) + const env = buildPtyHostEnv('pane', { OPENCODE_CONFIG_DIR: neighbour }, options) + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(neighbour) + expect(env.OPENCODE_CONFIG_DIR).not.toBeUndefined() +}) + +it.each(['explicit', 'inherited'])('refreshes the %s stale plugin with hooks off', (source) => { + const legacy = join(fixture.userData, 'opencode-hooks', 'shared') + const stalePlugin = join(legacy, 'plugins', 'orca-opencode-status.js') + mkdirSync(join(legacy, 'plugins'), { recursive: true }) + writeFileSync(stalePlugin, 'export default { id: "orca-opencode-status", server() {} }\n') + if (source === 'inherited') { + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + } + const env = buildPtyHostEnv( + 'pane', + source === 'explicit' ? { OPENCODE_CONFIG_DIR: legacy } : {}, + { ...options, agentStatusHooksEnabled: false } + ) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() + expect(readFileSync(stalePlugin, 'utf8')).toContain('setup') +}) + +it.each([true, false])('strips daemon-inherited retired paths (known to main: %s)', (known) => { + const legacy = join(fixture.userData, 'opencode-hooks', 'shared') + if (known) { + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + } + const env = buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + vi.stubEnv('ORCA_USER_DATA_PATH', fixture.userData) + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + const request = { sessionId: 'pane', cols: 80, rows: 24, cwd: root, env } + const result = createDaemonPtyEnvironment(request) + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() + result.OPENCODE_CONFIG_DIR = legacy + rescrubDaemonPtyEnvironment(result, request) + expect(result.OPENCODE_CONFIG_DIR).toBeUndefined() +}) + +it('preserves explicit user config over a retired daemon-inherited path', () => { + vi.stubEnv('ORCA_USER_DATA_PATH', fixture.userData) + vi.stubEnv('OPENCODE_CONFIG_DIR', join(fixture.userData, 'opencode-hooks', 'shared')) + const env = { OPENCODE_CONFIG_DIR: custom } + const result = createDaemonPtyEnvironment({ + sessionId: 'pane', + cols: 80, + rows: 24, + cwd: root, + env + }) + expect(result.OPENCODE_CONFIG_DIR).toBe(custom) +}) + +it('does not restore a retired source from process.env with hooks disabled', () => { + vi.stubEnv('ORCA_OPENCODE_SOURCE_CONFIG_DIR', join(fixture.userData, 'opencode-hooks', 'shared')) + const env = buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() +}) + +it.each([true, false])( + 'preserves explicit config with a retired parent source (hooks: %s)', + (enabled) => { + vi.stubEnv( + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR', + join(fixture.userData, 'opencode-hooks', 'shared') + ) + const env = buildPtyHostEnv( + 'pane', + { OPENCODE_CONFIG_DIR: custom }, + { ...options, agentStatusHooksEnabled: enabled } + ) + if (enabled) { + expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe(custom) + expect(readFileSync(join(env.OPENCODE_CONFIG_DIR, 'opencode.json'), 'utf8')).toBe( + '{"model":"fixture"}' + ) + } else { + expect(env.OPENCODE_CONFIG_DIR).toBe(custom) + } + } +) + +it('repairs both legacy variants without main inheriting any retired path or enabling hooks', () => { + for (const agent of ['opencode', 'opencode2']) { + const path = plugin(join(fixture.userData, `${agent}-hooks`, 'shared'), agent) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, '// old plugin') + } + buildPtyHostEnv('pane', {}, { ...options, agentStatusHooksEnabled: false }) + for (const agent of ['opencode', 'opencode2']) { + expect( + readFileSync(plugin(join(fixture.userData, `${agent}-hooks`, 'shared'), agent), 'utf8') + ).toContain('setup') + } +}) diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 3c33c499c89..05258f0bdc6 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -170,15 +170,16 @@ export function resolveMimocodeSourceHome(baseEnv: Record): stri } export function resolveOpenCodeSourceConfigDir( - baseEnv: Record + baseEnv: Record, + inheritedEnv: NodeJS.ProcessEnv = process.env ): 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 + const configDir = baseEnv.OPENCODE_CONFIG_DIR ?? inheritedEnv.OPENCODE_CONFIG_DIR + const orcaConfigDir = baseEnv.ORCA_OPENCODE_CONFIG_DIR ?? inheritedEnv.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 + baseEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR ?? inheritedEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR if (sourceDir) { return sourceDir } diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index a2adf48771b..42d2aedf008 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,3 +1,5 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import { isTuiAgent } from '../../../../shared/tui-agent-config' import { CLAUDE_AUTH_ENV_VARS } from '../../../claude-accounts/environment' import { LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS } from '../../../pty/legacy-terminal-shim-dir' @@ -43,6 +45,10 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], + // The daemon must judge its own inherited value; main may have a different config. + !args.connectionId && !ctx.isDaemonHostSpawn + ? getLegacyOpenCodeEnvKeysToDelete(ctx.spawnEnv, getAppEnvironment().getPath('userData')) + : [], getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; diff --git a/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts b/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts new file mode 100644 index 00000000000..1c7203ab046 --- /dev/null +++ b/src/main/ipc/pty/opencode-legacy-spawn-deletions.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { createDaemonPtyEnvironment } from '../../daemon/pty-subprocess/spawn-environment' +import { getAppEnvironment } from '../../../shared/app-environment' +import { buildPtyIpcSpawnOptions } from './ipc/spawn-options' +import { createPtyIpcSpawnState } from './ipc/spawn-state' +import type { PtySpawnIpcDeps } from './ipc/spawn-types' +import { buildRuntimePtySpawnOptions } from './runtime/spawn-options' +import { createRuntimePtySpawnState } from './runtime/spawn-state' +import type { PtyRuntimeControllerDeps } from './runtime/controller-deps' + +afterEach(() => vi.unstubAllEnvs()) + +describe.each(['renderer', 'runtime'])('%s retired OpenCode environment deletion', (route) => { + it.each([ + { connectionId: undefined, daemon: false, explicit: undefined, deleted: true }, + { connectionId: undefined, daemon: true, explicit: undefined, deleted: false }, + { connectionId: undefined, daemon: true, explicit: '/user/config', deleted: false }, + { connectionId: 'ssh-host', daemon: false, explicit: undefined, deleted: false } + ])( + 'respects explicit=$explicit, daemon=$daemon, and connection=$connectionId', + async ({ connectionId, daemon, explicit, deleted }) => { + const legacy = join(getAppEnvironment().getPath('userData'), 'opencode-hooks', 'shared') + vi.stubEnv('OPENCODE_CONFIG_DIR', legacy) + const args = { cols: 80, rows: 24, connectionId } + const env: Record = explicit ? { OPENCODE_CONFIG_DIR: explicit } : {} + let deletions: string[] | undefined + if (route === 'renderer') { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this option-only path reads no required dependency methods with no worktree or hidden pane. + const ctx = createPtyIpcSpawnState({} as PtySpawnIpcDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = daemon + await buildPtyIpcSpawnOptions(ctx) + deletions = ctx.spawnOptions.envToDelete + ctx.finishTerminalInstall() + } else { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this option-only path reads no required dependency methods with no worktree or hidden pane. + const ctx = createRuntimePtySpawnState({} as PtyRuntimeControllerDeps, args) + ctx.env = env + ctx.isDaemonHostSpawn = daemon + await buildRuntimePtySpawnOptions(ctx) + deletions = ctx.spawnOptions.envToDelete + ctx.finishTerminalInstall() + } + expect(deletions?.includes('OPENCODE_CONFIG_DIR') ?? false).toBe(deleted) + expect(env.OPENCODE_CONFIG_DIR).toBe(explicit) + if (daemon) { + vi.stubEnv('ORCA_USER_DATA_PATH', getAppEnvironment().getPath('userData')) + for (const inherited of [legacy, '/daemon/user-config']) { + vi.stubEnv('OPENCODE_CONFIG_DIR', inherited) + const request = { sessionId: 'pane', cols: 80, rows: 24, env, envToDelete: deletions } + const result = createDaemonPtyEnvironment(request) + expect(result.OPENCODE_CONFIG_DIR).toBe( + explicit ?? (inherited === legacy ? undefined : inherited) + ) + expect( + createDaemonPtyEnvironment({ ...request, envToDelete: ['OPENCODE_CONFIG_DIR'] }) + .OPENCODE_CONFIG_DIR + ).toBeUndefined() + } + } + } + ) +}) diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 67c633560e9..c544b9ac7c9 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -1,3 +1,5 @@ +import { getAppEnvironment } from '../../../../shared/app-environment' +import { getLegacyOpenCodeEnvKeysToDelete } from '../../../opencode/legacy-shared-config-dir' import type { IPtyProvider, PtySpawnResult } from '../../../providers/types' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' @@ -72,6 +74,10 @@ export async function buildRuntimePtySpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], + // The daemon must judge its own inherited value; main may have a different config. + !args.connectionId && !ctx.isDaemonHostSpawn + ? getLegacyOpenCodeEnvKeysToDelete(ctx.env, getAppEnvironment().getPath('userData')) + : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) ) diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 9b0db3b0621..66fb3c86a47 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -1,13 +1,17 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, + openSync, + closeSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + statSync, symlinkSync, + utimesSync, writeFileSync } from 'node:fs' import { createHash } from 'node:crypto' @@ -313,6 +317,93 @@ describe('OpenCodeHookService buildPtyEnv / clearPty round-trip', () => { expect(module.default?.setup).toBeTypeOf('function') }) + // Why: pre-1.4.209 Orca wrote a server()-only plugin into /opencode-hooks/shared and + // stopped maintaining it; shells and OpenCode 2 background services still pointing there got + // "Plugin must export a default definition with an id and an effect or setup function." + it('refreshes a stale plugin left in the retired shared hooks dir', async () => { + const legacyPluginPath = join( + userDataDir, + 'opencode-hooks', + 'shared', + 'plugins', + 'orca-opencode-status.js' + ) + mkdirSync(join(legacyPluginPath, '..'), { recursive: true }) + writeFileSync( + legacyPluginPath, + 'export default { id: "orca-opencode-status", server: async () => ({}) };\n' + ) + + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + + const modulePath = join(userDataDir, `legacy-opencode-plugin-${Date.now()}.mjs`) + writeFileSync(modulePath, readFileSync(legacyPluginPath, 'utf8')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertions below validate the shape this names. + const module = (await import(pathToFileURL(modulePath).href)) as { + default?: { id?: unknown; server?: unknown; setup?: unknown } + } + expect(module.default?.id).toBe('orca-opencode-status') + expect(module.default?.server).toBeTypeOf('function') + expect(module.default?.setup).toBeTypeOf('function') + }) + + it('repairs late and overwritten legacy plugins atomically on the same service', () => { + const service = new OpenCodeHookService() + service.refreshLegacySharedPlugin() + const path = join(userDataDir, 'opencode-hooks', 'shared', 'plugins', 'orca-opencode-status.js') + mkdirSync(join(path, '..'), { recursive: true }) + for (const stale of ['// late old install', '// old process overwrote repair']) { + writeFileSync(path, stale) + const reader = openSync(path, 'r') + try { + service.refreshLegacySharedPlugin() + expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) + expect(readFileSync(reader, 'utf8')).toBe(stale) + expect(readdirSync(join(path, '..'))).toEqual(['orca-opencode-status.js']) + } finally { + closeSync(reader) + } + } + }) + + it('reports repair failures and retries after the obstruction is removed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const path = join(userDataDir, 'opencode-hooks', 'shared', 'plugins', 'orca-opencode-status.js') + const service = new OpenCodeHookService() + try { + service.refreshLegacySharedPlugin() + expect(warn).not.toHaveBeenCalled() + mkdirSync(path, { recursive: true }) + service.refreshLegacySharedPlugin() + expect(warn).toHaveBeenCalledWith( + '[OpenCode] Failed to repair legacy status plugin:', + path, + expect.any(Error) + ) + rmSync(path, { recursive: true }) + writeFileSync(path, '// stale') + service.refreshLegacySharedPlugin() + expect(readFileSync(path, 'utf8')).toBe(getOpenCodePluginSource()) + } finally { + warn.mockRestore() + } + }) + + it('leaves an up-to-date legacy plugin untouched and never creates the retired dir', () => { + const legacyDir = join(userDataDir, 'opencode-hooks') + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + expect(existsSync(legacyDir)).toBe(false) + + const legacyPluginPath = join(legacyDir, 'shared', 'plugins', 'orca-opencode-status.js') + mkdirSync(join(legacyPluginPath, '..'), { recursive: true }) + writeFileSync(legacyPluginPath, getOpenCodePluginSource()) + const past = new Date('2020-01-01T00:00:00Z') + utimesSync(legacyPluginPath, past, past) + new OpenCodeHookService().buildPtyEnv(daemonSessionId) + // Why: a running OpenCode 2 service re-runs its plugin load on every write to a watched plugin file. + expect(statSync(legacyPluginPath).mtimeMs).toBe(past.getTime()) + }) + // Why: #22506 — both variants install side by side in one global plugins dir, and // OpenCode 2 kills every plugin after the first that reuses an id ("Duplicate plugin // ID"). Discovery sorts by path, so orca-opencode-status.js always wins and the diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index f46667ed1b8..e7ec0cc8b2f 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -1,3 +1,4 @@ +import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getAppEnvironment } from '../../shared/app-environment' import { join } from 'node:path' import { @@ -22,11 +23,14 @@ import { getStatusPluginOwnershipSource } from './status-plugin-ownership-source import { getStatusPluginLifecycleSource } from './status-plugin-lifecycle-source' import { getStatusPluginFactorySource } from './status-plugin-factory-source' import { resolveOpenCodeConfigDirectory } from '../../shared/opencode-config-directory' +import { + getOpenCodeLegacySharedConfigDir, + OPENCODE2_LEGACY_HOOKS_DIR, + OPENCODE_LEGACY_HOOKS_DIR +} from './legacy-shared-config-dir' const ORCA_OPENCODE_PLUGIN_FILE = 'orca-opencode-status.js' -const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' const OPENCODE_OVERLAY_DIR = 'opencode-config-overlays' -const OPENCODE_SHARED_CONFIG_DIR = 'shared' const OPENCODE_OVERLAY_MANIFEST_FILE = '.orca-opencode-overlay-manifest.json' type OpenCodeOverlayManifest = { @@ -127,6 +131,7 @@ export class OpenCodeHookService { return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {} } + this.refreshLegacySharedPlugin() const managedConfigDir = this.getSharedConfigDir() if (!existingConfigDir || existingConfigDir === managedConfigDir) { try { @@ -150,6 +155,23 @@ export class OpenCodeHookService { } } + // Why: pre-1.4.209 Orca left a server()-only plugin here that OpenCode 2 rejects. Only helps + // processes that load it later; a running OpenCode 2 service keeps its cached module until restarted. + refreshLegacySharedPlugin(): void { + const pluginPath = join(this.getSharedConfigDir(), 'plugins', this.pluginFileName) + try { + const source = this.pluginSource() + if (readFileSync(pluginPath, 'utf8') !== source) { + writeFileAtomically(pluginPath, source) + } + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return + } + console.warn('[OpenCode] Failed to repair legacy status plugin:', pluginPath, error) + } + } + private getOverlayRoot(): string { return join(getAppEnvironment().getPath('userData'), this.overlayDir) } @@ -159,10 +181,9 @@ export class OpenCodeHookService { } private getSharedConfigDir(): string { - return join( + return getOpenCodeLegacySharedConfigDir( getAppEnvironment().getPath('userData'), - this.legacyHooksDir, - OPENCODE_SHARED_CONFIG_DIR + this.legacyHooksDir ) } @@ -275,7 +296,7 @@ export class OpenCodeHookService { export const openCodeHookService = new OpenCodeHookService() export const openCode2HookService = new OpenCodeHookService({ pluginFileName: 'orca-opencode2-status.js', - legacyHooksDir: 'opencode2-hooks', + legacyHooksDir: OPENCODE2_LEGACY_HOOKS_DIR, overlayDir: 'opencode2-config-overlays', pluginSource: getOpenCode2PluginSource }) diff --git a/src/main/opencode/legacy-shared-config-dir.test.ts b/src/main/opencode/legacy-shared-config-dir.test.ts new file mode 100644 index 00000000000..f4a20713e8b --- /dev/null +++ b/src/main/opencode/legacy-shared-config-dir.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import { isOpenCodeLegacySharedConfigDir } from './legacy-shared-config-dir' + +describe('retired config directory recognition', () => { + const root = join(process.cwd(), 'user-data') + it.each(['opencode-hooks', 'opencode2-hooks'])('normalizes %s paths', (hooks) => { + const path = join(root, hooks, 'shared') + expect(isOpenCodeLegacySharedConfigDir(`${path}/`, root)).toBe(true) + expect(isOpenCodeLegacySharedConfigDir(`${path}/../shared`, root)).toBe(true) + expect(isOpenCodeLegacySharedConfigDir(`${path}/../mine`, root)).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(`${path}-custom`, root)).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(undefined, root)).toBe(false) + }) + it.skipIf(process.platform !== 'win32')('handles Windows casing and separators', () => { + expect(isOpenCodeLegacySharedConfigDir('C:/ORCA/opencode-hooks/shared/', 'c:\\orca')).toBe(true) + }) +}) + +describe('isOpenCodeLegacySharedConfigDir', () => { + const userData = join('fixture', 'user-data') + + it('matches only the retired shared dirs of both OpenCode variants', () => { + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode-hooks', 'shared'), userData) + ).toBe(true) + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode2-hooks', 'shared'), userData) + ).toBe(true) + expect( + isOpenCodeLegacySharedConfigDir(join(userData, 'opencode-hooks', 'mine'), userData) + ).toBe(false) + expect( + isOpenCodeLegacySharedConfigDir(join('other', 'opencode-hooks', 'shared'), userData) + ).toBe(false) + expect(isOpenCodeLegacySharedConfigDir(undefined, userData)).toBe(false) + }) +}) diff --git a/src/main/opencode/legacy-shared-config-dir.ts b/src/main/opencode/legacy-shared-config-dir.ts new file mode 100644 index 00000000000..8b0c2b842e4 --- /dev/null +++ b/src/main/opencode/legacy-shared-config-dir.ts @@ -0,0 +1,48 @@ +import { join, resolve } from 'node:path' + +export const OPENCODE_LEGACY_HOOKS_DIR = 'opencode-hooks' +export const OPENCODE2_LEGACY_HOOKS_DIR = 'opencode2-hooks' + +// Why: before 1.4.209 Orca pointed OPENCODE_CONFIG_DIR at this dir; shells and OpenCode 2 background services from then can still load it. +export function getOpenCodeLegacySharedConfigDir( + userDataPath: string, + legacyHooksDir: string +): string { + return join(userDataPath, legacyHooksDir, 'shared') +} + +export function isOpenCodeLegacySharedConfigDir( + configDir: string | undefined, + userDataPath: string +): boolean { + return ( + configDir !== undefined && + [OPENCODE_LEGACY_HOOKS_DIR, OPENCODE2_LEGACY_HOOKS_DIR].some( + (hooksDir) => + normalizeConfigPath(configDir) === + normalizeConfigPath(getOpenCodeLegacySharedConfigDir(userDataPath, hooksDir)) + ) + ) +} + +function normalizeConfigPath(path: string): string { + const resolved = resolve(path) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +export const OPENCODE_CONFIG_DIR_ENV_KEYS = [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_SOURCE_CONFIG_DIR' +] as const + +/** Carries retired inherited paths through providers that merge their environment later. */ +export function getLegacyOpenCodeEnvKeysToDelete( + env: Record | undefined, + userDataPath: string, + inherited: Record = process.env +): string[] { + return OPENCODE_CONFIG_DIR_ENV_KEYS.filter((key) => + isOpenCodeLegacySharedConfigDir(env?.[key] ?? inherited[key], userDataPath) + ) +} diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index c6f00c98354..c74358b89b0 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -49,7 +49,16 @@ vi.mock('node-pty', () => ({ })) vi.mock('../opencode/hook-service', () => ({ - openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } + openCodeHookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + }, + openCode2HookService: { + buildPtyEnv: () => ({}), + refreshLegacySharedPlugin: vi.fn(), + clearPty: vi.fn() + } })) vi.mock('../pi/titlebar-extension-service', () => ({