diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 4b50f154990..7ff6c9f94ca 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -8,7 +8,6 @@ src/cli/handlers/skills.ts src/cli/runtime/launch.ts src/cli/runtime/serve-update-supervisor.ts src/main/agent-hooks/managed-hook-owner-identity.ts -src/main/agent-hooks/managed-hook-runtime.ts src/main/agent-hooks/wsl-hook-relay-launch.ts src/main/agent-hooks/wsl-hook-relay-link.ts src/main/agent-hooks/wsl-hook-relay-manager.ts diff --git a/src/main/agent-hooks/agent-home-login-shell.test.ts b/src/main/agent-hooks/agent-home-login-shell.test.ts new file mode 100644 index 00000000000..a7d38b809e4 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type ProbeResult = { code: number | null; stdout: string; timedOut: boolean } +type RunProcessStub = (spec: { program: string; args: string[] }) => Promise + +// Why: the probe spawns a real login shell and swallows every spawn failure into its fallback. +// Left unmocked this asserts the runner's scheduling latency, not the parser: on a loaded CI box +// the 8s timeout expires and the first case silently flips to the fallback path. +const probe = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal>()), + runProcess: probe.runProcess +})) + +const probeMock = probe.runProcess +const { adoptLoginShellAgentHomes, resolveLoginShellGrokHome, resolveLoginShellKiroHome } = + await import('./agent-home-login-shell') + +function stubProbeOutput(stdout: string | ((script: string) => string)): void { + probeMock.mockImplementation(async ({ args }) => ({ + code: 0, + timedOut: false, + stdout: typeof stdout === 'string' ? stdout : stdout(args[1] ?? '') + })) +} + +function stubProbeFailure(error: Error & { killed?: boolean }): void { + probeMock.mockImplementation(async () => { + if (error.killed) { + return { code: null, timedOut: true, stdout: '' } + } + throw error + }) +} + +beforeEach(() => { + probeMock.mockReset() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellGrokHome', () => { + it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/grok///\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/sh') + // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. + expect(args?.[0]).toBe('-c') + }) + + it('passes -lc to a login shell that supports it', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('/srv/grok\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/srv/grok') + + const { program: shell, args } = probeMock.mock.calls[0]?.[0] ?? {} + expect(shell).toBe('/bin/zsh') + expect(args?.[0]).toBe('-lc') + }) + + it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('../relative\n') + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) + + // Why: this is the branch that made the old test flaky — pin it so a probe failure is + // an asserted fallback rather than an invisible substitution for a real answer. + it('falls back when the probe fails or times out', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) + + await expect(resolveLoginShellGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') + }) +}) + +describe.runIf(process.platform !== 'win32')('resolveLoginShellKiroHome', () => { + it('reads KIRO_HOME from the login shell', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('/srv/kiro/\n') + + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/srv/kiro') + expect(probeMock.mock.calls[0]?.[0].args[1]).toContain('printenv KIRO_HOME') + }) + + it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { + vi.stubEnv('SHELL', '/bin/sh') + stubProbeOutput('\n') + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + + stubProbeFailure(new Error('spawn failed')) + await expect(resolveLoginShellKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') + }) +}) + +describe.runIf(process.platform !== 'win32')('adoptLoginShellAgentHomes', () => { + it('adopts profile-only agent homes and keeps ones already set', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput((script) => (script.includes('KIRO_HOME') ? '/srv/kiro\n' : '/srv/grok\n')) + const env: NodeJS.ProcessEnv = { GROK_HOME: '/already/grok' } + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({ GROK_HOME: '/already/grok', KIRO_HOME: '/srv/kiro' }) + expect(probeMock).toHaveBeenCalledTimes(1) + }) + + it('leaves the environment alone when the login shell has no override', async () => { + vi.stubEnv('SHELL', '/bin/zsh') + stubProbeOutput('\n') + const env: NodeJS.ProcessEnv = {} + + await adoptLoginShellAgentHomes(env) + + expect(env).toEqual({}) + }) +}) diff --git a/src/main/agent-hooks/agent-home-login-shell.ts b/src/main/agent-hooks/agent-home-login-shell.ts new file mode 100644 index 00000000000..73a332d1ad8 --- /dev/null +++ b/src/main/agent-hooks/agent-home-login-shell.ts @@ -0,0 +1,107 @@ +import { basename } from 'node:path' +import { userInfo } from 'node:os' +import { runProcess } from '@orca/process-host' + +const AGENT_HOME_MAX_LENGTH = 4096 +const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 + +type AgentHomeEnvName = 'GROK_HOME' | 'KIRO_HOME' + +function defaultAgentHome(home: string, dirName: string): string { + return `${home.replace(/\/+$/, '') || home}/${dirName}` +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +function normalizeAgentHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > AGENT_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +function resolveLoginShell(): string { + const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' + if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { + return '/bin/sh' + } + return candidate +} + +/** The login shell's value for `envName`, or null when unset, invalid, or unreadable. */ +async function readLoginShellAgentHome( + envName: AgentHomeEnvName, + signal?: AbortSignal +): Promise { + try { + const shell = resolveLoginShell() + const shellName = basename(shell) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + // Why: agent PTYs start login shells, so read the same profile-derived home override. + const result = await runProcess({ + program: shell, + args: [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], + timeoutMs: AGENT_HOME_PROBE_TIMEOUT_MS, + maxOutputBytes: AGENT_HOME_MAX_LENGTH * 2, + ...(signal ? { signal } : {}) + }) + signal?.throwIfAborted() + if (result.timedOut || result.code !== 0) { + return null + } + return normalizeAgentHome(result.stdout.split(/\r?\n/, 1)[0] ?? '') + } catch { + signal?.throwIfAborted() + return null + } +} + +export async function resolveLoginShellGrokHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('GROK_HOME', signal)) ?? defaultAgentHome(home, '.grok') +} + +/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ +export async function resolveLoginShellKiroHome( + home: string, + signal?: AbortSignal +): Promise { + return (await readLoginShellAgentHome('KIRO_HOME', signal)) ?? defaultAgentHome(home, '.kiro') +} + +/** + * A process not started from a login shell (orcad over SSH) misses profile-only agent homes, so + * its installers would write where the agents' login-shell panes never look. Never throws. + */ +export async function adoptLoginShellAgentHomes( + env: NodeJS.ProcessEnv = process.env +): Promise { + if (process.platform === 'win32') { + return + } + const names: AgentHomeEnvName[] = ['GROK_HOME', 'KIRO_HOME'] + await Promise.all( + names + .filter((name) => !env[name]) + .map(async (name) => { + const value = await readLoginShellAgentHome(name) + if (value && !env[name]) { + env[name] = value + } + }) + ) +} diff --git a/src/main/agent-hooks/managed-agent-hook-startup.ts b/src/main/agent-hooks/managed-agent-hook-startup.ts new file mode 100644 index 00000000000..c845bf4bd36 --- /dev/null +++ b/src/main/agent-hooks/managed-agent-hook-startup.ts @@ -0,0 +1,30 @@ +import { recordManagedHookInstallFailure } from './install-telemetry' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup +} from './managed-agent-hook-controls' + +type StartupHookSettings = NonNullable[0]> + +/** Installs or refreshes this host's managed agent hooks once at process start; never removes. */ +export function reconcileStartupManagedAgentHooks(host: { + getSettings: () => StartupHookSettings + isQuitting: () => boolean + shouldHydrateShellPath: boolean +}): void { + // Why skip rather than remove when the off switch is set: the hook files are user-global but this + // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca + // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. + if (resolveStartupManagedHookAction(host.getSettings()) !== 'install') { + return + } + void installManagedAgentHooks(host.getSettings(), { + shouldHydrateShellPath: host.shouldHydrateShellPath, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup(host.isQuitting(), host.getSettings(), agent) + }).catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) +} diff --git a/src/main/agent-hooks/managed-hook-runtime.test.ts b/src/main/agent-hooks/managed-hook-runtime.test.ts index f6e55eab1de..281126fae78 100644 --- a/src/main/agent-hooks/managed-hook-runtime.test.ts +++ b/src/main/agent-hooks/managed-hook-runtime.test.ts @@ -1,55 +1,9 @@ import { chmod, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as NodeChildProcess from 'node:child_process' +import { afterEach, describe, expect, it, vi } from 'vitest' -// Why: the probe spawns a real login shell, and `resolveRelayGrokHome` swallows every -// spawn failure into its fallback. Left unmocked this asserts the runner's scheduling -// latency, not the parser: on a loaded sharded CI box the 8s timeout expires and the -// first case silently flips to the fallback path. -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, execFile: vi.fn() } -}) - -const { execFile } = await import('node:child_process') -const execFileMock = vi.mocked(execFile) -const { execFile: actualExecFile } = - await vi.importActual('node:child_process') -const { installManagedHooks, resolveRelayGrokHome, resolveRelayKiroHome } = - await import('./managed-hook-runtime') - -type ExecFileCallback = (error: Error | null, result?: { stdout: string; stderr: string }) => void - -function stubProbeOutput(stdout: string): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(null, { stdout, stderr: '' }) - return undefined - }) as unknown as typeof execFile) -} - -function stubProbeFailure(error: Error): void { - execFileMock.mockImplementation(((...args: unknown[]) => { - ;(args.at(-1) as ExecFileCallback)(error) - return undefined - }) as unknown as typeof execFile) -} - -beforeEach(() => { - execFileMock.mockReset() - // Why: `installManagedHooks` proves a skipped probe through the login-shell run log, so the - // probe must really spawn unless a case above stubs it. The mock loses `promisify.custom`, - // so the real `(error, stdout, stderr)` callback is reshaped into the `{ stdout, stderr }` - // that `promisify(execFile)` resolves in production. - execFileMock.mockImplementation(((...args: unknown[]) => { - const callback = args.at(-1) as ExecFileCallback - return (actualExecFile as (...callArgs: unknown[]) => unknown)( - ...args.slice(0, -1), - (error: Error | null, stdout: string, stderr: string) => callback(error, { stdout, stderr }) - ) - }) as unknown as typeof execFile) -}) +const { installManagedHooks } = await import('./managed-hook-runtime') const tempHomes: string[] = [] const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp' @@ -80,66 +34,6 @@ afterEach(async () => { await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) }) -describe.runIf(process.platform !== 'win32')('resolveRelayGrokHome', () => { - it('uses the login-shell GROK_HOME and normalizes trailing separators', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/grok///\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/sh') - // `sh`/`dash` reject `-lc`, so the mode choice is part of the contract under test. - expect(args?.[0]).toBe('-c') - }) - - it('passes -lc to a login shell that supports it', async () => { - vi.stubEnv('SHELL', '/bin/zsh') - stubProbeOutput('/srv/grok\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/srv/grok') - - const [shell, args] = execFileMock.mock.calls[0] ?? [] - expect(shell).toBe('/bin/zsh') - expect(args?.[0]).toBe('-lc') - }) - - it('falls back when the login-shell GROK_HOME is not an absolute POSIX path', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('../relative\n') - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) - - // Why: this is the branch that made the old test flaky — pin it so a probe failure is - // an asserted fallback rather than an invisible substitution for a real answer. - it('falls back when the probe fails or times out', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeFailure(Object.assign(new Error('spawn timed out'), { killed: true })) - - await expect(resolveRelayGrokHome('/home/orca')).resolves.toBe('/home/orca/.grok') - }) -}) - -describe.runIf(process.platform !== 'win32')('resolveRelayKiroHome', () => { - it('reads KIRO_HOME from the login shell', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('/srv/kiro/\n') - - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/srv/kiro') - expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('printenv KIRO_HOME') - }) - - it('falls back to ~/.kiro when KIRO_HOME is unset or the probe fails', async () => { - vi.stubEnv('SHELL', '/bin/sh') - stubProbeOutput('\n') - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - - stubProbeFailure(new Error('spawn failed')) - await expect(resolveRelayKiroHome('/home/orca')).resolves.toBe('/home/orca/.kiro') - }) -}) - describe.runIf(process.platform !== 'win32')('installManagedHooks', () => { it.each([ ['omitted', undefined], diff --git a/src/main/agent-hooks/managed-hook-runtime.ts b/src/main/agent-hooks/managed-hook-runtime.ts index e8a32187f8a..726cc5322c1 100644 --- a/src/main/agent-hooks/managed-hook-runtime.ts +++ b/src/main/agent-hooks/managed-hook-runtime.ts @@ -1,7 +1,5 @@ -import { execFile } from 'node:child_process' -import { basename } from 'node:path' -import { homedir, userInfo } from 'node:os' -import { promisify } from 'node:util' +import { homedir } from 'node:os' +import { resolveLoginShellGrokHome, resolveLoginShellKiroHome } from './agent-home-login-shell' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' import type { AgentHookTarget } from '../../shared/agent-hook-types' import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem' @@ -11,80 +9,11 @@ import { scopeManagedHookHostIdentity } from './managed-hook-owner-identity' -const execFileAsync = promisify(execFile) -const AGENT_HOME_MAX_LENGTH = 4096 -const AGENT_HOME_PROBE_TIMEOUT_MS = 8_000 - export type ManagedHookInstallSummary = { installers: number errors: number } -function defaultAgentHome(home: string, dirName: string): string { - return `${home.replace(/\/+$/, '') || home}/${dirName}` -} - -function hasControlCharacter(value: string): boolean { - return Array.from(value).some((character) => { - const code = character.charCodeAt(0) - return code <= 0x1f || code === 0x7f - }) -} - -function normalizeAgentHome(candidate: string): string | null { - if ( - candidate.length === 0 || - candidate.length > AGENT_HOME_MAX_LENGTH || - candidate !== candidate.trim() || - !candidate.startsWith('/') || - candidate.includes('\\') || - hasControlCharacter(candidate) - ) { - return null - } - return candidate.replace(/\/+$/, '') || '/' -} - -function resolveLoginShell(): string { - const candidate = process.env.SHELL || userInfo().shell || '/bin/sh' - if (!candidate.startsWith('/') || candidate.includes('\\') || hasControlCharacter(candidate)) { - return '/bin/sh' - } - return candidate -} - -async function resolveRelayAgentHome( - envName: 'GROK_HOME' | 'KIRO_HOME', - fallback: string, - signal?: AbortSignal -): Promise { - try { - const shell = resolveLoginShell() - const shellName = basename(shell) - const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' - // Why: agent PTYs start login shells, so read the same profile-derived - // home override without opening two additional SSH exec channels. - const { stdout } = await execFileAsync( - shell, - [mode, `printenv ${envName} | head -c ${AGENT_HOME_MAX_LENGTH + 1}`], - { encoding: 'utf8', timeout: AGENT_HOME_PROBE_TIMEOUT_MS, signal } - ) - return normalizeAgentHome(stdout.split(/\r?\n/, 1)[0] ?? '') ?? fallback - } catch { - signal?.throwIfAborted() - return fallback - } -} - -export function resolveRelayGrokHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('GROK_HOME', defaultAgentHome(home, '.grok'), signal) -} - -/** `$KIRO_HOME` replaces `~/.kiro` outright, for agent configs and sessions alike. */ -export function resolveRelayKiroHome(home: string, signal?: AbortSignal): Promise { - return resolveRelayAgentHome('KIRO_HOME', defaultAgentHome(home, '.kiro'), signal) -} - export async function installManagedHooks(options?: { signal?: AbortSignal hostKeyFingerprint?: string @@ -100,8 +29,8 @@ export async function installManagedHooks(options?: { const home = homedir() // Why parallel: each probe may wait out its own login-shell timeout. const [grokHomeDir, kiroHomeDir] = await Promise.all([ - agents.includes('grok') ? resolveRelayGrokHome(home, options?.signal) : undefined, - agents.includes('kiro') ? resolveRelayKiroHome(home, options?.signal) : undefined + agents.includes('grok') ? resolveLoginShellGrokHome(home, options?.signal) : undefined, + agents.includes('kiro') ? resolveLoginShellKiroHome(home, options?.signal) : undefined ]) options?.signal?.throwIfAborted() const hostIdentity = scopeManagedHookHostIdentity( diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 5047dd82027..0f199ef3a9f 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -177,6 +177,7 @@ async function startOrcadRuntime( const { isAgentStatusHooksEnabled } = await import('../agent-hooks/managed-agent-hook-controls') const { installHookStatusSessionTabsRepublish } = await import('../agent-hooks/hook-status-session-tabs-republish') + const { startOrcadManagedAgentHooks } = await import('./orcad-managed-agent-hooks') const { AgentStatusObservedPaneIdentities, AgentStatusObservedPaneIdentityCapture } = await import('../runtime/agent-status-observed-pane-identity') @@ -187,12 +188,10 @@ async function startOrcadRuntime( | undefined let uninstallHookStatusRepublish = (): void => {} let uninstallObservedStatusIdentity = (): void => {} - let removeStatusHookSettingsListener = (): void => {} // Cleanups run in reverse: RPC, then recovery and watchers, then the final flush, then daemon. registerCleanup(() => agentHookServer.stop()) registerCleanup(() => uninstallHookStatusRepublish()) registerCleanup(() => uninstallObservedStatusIdentity()) - registerCleanup(() => removeStatusHookSettingsListener()) // Why disconnect and not shut down: the daemon must outlive this process, or an orcad // restart goes back to killing every running terminal. registerCleanup(() => stopOrcadDaemon()) @@ -227,11 +226,11 @@ async function startOrcadRuntime( statusHooksEnabled: isAgentStatusHooksEnabled(profileStore.getSettings()) }) - removeStatusHookSettingsListener = profileStore.onSettingsChanged((updates, settings) => { - if ('agentStatusHooksEnabled' in updates) { - agentHookServer.setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) - } - }) + startOrcadManagedAgentHooks( + profileStore, + (enabled) => agentHookServer.setStatusHooksEnabled(enabled), + registerCleanup + ) // Why before the runtime and the PTY handlers: `setLocalPtyProvider` installs the daemon // adapter as THE local provider, and the registry's contract is that it lands before diff --git a/src/main/orcad/orcad-managed-agent-hooks.ts b/src/main/orcad/orcad-managed-agent-hooks.ts new file mode 100644 index 00000000000..dbdf08695fd --- /dev/null +++ b/src/main/orcad/orcad-managed-agent-hooks.ts @@ -0,0 +1,54 @@ +import { getAppEnvironment } from '../../shared/app-environment' +import { startCodexHooks } from '../codex/codex-hook-reconcile' +import { adoptLoginShellAgentHomes } from '../agent-hooks/agent-home-login-shell' +import { hydrateAgentCliShellPath } from '../agent-hooks/local-agent-cli-presence' +import { + isAgentStatusHooksEnabled, + isAgentStatusHooksEnabledForAgent +} from '../agent-hooks/managed-agent-hook-controls' +import { reconcileStartupManagedAgentHooks } from '../agent-hooks/managed-agent-hook-startup' +import { refreshInstalledOpenCodeStatusPlugins } from '../opencode/opencode-status-plugin-startup-refresh' +import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' + +/** + * Keeps this host's hook server and its agents' hook configs true to its settings. The startup + * reconcile is the desktop's: without it a freshly managed SSH host only got hooks when its hook + * settings changed, so its agents never reported status. + */ +export function startOrcadManagedAgentHooks( + store: Pick, + setStatusHooksEnabled: (enabled: boolean) => void, + registerCleanup: (cleanup: OrcadRuntimeCleanup) => void +): void { + let stopping = false + const removeSettingsListener = store.onSettingsChanged((updates, settings) => { + if ('agentStatusHooksEnabled' in updates) { + setStatusHooksEnabled(isAgentStatusHooksEnabled(settings)) + } + }) + registerCleanup(() => { + stopping = true + removeSettingsListener() + }) + const getSettings = () => store.getSettings() + const shouldHydrateShellPath = getAppEnvironment().isPackaged() + startCodexHooks({ + pathReady: shouldHydrateShellPath ? hydrateAgentCliShellPath() : Promise.resolve(), + isEnabled: () => isAgentStatusHooksEnabledForAgent(getSettings(), 'codex'), + // Why null: orcad launches Codex on ~/.codex; Orca-managed account homes are a desktop flow. + resolveLaunchHome: () => null + }) + void adoptLoginShellAgentHomes().then(() => + reconcileStartupManagedAgentHooks({ + getSettings, + isQuitting: () => stopping, + shouldHydrateShellPath + }) + ) + try { + refreshInstalledOpenCodeStatusPlugins(getSettings()) + } catch (error) { + console.warn('[orcad] could not refresh OpenCode status plugins:', error) + } +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 025dc666773..b2349b560b1 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { ProfilePreferences } from '../persistence/loading-store/profile-preferences' +import type { GlobalSettings } from '../../shared/global-settings-types' import { DeviceRegistry } from '../runtime/device-registry' import { RuntimeMobileNotificationController } from '../runtime/runtime-mobile-notification-controller' import { PushUnregisterOutbox } from '../runtime/push/push-unregister-outbox' @@ -21,7 +22,34 @@ const state = vi.hoisted(() => ({ startDaemon: vi.fn(async () => {}), browserProvider: vi.fn(async () => null), register: vi.fn(async () => ({ ok: true, registrationId: 'headless-registration' })), - send: vi.fn(async () => ({ ok: true, results: [] })) + send: vi.fn(async () => ({ ok: true, results: [] })), + startCodexHooks: vi.fn(), + installManagedAgentHooks: vi.fn(async () => []), + refreshOpenCodePlugins: vi.fn(), + adoptLoginShellAgentHomes: vi.fn(async () => {}) +})) +const profile = vi.hoisted(() => { + const value: { settings: Partial } = { settings: {} } + return value +}) +// Why: startup reconciles the user-global agent configs; keep it off this machine's real home. +vi.mock('../codex/codex-hook-reconcile', async (importOriginal) => ({ + ...(await importOriginal>()), + startCodexHooks: state.startCodexHooks +})) +vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({ + ...(await importOriginal>()), + installManagedAgentHooks: state.installManagedAgentHooks +})) +vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({ + ...(await importOriginal>()), + hydrateAgentCliShellPath: async () => {} +})) +vi.mock('../agent-hooks/agent-home-login-shell', () => ({ + adoptLoginShellAgentHomes: state.adoptLoginShellAgentHomes +})) +vi.mock('../opencode/opencode-status-plugin-startup-refresh', () => ({ + refreshInstalledOpenCodeStatusPlugins: state.refreshOpenCodePlugins })) vi.mock('./orcad-app-paths', () => ({ resolveOrcadInstallRoot: () => state.root, @@ -65,7 +93,7 @@ vi.mock('./orcad-profile-state-startup', () => ({ } return { store: { - getSettings: () => ({}), + getSettings: () => profile.settings, onSettingsChanged: state.onSettingsChanged, flushFinalOrThrowAsync: async () => {}, freezeWritesAsync: async () => {} @@ -157,6 +185,7 @@ beforeEach(() => { afterEach(() => { rmSync(state.root, { recursive: true, force: true }) state.profileStartupErrors.length = 0 + profile.settings = {} vi.clearAllMocks() }) @@ -255,3 +284,42 @@ it('unsubscribes settings when daemon startup fails after hook setup', async () expect(readdirSync(profileStateAccessPaths(state.root).participants)).toEqual([]) acquireProfileStateMaintenance(state.root).release() }) + +// Why: a freshly managed SSH host starts orcad on connect; without this its agents never report status. +it('installs agent status hooks on the host when it starts', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-')) + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + expect(state.startCodexHooks).toHaveBeenCalledOnce() + // orcad launches Codex on the real ~/.codex; it has no Orca-managed account homes. + expect(state.startCodexHooks.mock.calls[0]![0].resolveLaunchHome()).toBeNull() + await vi.waitFor(() => + expect(state.installManagedAgentHooks).toHaveBeenCalledWith( + profile.settings, + expect.objectContaining({ shouldHydrateShellPath: true }) + ) + ) + expect(state.refreshOpenCodePlugins).toHaveBeenCalledWith(profile.settings) + // Grok/Kiro homes set only in login profiles must be known before their installers run. + expect(state.adoptLoginShellAgentHomes.mock.invocationCallOrder[0]).toBeLessThan( + state.installManagedAgentHooks.mock.invocationCallOrder[0]! + ) + } finally { + await host.stop() + } +}) + +it('leaves agent configs alone when status hooks are off', async () => { + state.root = mkdtempSync(join(tmpdir(), 'orca-headless-hooks-off-')) + profile.settings = { agentStatusHooksEnabled: false } + const { startOrcad } = await import('./orcad-entry') + const host = await startOrcad({ noPairing: true, json: true }) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(state.installManagedAgentHooks).not.toHaveBeenCalled() + expect(state.startCodexHooks.mock.calls[0]![0].isEnabled()).toBe(false) + } finally { + await host.stop() + } +}) diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts index 4b963730ebf..e0e93f920e1 100644 --- a/src/main/startup/main-process-ready-runtime.ts +++ b/src/main/startup/main-process-ready-runtime.ts @@ -15,14 +15,9 @@ import { handleGpuChildCrash } from './gpu-lifecycle' import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' import { startCodexHooks } from '../codex/codex-hook-reconcile' import { hydrateAgentCliShellPath } from '../agent-hooks/local-agent-cli-presence' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabledForAgent, - resolveStartupManagedHookAction, - shouldContinueManagedHookStartup -} from '../agent-hooks/managed-agent-hook-controls' +import { isAgentStatusHooksEnabledForAgent } from '../agent-hooks/managed-agent-hook-controls' +import { reconcileStartupManagedAgentHooks } from '../agent-hooks/managed-agent-hook-startup' import { shouldInstallManagedHooks } from './configure-process' -import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' import { mainProcessState as state } from './main-process-state' import { initializeMainProcessObservers } from './main-process-observers' import { initializeMainProcessAccountServices } from './main-process-account-services' @@ -117,23 +112,12 @@ export async function initializeReadyRuntimeServices(): Promise { return state.codexRuntimeHome.resolveHostCodexHomePathForLaunchReadOnly() } }) - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => - shouldContinueManagedHookStartup(state.isQuitting, managedHookStore.getSettings(), agent) - }).catch((error: unknown) => - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - ) + if (shouldInstallManagedHooks(is.dev)) { + reconcileStartupManagedAgentHooks({ + getSettings: () => store.getSettings(), + isQuitting: () => state.isQuitting, + shouldHydrateShellPath: app.isPackaged + }) } // Why: process-gone metrics only see survivors, and the gone-time host memory // read lands after the corpse released its pages; both need a live pre-gone