mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user