mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +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,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', () => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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', () => ({
|
||||
|
||||
Reference in New Issue
Block a user