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 <userData>/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 <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-28 14:55:42 -07:00
committed by GitHub
co-authored by m4air
parent b482b4d3b4
commit 813aff8f8a
14 changed files with 475 additions and 20 deletions
@@ -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<string, string>,
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 &&
@@ -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', () => ({
+2
View File
@@ -108,12 +108,14 @@ export const childProcessModuleMock = (original: Record<string, unknown>) => ({
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
}
})
+33 -8
View File
@@ -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.
@@ -365,3 +365,131 @@ it.each([true, false])(
expect(existsSync(config)).toBe(false)
}
)
// Why: pre-1.4.209 panes exported Orca's retired <userData>/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')
}
})
+5 -4
View File
@@ -170,15 +170,16 @@ export function resolveMimocodeSourceHome(baseEnv: Record<string, string>): stri
}
export function resolveOpenCodeSourceConfigDir(
baseEnv: Record<string, string>
baseEnv: Record<string, string>,
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
}
+6
View File
@@ -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;
@@ -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<string, string> = 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()
}
}
}
)
})
@@ -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)
)
+91
View File
@@ -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 <userData>/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
+27 -6
View File
@@ -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
})
@@ -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)
})
})
@@ -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<string, string | undefined> | undefined,
userDataPath: string,
inherited: Record<string, string | undefined> = process.env
): string[] {
return OPENCODE_CONFIG_DIR_ENV_KEYS.filter((key) =>
isOpenCodeLegacySharedConfigDir(env?.[key] ?? inherited[key], userDataPath)
)
}
+10 -1
View File
@@ -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', () => ({