fix: surface remote agent status during startup (#2123)

This commit is contained in:
Jinjing 2026-05-16 16:25:46 -07:00 committed by GitHub
parent 0289ffaa6e
commit f015202a03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 309 additions and 20 deletions

View File

@ -0,0 +1,56 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
type RemoteManagedHookInstaller = readonly [
AgentHookInstallStatus['agent'],
(sftp: SFTPWrapper, remoteHome: string) => Promise<AgentHookInstallStatus>
]
const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['claude', (sftp, remoteHome) => claudeHookService.installRemote(sftp, remoteHome)],
['codex', (sftp, remoteHome) => codexHookService.installRemote(sftp, remoteHome)],
['gemini', (sftp, remoteHome) => geminiHookService.installRemote(sftp, remoteHome)],
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)]
]
export async function installRemoteManagedAgentHooks(
sftp: SFTPWrapper,
remoteHome: string
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
try {
const result = await install(sftp, remoteHome)
results.push(result)
if (result.state === 'error') {
console.warn(
`[agent-hooks] Remote ${agent} managed hook install failed for ${result.configPath}: ${
result.detail ?? 'unknown error'
}`
)
}
} catch (error) {
// Why: remote hook installation must not block SSH workspace startup.
// A broken agent config or transient SFTP failure should degrade status
// reporting only, while terminals/filesystem/git still come online.
const detail = error instanceof Error ? error.message : String(error)
console.warn(`[agent-hooks] Remote ${agent} managed hook install threw: ${detail}`)
results.push({
agent,
state: 'error',
configPath: remoteHome,
managedHooksPresent: false,
detail
})
}
}
return results
}

View File

@ -8,8 +8,9 @@ import type { Store } from '../persistence'
import type { SshPortForwardManager } from './ssh-port-forward'
import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay'
const { muxRequestMock } = vi.hoisted(() => ({
muxRequestMock: vi.fn()
const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({
muxRequestMock: vi.fn(),
installRemoteManagedAgentHooksMock: vi.fn()
}))
vi.mock('./ssh-relay-deploy', () => ({
@ -29,6 +30,10 @@ vi.mock('./ssh-channel-multiplexer', () => {
}
})
vi.mock('../agent-hooks/remote-managed-hook-installers', () => ({
installRemoteManagedAgentHooks: installRemoteManagedAgentHooksMock
}))
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('not found'),
@ -127,6 +132,8 @@ describe('SshRelaySession', () => {
delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS
muxRequestMock.mockReset()
muxRequestMock.mockResolvedValue([])
installRemoteManagedAgentHooksMock.mockReset()
installRemoteManagedAgentHooksMock.mockResolvedValue([])
mockDeploySuccess()
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
})
@ -151,9 +158,14 @@ describe('SshRelaySession', () => {
expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('syncs relay-owned plugin assets before registering the SSH PTY provider', async () => {
it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
muxRequestMock.mockResolvedValue({ ok: true })
muxRequestMock.mockImplementation(async (method: string) => {
if (method === 'session.resolveHome') {
return { resolvedPath: '/home/orca' }
}
return { ok: true }
})
const sftp = { end: vi.fn() }
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {
@ -167,14 +179,15 @@ describe('SshRelaySession', () => {
([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD
)
expect(installPluginsCallIndex).toBeGreaterThanOrEqual(0)
expect(mockConn.sftp).toHaveBeenCalledTimes(1)
expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca')
expect(sftp.end).toHaveBeenCalledTimes(1)
expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan(
muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]
)
expect(muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex]).toBeLessThan(
vi.mocked(registerSshPtyProvider).mock.invocationCallOrder[0]
)
// Why: connecting to SSH may upload relay-owned plugin source, but must
// not mutate user-owned agent config files. Remote managed-hook install
// belongs behind an explicit per-host user action.
expect(mockConn.sftp).not.toHaveBeenCalled()
expect(sftp.end).not.toHaveBeenCalled()
})
it('does not register providers if dispose wins during initial plugin sync', async () => {

View File

@ -17,6 +17,7 @@ import { SshPtyProvider, isSshPtyNotFoundError } from '../providers/ssh-pty-prov
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
import { installRemoteManagedAgentHooks } from '../agent-hooks/remote-managed-hook-installers'
import {
AGENT_HOOK_INSTALL_PLUGINS_METHOD,
AGENT_HOOK_NOTIFICATION_METHOD,
@ -432,6 +433,11 @@ export class SshRelaySession {
return false
}
await this.installManagedHooksOnRemote(mux)
if (shouldContinue && !shouldContinue()) {
return false
}
await this.installPluginsOnRelay(mux)
if (shouldContinue && !shouldContinue()) {
return false
@ -454,6 +460,52 @@ export class SshRelaySession {
return true
}
// Why: the relay can inject ORCA_AGENT_HOOK_* env into SSH PTYs, but
// hook-script agents (Claude/Codex/Gemini/etc.) still need their config
// files on the remote host to call Orca's managed script. Install those
// configs before registering the PTY provider so newly spawned agent panes
// report status from their first prompt.
private async installManagedHooksOnRemote(mux: SshChannelMultiplexer): Promise<void> {
if (!isRemoteAgentHooksEnabled()) {
return
}
let remoteHome: string
try {
const result = (await mux.request('session.resolveHome', { path: '~' })) as {
resolvedPath?: unknown
}
if (typeof result.resolvedPath !== 'string' || result.resolvedPath.length === 0) {
console.warn(
`[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: could not resolve remote home`
)
return
}
remoteHome = result.resolvedPath
} catch (error) {
console.warn(
`[ssh-relay-session] skipped remote managed hook install for ${this.targetId}: ${
error instanceof Error ? error.message : String(error)
}`
)
return
}
let sftp: Awaited<ReturnType<SshConnection['sftp']>> | null = null
try {
sftp = await this.requireReadyConnection().sftp()
await installRemoteManagedAgentHooks(sftp, remoteHome)
} catch (error) {
console.warn(
`[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${
error instanceof Error ? error.message : String(error)
}`
)
} finally {
;(sftp as { end?: () => void } | null)?.end?.()
}
}
// Why: ship the OpenCode plugin / Pi extension source bodies to the relay
// so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR
// / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent

View File

@ -1995,6 +1995,132 @@ describe('useIpcEvents agent status snapshot integration', () => {
)
})
it('applies remote status snapshots while repo ownership is still hydrating', async () => {
const setAgentStatus = vi.fn()
const getSnapshot = vi.fn(() =>
Promise.resolve([
{
paneKey: FUTURE_PANE_KEY,
state: 'working' as const,
prompt: 'remote p',
agentType: 'codex',
worktreeId: 'wt-1',
connectionId: 'ssh-1',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
}
])
)
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'SSH Tab' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
repos: [],
worktreesByRepo: {}
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot,
onSet: () => () => {}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
expect(setAgentStatus).toHaveBeenCalledTimes(1)
expect(setAgentStatus).toHaveBeenCalledWith(
FUTURE_PANE_KEY,
expect.objectContaining({ state: 'working', prompt: 'remote p', agentType: 'codex' }),
'SSH Tab',
{ updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }
)
})
it('still rejects remote status events once the pane resolves to a local repo', async () => {
const setAgentStatus = vi.fn()
const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = {
current: null
}
const storeState: StoreLike = buildStoreState({
setAgentStatus,
workspaceSessionReady: true,
tabsByWorktree: {
'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Local Tab' }]
},
terminalLayoutsByTabId: {
'tab-future': {
root: { type: 'leaf', leafId: FUTURE_LEAF_ID },
activeLeafId: FUTURE_LEAF_ID,
expandedLeafId: null
}
},
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
})
stubReactSyncEffect()
vi.doMock('../store', () => ({
useAppStore: {
subscribe: vi.fn(() => () => {}),
getState: () => storeState
}
}))
stubAuxiliaryModules()
vi.stubGlobal(
'window',
buildWindowApi({
onSet: (cb) => {
onSetListenerRef.current = cb
return () => {}
}
})
)
const { useIpcEvents } = await import('./useIpcEvents')
useIpcEvents()
await Promise.resolve()
if (typeof onSetListenerRef.current !== 'function') {
throw new Error('Expected agentStatus.onSet listener to be registered')
}
onSetListenerRef.current({
paneKey: FUTURE_PANE_KEY,
state: 'working',
prompt: 'remote p',
agentType: 'codex',
worktreeId: 'wt-1',
connectionId: 'ssh-1',
receivedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000
})
expect(setAgentStatus).not.toHaveBeenCalled()
})
it('tracks ready push events whose paneKey does not resolve to a renderer tab', async () => {
const setAgentStatus = vi.fn()
const track = vi.fn()

View File

@ -1474,7 +1474,8 @@ export function useIpcEvents(): void {
if (!payload) {
return
}
const { exists, title, repoConnectionId } = resolvePaneKey(store, data.paneKey)
const { exists, title, repoConnectionId, repoConnectionResolved, owningWorktreeId } =
resolvePaneKey(store, data.paneKey)
if (!exists) {
// Why: empty paneKeys are dropped in main before IPC fanout. Reaching
// this branch means a non-empty paneKey escaped without a matching
@ -1496,7 +1497,22 @@ export function useIpcEvents(): void {
// The IPC contract declares connectionId as required (string | null),
// so the undefined branch only fires under dev hot-reload skew where
// the renderer bundle is newer than the preload bundle.
if (data.connectionId !== undefined && data.connectionId !== repoConnectionId) {
// Why: startup snapshot replay can beat repo/worktree hydration for SSH
// panes. If the pane is already present and the event's worktreeId
// matches that tab's worktree, accept the status until repo ownership
// becomes available; once ownership is resolved, keep the strict
// connectionId check below.
const canAcceptPendingRemoteOwnership =
data.connectionId !== undefined &&
data.connectionId !== null &&
!repoConnectionResolved &&
data.worktreeId !== undefined &&
data.worktreeId === owningWorktreeId
if (
data.connectionId !== undefined &&
data.connectionId !== repoConnectionId &&
!canAcceptPendingRemoteOwnership
) {
return
}
store.setAgentStatus(data.paneKey, payload, title, {
@ -1685,29 +1701,47 @@ export function useIpcEvents(): void {
}
/** Resolve a paneKey (tabId:leafId) to both a liveness check and the current
* title, and the connectionId of the repo that owns the pane's worktree.
* title, the pane's worktree, and the connectionId of the repo that owns it.
* Walks tabsByWorktree to locate the tab, then resolves the owning worktree
* and repo via cached selector maps. Used for agent type inference when the
* CLI payload omits agentType, plus to drop status updates targeted at panes
* whose tabs have already been torn down or whose owning connection is no
* longer live (see docs/design/agent-status-over-ssh.md §5).
* Why combined: callers need all three pieces per hook event, and hook
* Why combined: callers need all routing pieces per hook event, and hook
* events can fire many times per second during a tool-use run. Bundling
* liveness + title + connectionId into one helper keeps the per-event work
* in one place and avoids re-deriving the owning repo at the call site. */
function resolvePaneKey(
store: ReturnType<typeof useAppStore.getState>,
paneKey: string
): { exists: boolean; title: string | undefined; repoConnectionId: string | null } {
): {
exists: boolean
title: string | undefined
repoConnectionId: string | null
repoConnectionResolved: boolean
owningWorktreeId: string | undefined
} {
const parsed = parsePaneKey(paneKey)
if (!parsed) {
return { exists: false, title: undefined, repoConnectionId: null }
return {
exists: false,
title: undefined,
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined
}
}
const { tabId, leafId } = parsed
const layout = store.terminalLayoutsByTabId?.[tabId]
const leafExists = collectLeafIdsInOrder(layout?.root).includes(leafId)
if (!leafExists) {
return { exists: false, title: undefined, repoConnectionId: null }
return {
exists: false,
title: undefined,
repoConnectionId: null,
repoConnectionResolved: false,
owningWorktreeId: undefined
}
}
// Why: replay can remint numeric pane ids, so status title recovery must use
// persisted leaf-keyed titles when crossing from hook state into tab state.
@ -1734,16 +1768,24 @@ function resolvePaneKey(
}
}
// Why: ownership lookup is `tab → worktree → repo → repo.connectionId`.
// Treat unknown owner (no matching worktree/repo) as `null` so remote
// events stamped with a string connectionId are dropped by the caller —
// we cannot prove they belong to the currently-live local repo.
// Keep "resolved to a local repo" distinct from "not hydrated yet" so the
// caller can preserve strict filtering after hydration while accepting SSH
// snapshots that arrive during the startup ownership gap.
let repoConnectionId: string | null = null
let repoConnectionResolved = false
if (owningWorktreeId !== undefined) {
const worktree = getWorktreeMapFromState(store).get(owningWorktreeId)
if (worktree) {
const repo = getRepoMapFromState(store).get(worktree.repoId)
repoConnectionResolved = repo !== undefined
repoConnectionId = repo?.connectionId ?? null
}
}
return { exists, title: paneTitle ?? tabTitle, repoConnectionId }
return {
exists,
title: paneTitle ?? tabTitle,
repoConnectionId,
repoConnectionResolved,
owningWorktreeId
}
}