mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user