Install OpenCode hooks in the terminal's config directory (#25296)

* test: reproduce OpenCode plugin installation in the wrong config root

* fix: install OpenCode hooks in the execution config directory

* test: isolate config installation from CLI version probing

* style: format consumer config installation controls

* fix: use checked startup environment and supported relay shell context

* fix: install OpenCode hooks using the selected execution shell

* test(opencode): assert consumer config root in PTY fixtures

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Orca <orca@stably.ai>
This commit is contained in:
Neil
2026-10-04 15:20:07 -07:00
committed by GitHub
co-authored by Codex Orca
parent f371532707
commit 8ff6ec9fb1
13 changed files with 232 additions and 23 deletions
@@ -7,7 +7,8 @@ import {
import {
expectedOmpStatusExtension,
TEST_CODEX_HOME,
TEST_CODEX_AUTH_JSON
TEST_CODEX_AUTH_JSON,
TEST_MANAGED_ROOT
} from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { createDaemonActiveProviderFixtures } from './pty-ipc-daemon-provider-fixtures'
@@ -15,6 +16,8 @@ import { join } from 'node:path'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
import { registerPtyHandlers, resolveCodexHomeAfterManagedAuthReadiness } from './pty'
const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config')
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
@@ -435,11 +438,15 @@ describe('registerPtyHandlers', () => {
expect(env.ORCA_OPENCODE_HOOK_PORT).toBe('4567')
})
it('mirrors a user-provided OPENCODE_CONFIG_DIR into a source-scoped overlay on the daemon path', async () => {
const env = await daemonSpawnAndGetEnv({ OPENCODE_CONFIG_DIR: '/user/custom/opencode' })
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/user/custom/opencode',
XDG_CONFIG_HOME: consumerConfigHome
})
// Why: OpenCode loads config from a single dir, so the user's path is mirrored into a source-scoped overlay, not passed through.
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
'/user/custom/opencode',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -448,11 +455,13 @@ describe('registerPtyHandlers', () => {
it('uses source OpenCode config env instead of remirroring a parent overlay', async () => {
const env = await daemonSpawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode'
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/user/custom/opencode',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/user/custom/opencode'
'/user/custom/opencode',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -1,3 +1,4 @@
import { join } from 'node:path'
import { withFreshOmpLaunch } from '../../shared/omp-fresh-launch'
import { describe, expect, it, vi } from 'vitest'
import {
@@ -7,10 +8,12 @@ import {
mimoCodeBuildPtyEnvMock,
piBuildPtyEnvMock
} from './pty-ipc-mock-registry'
import { posixOnlyIt } from './pty-ipc-test-constants'
import { posixOnlyIt, TEST_MANAGED_ROOT } from './pty-ipc-test-constants'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
const consumerConfigHome = join(TEST_MANAGED_ROOT, 'opencode-consumer-config')
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
@@ -99,11 +102,13 @@ describe('registerPtyHandlers', () => {
it('mirrors the original OpenCode source dir when launched from an Orca overlay shell', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config'
ORCA_OPENCODE_SOURCE_CONFIG_DIR: '/tmp/user-opencode-config',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/tmp/user-opencode-config'
'/tmp/user-opencode-config',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
@@ -112,10 +117,15 @@ describe('registerPtyHandlers', () => {
it('does not treat inherited Orca OpenCode config as user config without a source dir', async () => {
const env = await spawnAndGetEnv({
OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay'
ORCA_OPENCODE_CONFIG_DIR: '/tmp/parent-orca-opencode-overlay',
XDG_CONFIG_HOME: consumerConfigHome
})
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(expect.any(String), undefined)
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
undefined,
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
expect(env.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBeUndefined()
@@ -222,16 +232,20 @@ describe('registerPtyHandlers', () => {
return ''
})
const env = await spawnAndGetEnv(undefined, {
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
})
const env = await spawnAndGetEnv(
{ XDG_CONFIG_HOME: consumerConfigHome },
{
HOME: '/home/pim',
SHELL: '/bin/zsh',
OPENCODE_CONFIG_DIR: undefined,
ORCA_OPENCODE_SOURCE_CONFIG_DIR: undefined
}
)
expect(openCodeBuildPtyEnvMock).toHaveBeenCalledWith(
expect.any(String),
'/home/pim/company/opencode-config'
'/home/pim/company/opencode-config',
join(consumerConfigHome, 'opencode')
)
expect(env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-overlay')
+10 -2
View File
@@ -6,6 +6,8 @@ import { openCode2HookService, openCodeHookService } from '../../../opencode/hoo
import { resolveOpenCodeSourceConfigDir, restoreOrStripOverlayEnv } from './pi-agent'
import { selectOpenCodeHookAgent } from '../../../../shared/opencode-launch-command'
import { isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import { resolveOpenCodeConfigDirectory } from '../../../../shared/opencode-config-directory'
import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env'
import type { BuildPtyHostEnvOptions } from './types'
type OpenCodeSourceConfig = {
@@ -41,7 +43,7 @@ export function applyOpenCodeStatusPluginEnv(
config: OpenCodeSourceConfig,
options: Pick<
BuildPtyHostEnvOptions,
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl'
'launchAgent' | 'agentStatusHooksEnabled' | 'disabledTuiAgents' | 'isWsl' | 'shellPath'
>,
command: string | undefined
): 'opencode' | 'opencode2' | null {
@@ -69,7 +71,13 @@ export function applyOpenCodeStatusPluginEnv(
env.ORCA_OPENCODE_AGENT = agent
// WSL owns its config writes; only the guest overlay may enter a WSL pane.
if (!options.isWsl) {
Object.assign(env, service.buildPtyEnv(id, config.directory))
const executionEnv = { ...process.env, ...env }
const shellConfigHome = readSessionShellStartupEnvVar('XDG_CONFIG_HOME', env, options.shellPath)
const defaultConfigDir = resolveOpenCodeConfigDirectory({
...executionEnv,
XDG_CONFIG_HOME: shellConfigHome ?? executionEnv.XDG_CONFIG_HOME
})
Object.assign(env, service.buildPtyEnv(id, config.directory, defaultConfigDir))
}
if (env.OPENCODE_CONFIG_DIR) {
// Shell startup can re-export the default; preserve this pane's overlay and original source.
@@ -77,6 +77,85 @@ afterEach(() => {
})
describe('OpenCode installation uses the current enabled agents', () => {
it.each(
process.platform === 'win32' ? (['home', 'xdg'] as const) : (['home', 'xdg', 'shell'] as const)
)(
'consumer config root follows the %s execution environment without redirecting JSON',
(kind) => {
const home = join(root, 'consumer-home')
const xdg = join(root, 'consumer-xdg')
const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode')
mkdirSync(home, { recursive: true })
mkdirSync(consumer, { recursive: true })
const settings = '{"model":"consumer-model","description":"external-settings"}'
writeFileSync(join(consumer, 'opencode.json'), settings)
if (kind === 'shell') {
writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`)
}
const input = {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '',
SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh'
}
const env = buildPtyHostEnv(
'folder-pane',
{ ...input },
{ ...options, launchAgent: 'opencode' }
)
expect(existsSync(plugin(consumer, 'opencode'))).toBe(true)
expect(existsSync(plugin(config, 'opencode'))).toBe(false)
expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe(settings)
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
for (const key of ['HOME', 'USERPROFILE', 'XDG_CONFIG_HOME', 'SHELL'] as const) {
expect(env[key]).toBe(input[key])
}
}
)
it.skipIf(process.platform === 'win32').each([
{ selected: '/bin/bash', inherited: '/bin/zsh', file: '.bash_profile' },
{ selected: '/bin/zsh', inherited: '/bin/bash', file: '.zshrc' }
])('consumer config follows selected $selected over inherited $inherited', (selection) => {
const home = join(root, 'selected-shell-home')
const selectedXdg = join(root, 'selected-shell-xdg')
const inheritedXdg = join(root, 'inherited-shell-xdg')
mkdirSync(home)
writeFileSync(
join(home, '.bash_profile'),
`export XDG_CONFIG_HOME='${selection.file === '.bash_profile' ? selectedXdg : inheritedXdg}'\n`
)
writeFileSync(
join(home, '.zshrc'),
`export XDG_CONFIG_HOME='${selection.file === '.zshrc' ? selectedXdg : inheritedXdg}'\n`
)
const input = { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: '', SHELL: selection.inherited }
const launchOptions = {
...options,
launchAgent: 'opencode' as const,
shellPath: selection.selected
}
const env = buildPtyHostEnv('selected-shell-pane', input, launchOptions)
expect(existsSync(plugin(join(selectedXdg, 'opencode'), 'opencode'))).toBe(true)
expect(existsSync(plugin(join(inheritedXdg, 'opencode'), 'opencode'))).toBe(false)
expect(env.SHELL).toBe(selection.inherited)
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
})
it.each([false, true])('consumer config root is untouched with hooks disabled %s', (disabled) => {
const consumer = join(root, 'disabled-consumer')
buildPtyHostEnv(
'pane',
{ XDG_CONFIG_HOME: consumer },
{
...options,
agentStatusHooksEnabled: disabled,
disabledTuiAgents: disabled ? ['opencode', 'opencode2'] : []
}
)
expect(existsSync(join(consumer, 'opencode'))).toBe(false)
})
it('refuses spawn if a prepared startup intent loses its owned installer', () => {
mkdirSync(fixture.userData, { recursive: true })
writeFileSync(
+2
View File
@@ -23,6 +23,8 @@ export type BuildPtyHostEnvOptions = {
launchCommand?: string
/** Trusted agent identity for wrapped commands that cannot be recognized from text. */
launchAgent?: TuiAgent
/** Selected execution shell, which may differ from inherited SHELL. */
shellPath?: string
isWsl?: boolean
/** Distro for WSL spawns (null = Windows default distro); drives the WSL hook relay + endpoint repoint. Only read when isWsl. */
wslDistro?: string | null
+1
View File
@@ -138,6 +138,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis
stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome,
launchCommand: ctx.launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: ctx.effectiveShellOverride,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.effectiveShellOverride, ctx.cwd),
wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
@@ -89,6 +89,7 @@ export function configureLocalPtyProvider(args: {
}),
launchCommand: ctx?.command,
launchAgent: ctx?.launchAgent,
shellPath: ctx?.shellPath,
isWsl: ctx?.isWsl,
wslDistro: ctx?.wslDistro ?? null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
@@ -272,6 +272,7 @@ export async function prepareRuntimePtySpawn(
stripInheritedOrcaCodexHome: ctx.stripInheritedOrcaCodexHome,
launchCommand: ctx.launchCommand,
launchAgent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined,
shellPath: ctx.daemonShellOverride,
isWsl: shouldSkipCodexHomeEnvForWindowsShell(ctx.daemonShellOverride, ctx.cwd),
wslDistro: ctx.codexSelectionTarget.runtime === 'wsl' ? ctx.expectedWslDistro : null,
agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings),
+6 -2
View File
@@ -121,7 +121,11 @@ export class OpenCodeHookService {
}
}
buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record<string, string> {
buildPtyEnv(
ptyId: string,
existingConfigDir?: string | undefined,
defaultConfigDir?: string
): Record<string, string> {
if (!isUsableId(ptyId)) {
// Why: on a bad id, still preserve a user-set OPENCODE_CONFIG_DIR; only the Orca status plugin is forfeited.
return existingConfigDir ? { OPENCODE_CONFIG_DIR: existingConfigDir } : {}
@@ -131,7 +135,7 @@ export class OpenCodeHookService {
const managedConfigDir = this.getSharedConfigDir()
if (!existingConfigDir || existingConfigDir === managedConfigDir) {
try {
this.writePluginToConfigDir(resolveOpenCodeConfigDirectory())
this.writePluginToConfigDir(defaultConfigDir ?? resolveOpenCodeConfigDirectory())
return {}
} catch {
return {}
+35
View File
@@ -27,6 +27,9 @@ vi.mock('node-pty', () => ({ spawn: mocks.mockPtySpawn }))
vi.mock('../main/shell-prompt-readiness-probe', () => ({
createShellPromptReadinessProbe: mocks.mockCreateShellPromptReadinessProbe
}))
vi.mock('../main/opencode/opencode-launch-capabilities', () => ({
probeOpenCodeLaunchCapabilities: async () => null
}))
vi.mock('../main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: vi.fn((_pid: number, fallback: () => void) => fallback())
}))
@@ -94,6 +97,38 @@ async function spawn(params: Record<string, unknown> = {}): Promise<Record<strin
}
describe('relay OpenCode source selection on real fixture files', () => {
it.each(['home', 'xdg', 'shell'] as const)(
'consumer config root follows the remote %s environment',
async (kind) => {
await install('// remote plugin', '')
const home = join(root, 'remote-home')
const xdg = join(root, 'remote-xdg')
const consumer = kind === 'home' ? join(home, '.config', 'opencode') : join(xdg, 'opencode')
mkdirSync(home, { recursive: true })
mkdirSync(consumer, { recursive: true })
writeFileSync(join(consumer, 'opencode.json'), '{"model":"remote-model"}')
if (kind === 'shell') {
writeFileSync(join(home, '.zshrc'), `export XDG_CONFIG_HOME='${xdg}'\n`)
}
const env = await spawn({
cwd: home,
launchAgent: 'opencode',
env: {
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: kind === 'xdg' ? xdg : '',
SHELL: kind === 'shell' ? '/bin/zsh' : '/bin/sh'
}
})
expect(readFileSync(plugin(consumer, 'opencode'), 'utf8')).toBe('// remote plugin')
expect(existsSync(plugin(join(root, 'xdg', 'opencode'), 'opencode'))).toBe(false)
expect(readFileSync(join(consumer, 'opencode.json'), 'utf8')).toBe('{"model":"remote-model"}')
expect(env.OPENCODE_CONFIG_DIR).toBeUndefined()
expect(env.HOME).toBe(home)
expect(env.XDG_CONFIG_HOME).toBe(kind === 'xdg' ? xdg : '')
}
)
it('leaves a standalone relay without supplied sources unconfigured', async () => {
const env = await spawn()
expect(env.ORCA_OPENCODE_AGENT).toBeUndefined()
+7 -1
View File
@@ -25,6 +25,7 @@ import { selectOpenCodeHookAgent } from '../shared/opencode-launch-command'
import { relayLogLine } from './relay-diagnostic-log'
import { restoreOrStripOverlayEnv } from '../shared/agent-overlay-env'
import { registerManagedHookInstaller } from './managed-hook-installer'
import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env'
export class RelayAgentHookRuntime {
private readonly hookServer: RelayAgentHookServer
@@ -122,7 +123,12 @@ export class RelayAgentHookRuntime {
env.ORCA_OPENCODE_SOURCE_CONFIG_DIR = sourceDir
}
} else {
this.pluginOverlay.installOpenCodePlugin(opencodeAgent, context.env)
this.pluginOverlay.installOpenCodePlugin(opencodeAgent, {
...context.env,
XDG_CONFIG_HOME:
readSessionShellStartupEnvVar('XDG_CONFIG_HOME', context.env, context.shell) ??
context.env.XDG_CONFIG_HOME
})
}
}
const explicitKind = isPiCompatibleAgentType(context.launchAgent)
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it } from 'vitest'
import { join } from 'node:path'
import { resolveOpenCodeConfigDirectory } from './opencode-config-directory'
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
describe('OpenCode consumer config directory', () => {
it('uses the execution HOME instead of the installer home on POSIX', () => {
Object.defineProperty(process, 'platform', { value: 'linux' })
expect(resolveOpenCodeConfigDirectory({ HOME: '/consumer' }, '/installer')).toBe(
join('/consumer', '.config', 'opencode')
)
})
it('uses the execution USERPROFILE on Windows', () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
expect(
resolveOpenCodeConfigDirectory({ USERPROFILE: '/consumer', HOME: '/shell' }, '/installer')
).toBe(join('/consumer', '.config', 'opencode'))
})
it('keeps the host home fallback when the execution home is absent', () => {
expect(resolveOpenCodeConfigDirectory({}, '/installer')).toBe(
join('/installer', '.config', 'opencode')
)
})
it('does not treat a Windows shell HOME as the native OpenCode home', () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
expect(resolveOpenCodeConfigDirectory({ HOME: '/shell' }, '/installer')).toBe(
join('/installer', '.config', 'opencode')
)
})
it('keeps XDG_CONFIG_HOME ahead of the execution home', () => {
expect(
resolveOpenCodeConfigDirectory({ HOME: '/consumer', XDG_CONFIG_HOME: '/xdg' }, '/installer')
).toBe(join('/xdg', 'opencode'))
})
})
+5 -1
View File
@@ -6,5 +6,9 @@ export function resolveOpenCodeConfigDirectory(
environment: NodeJS.ProcessEnv | Record<string, string> = process.env,
homeDirectory = homedir()
): string {
return join(environment.XDG_CONFIG_HOME?.trim() || join(homeDirectory, '.config'), 'opencode')
const executionHome = process.platform === 'win32' ? environment.USERPROFILE : environment.HOME
return join(
environment.XDG_CONFIG_HOME?.trim() || join(executionHome || homeDirectory, '.config'),
'opencode'
)
}