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
+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)
)
}