From 8ff6ec9fb1cd51b2b7a4613c8f97a72c834a6f1e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:20:07 -0700 Subject: [PATCH] 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 Co-authored-by: Orca --- .../ipc/pty-daemon-spawn-codex-auth.test.ts | 19 +++-- .../ipc/pty-spawn-env-agent-overlays.test.ts | 38 ++++++--- src/main/ipc/pty/host-env/opencode-config.ts | 12 ++- .../opencode-hook-installation.test.ts | 79 +++++++++++++++++++ src/main/ipc/pty/host-env/types.ts | 2 + src/main/ipc/pty/ipc/spawn-env-codex.ts | 1 + src/main/ipc/pty/provider/local-configure.ts | 1 + src/main/ipc/pty/runtime/spawn-preflight.ts | 1 + src/main/opencode/hook-service.ts | 8 +- src/relay/opencode-hook-selection.test.ts | 35 ++++++++ src/relay/relay-agent-hook-runtime.ts | 8 +- src/shared/opencode-config-directory.test.ts | 45 +++++++++++ src/shared/opencode-config-directory.ts | 6 +- 13 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 src/shared/opencode-config-directory.test.ts diff --git a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts index cb60984c50c..0ecb8da064e 100644 --- a/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts +++ b/src/main/ipc/pty-daemon-spawn-codex-auth.test.ts @@ -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') diff --git a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts index 4c7204f1980..c194cf04179 100644 --- a/src/main/ipc/pty-spawn-env-agent-overlays.test.ts +++ b/src/main/ipc/pty-spawn-env-agent-overlays.test.ts @@ -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') diff --git a/src/main/ipc/pty/host-env/opencode-config.ts b/src/main/ipc/pty/host-env/opencode-config.ts index 88dafabc88e..74aa1e46625 100644 --- a/src/main/ipc/pty/host-env/opencode-config.ts +++ b/src/main/ipc/pty/host-env/opencode-config.ts @@ -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. diff --git a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts index ddae2771aba..fe42ca5735c 100644 --- a/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts +++ b/src/main/ipc/pty/host-env/opencode-hook-installation.test.ts @@ -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( diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 20981d0cce4..5e42310a45b 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -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 diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index b69bc65fed2..74f1d177e67 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -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), diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index f4dd8f32007..7a494522ec0 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -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), diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index 484f9166e4b..5effb7e682e 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -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), diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index 7a6d137ebfa..b4b30912b09 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -121,7 +121,11 @@ export class OpenCodeHookService { } } - buildPtyEnv(ptyId: string, existingConfigDir?: string | undefined): Record { + buildPtyEnv( + ptyId: string, + existingConfigDir?: string | undefined, + defaultConfigDir?: string + ): Record { 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 {} diff --git a/src/relay/opencode-hook-selection.test.ts b/src/relay/opencode-hook-selection.test.ts index 6e47c28bc36..0650662762c 100644 --- a/src/relay/opencode-hook-selection.test.ts +++ b/src/relay/opencode-hook-selection.test.ts @@ -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 = {}): Promise { + 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() diff --git a/src/relay/relay-agent-hook-runtime.ts b/src/relay/relay-agent-hook-runtime.ts index 4ee9113919a..c991b3fde71 100644 --- a/src/relay/relay-agent-hook-runtime.ts +++ b/src/relay/relay-agent-hook-runtime.ts @@ -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) diff --git a/src/shared/opencode-config-directory.test.ts b/src/shared/opencode-config-directory.test.ts new file mode 100644 index 00000000000..e11feee0cc2 --- /dev/null +++ b/src/shared/opencode-config-directory.test.ts @@ -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')) + }) +}) diff --git a/src/shared/opencode-config-directory.ts b/src/shared/opencode-config-directory.ts index 28ca9cd175a..e200a2201c5 100644 --- a/src/shared/opencode-config-directory.ts +++ b/src/shared/opencode-config-directory.ts @@ -6,5 +6,9 @@ export function resolveOpenCodeConfigDirectory( environment: NodeJS.ProcessEnv | Record = 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' + ) }