fix(orcad): install agent status hooks when a managed host starts (#27319)

* fix(orcad): install agent status hooks when a managed host starts

* fix(orcad): read login-profile Grok/Kiro homes before installing hooks

* refactor(agent-hooks): run the login-shell home probe through process-host

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-10 17:08:36 -07:00
committed by GitHub
co-authored by m4air
parent 884575a797
commit b4d8e370da
10 changed files with 406 additions and 217 deletions
@@ -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
@@ -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<ProbeResult>
// 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<RunProcessStub>() }))
vi.mock('@orca/process-host', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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({})
})
})
@@ -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<string | null> {
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<string> {
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<string> {
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<void> {
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
}
})
)
}
@@ -0,0 +1,30 @@
import { recordManagedHookInstallFailure } from './install-telemetry'
import {
installManagedAgentHooks,
resolveStartupManagedHookAction,
shouldContinueManagedHookStartup
} from './managed-agent-hook-controls'
type StartupHookSettings = NonNullable<Parameters<typeof installManagedAgentHooks>[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)
)
}
@@ -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<typeof NodeChildProcess>()
return { ...actual, execFile: vi.fn() }
})
const { execFile } = await import('node:child_process')
const execFileMock = vi.mocked(execFile)
const { execFile: actualExecFile } =
await vi.importActual<typeof NodeChildProcess>('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],
+4 -75
View File
@@ -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<string> {
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<string> {
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<string> {
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(
+6 -7
View File
@@ -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
@@ -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<ProfilePreferences, 'getSettings' | 'onSettingsChanged'>,
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)
}
}
+70 -2
View File
@@ -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<GlobalSettings> } = { 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<Record<string, unknown>>()),
startCodexHooks: state.startCodexHooks
}))
vi.mock('../agent-hooks/managed-agent-hook-controls', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
installManagedAgentHooks: state.installManagedAgentHooks
}))
vi.mock('../agent-hooks/local-agent-cli-presence', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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()
}
})
+8 -24
View File
@@ -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<void> {
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