From ba0a5b47ed1c784ddc498ec99aaae0ceb4c920c8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 13 May 2026 22:09:32 -0700 Subject: [PATCH] feat(ssh): agent-status over SSH (#1702) Co-authored-by: Orca --- src/main/agent-hooks/server.test.ts | 179 +++++++++- src/main/agent-hooks/server.ts | 23 +- src/main/ipc/pty.test.ts | 117 +++++++ src/main/ipc/pty.ts | 26 +- ...ay-session-agent-hooks.integration.test.ts | 323 ++++++++++++++++++ src/main/ssh/ssh-relay-session.ts | 78 +++++ src/relay/agent-hook-integration.test.ts | 203 +++++++++++ src/relay/agent-hook-server.test.ts | 51 ++- src/relay/agent-hook-server.ts | 34 +- src/relay/pty-handler.test.ts | 85 +++++ src/relay/pty-handler.ts | 148 +++++++- src/relay/relay.ts | 80 ++++- src/renderer/src/hooks/useIpcEvents.test.ts | 193 +++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 55 ++- src/shared/agent-hook-listener.ts | 67 ++-- src/shared/agent-hook-relay.ts | 27 +- tests/e2e/global-setup.ts | 12 + tests/e2e/helpers/orca-app.ts | 6 + tests/e2e/ssh-localhost.spec.ts | 298 ++++++++++++++++ 19 files changed, 1925 insertions(+), 80 deletions(-) create mode 100644 src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts create mode 100644 src/relay/agent-hook-integration.test.ts create mode 100644 tests/e2e/ssh-localhost.spec.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index b8ca1ffbb5a..724fc3a4a64 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -14,7 +14,10 @@ import { import { tmpdir } from 'os' import { join } from 'path' import { AgentHookServer, _internals } from './server' -import { parseAgentStatusPayload } from '../../shared/agent-status-types' +import { + AGENT_STATUS_MAX_FIELD_LENGTH, + parseAgentStatusPayload +} from '../../shared/agent-status-types' const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() @@ -166,6 +169,119 @@ describe('AgentHookServer listener replay', () => { } }) + // Why: agent-status-over-SSH §3 — ingestRemote must run the same warn-once + // cross-build diagnostics the local HTTP path runs, so a remote source of + // genuinely stale hooks emits the same signal locally. + it('runs warn-once env/version diagnostics on relay-forwarded events', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const listener = vi.fn() + server.setListener(listener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'development', + version: '999', + payload: { + state: 'working', + paneKey: PANE, + updatedAt: Date.now(), + agentType: 'claude' + } + }, + 'conn-1' + ) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: PANE, + connectionId: 'conn-1', + payload: expect.objectContaining({ state: 'working', agentType: 'claude' }) + }) + ) + + const warnCalls = warn.mock.calls.map((c) => String(c[0])) + expect(warnCalls.some((m) => m.includes('v999'))).toBe(true) + expect(warnCalls.some((m) => m.includes('development') && m.includes('production'))).toBe( + true + ) + + const warnsAfterFirst = warn.mock.calls.length + server.ingestRemote( + { + paneKey: 'tab-2:0', + env: 'development', + version: '999', + payload: { + state: 'working', + paneKey: 'tab-2:0', + updatedAt: Date.now(), + agentType: 'claude' + } + }, + 'conn-1' + ) + expect(warn.mock.calls.length).toBe(warnsAfterFirst) + // Why: pin both invariants — warn-once dedupe AND fanout still fires for + // the second event. Without the second assertion, a future refactor that + // drops the second event silently would still leave warn-count unchanged. + expect(listener).toHaveBeenCalledTimes(2) + } finally { + server.stop() + } + }) + + it('treats remote env as normal relay traffic and normalizes payload at the trust boundary', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production' }) + try { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const listener = vi.fn() + server.setListener(listener) + + const oversizedPrompt = 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH + 50) + server.ingestRemote( + { + paneKey: ' tab-3:0 ', + tabId: ' tab-3 ', + worktreeId: ' wt-3 ', + env: 'remote', + version: '1', + payload: { + state: 'done', + prompt: oversizedPrompt, + agentType: 'codex' + } + }, + ' conn-9 ' + ) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: 'tab-3:0', + tabId: 'tab-3', + worktreeId: 'wt-3', + connectionId: 'conn-9', + payload: expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'x'.repeat(AGENT_STATUS_MAX_FIELD_LENGTH) + }) + }) + ) + expect(warn).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('accepts form-encoded hook posts from Unix managed scripts', async () => { const server = new AgentHookServer() await server.start({ env: 'production' }) @@ -1490,6 +1606,67 @@ describe('Endpoint file lifecycle', () => { } }) + it('ingestRemote stamps connectionId and feeds the listener bypassing HTTP', () => { + const server = new AgentHookServer() + const events: { paneKey: string; connectionId: string | null; payload: unknown }[] = [] + server.setListener((evt) => { + events.push({ + paneKey: evt.paneKey, + connectionId: evt.connectionId, + payload: evt.payload + }) + }) + try { + server.ingestRemote( + { + paneKey: 'tab-3:0', + tabId: 'tab-3', + worktreeId: 'wt-3', + payload: { + state: 'working', + prompt: 'remote prompt', + agentType: 'claude' + } + }, + 'conn-42' + ) + expect(events).toHaveLength(1) + expect(events[0].paneKey).toBe('tab-3:0') + expect(events[0].connectionId).toBe('conn-42') + expect(events[0].payload).toMatchObject({ + state: 'working', + prompt: 'remote prompt', + agentType: 'claude' + }) + } finally { + server.setListener(null) + } + }) + + it('ingestRemote ignores malformed envelopes (fail-open)', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.setListener(listener) + try { + // Missing paneKey + server.ingestRemote({ paneKey: '', payload: { state: 'working' } } as never, 'conn-x') + // Missing payload state + server.ingestRemote({ paneKey: 'tab-1:0', payload: { foo: 'bar' } }, 'conn-x') + // Invalid payload state + server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'nonsense' } }, 'conn-x') + // Empty connection id + server.ingestRemote({ paneKey: 'tab-1:0', payload: { state: 'working' } }, ' ') + // Wrong types + server.ingestRemote( + { paneKey: 'tab-1:0', payload: 'not-an-object' as unknown } as never, + 'conn-x' + ) + expect(listener).not.toHaveBeenCalled() + } finally { + server.setListener(null) + } + }) + it('endpoint file contents are re-parseable by /bin/sh', async () => { if (process.platform === 'win32') { return diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 3130f65f509..241a0b2e8b1 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -28,6 +28,7 @@ import { parseFormEncodedBody, readRequestBody, resolveHookSource, + warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, type HookListenerState @@ -269,19 +270,17 @@ export class AgentHookServer { /** Ingest a payload that arrived over the relay JSON-RPC channel rather * than the local HTTP server. `connectionId` is the SshChannelMultiplexer * identity Orca holds (the wire envelope carries connectionId: null and - * Orca stamps the real value here). The relay pre-normalizes the inner - * payload via the shared listener module; we re-run the canonical - * normalizer here as a defense-in-depth check at the trust boundary - * before feeding the event into the same `onAgentStatus` fanout the HTTP - * path uses. See docs/design/agent-status-over-ssh.md §5. */ + * Orca stamps the real value here). The relay has already normalized the + * payload via the shared listener module, but main is still the SSH trust + * boundary: re-run the canonical status normalizer before caching or + * persisting anything. The `env`/`version` fields are forwarded verbatim + * from the agent CLI's POST body on the remote and validated here so the + * warn-once diagnostics fire for real cross-build mismatches. */ ingestRemote( envelope: { paneKey: string tabId?: string worktreeId?: string - // Why: forwarded verbatim from the agent CLI POST body on the remote so - // the warn-once cross-build / dev-vs-prod diagnostics fire identically - // to the local HTTP path. Declared on the type now; consumed in PR2. env?: string version?: string payload: unknown @@ -339,6 +338,14 @@ export class AgentHookServer { if (!normalizedPayload) { return } + // Why: run the same warn-once diagnostics the HTTP path runs (cross-build + // version mismatch, dev-vs-prod env mismatch). Use `this.env` as the + // expected env so the messages match what the local server produces. + warnOnHookEnvOrVersionMismatch(this.state, { + version: envelope.version, + env: envelope.env, + expectedEnv: this.env + }) const event: AgentHookEventPayload = { paneKey, tabId, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 2bf5769a20d..195685c90f2 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -945,6 +945,7 @@ describe('registerPtyHandlers', () => { // shipping any of them to a remote shell is at best useless and at // worst a credential leak. expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() expect(env.OPENCODE_CONFIG_DIR).toBeUndefined() expect(env.ORCA_OPENCODE_CONFIG_DIR).toBeUndefined() @@ -1227,6 +1228,122 @@ describe('registerPtyHandlers', () => { ) expect(runtime.onPtyExit).not.toHaveBeenCalled() }) + + it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when feature flag is off', async () => { + const sshSpawn = vi.fn(async (_opts: { env: Record }) => ({ + id: 'ssh-pty' + })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + try { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { + FOO: 'bar', + ORCA_PANE_KEY: 'tab-1:0', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'wt-1' + }, + connectionId: 'ssh-1' + }) + const env = sshSpawn.mock.calls.at(-1)![0].env + expect(env.FOO).toBe('bar') + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(env.ORCA_TAB_ID).toBeUndefined() + expect(env.ORCA_WORKTREE_ID).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + } finally { + if (prevFlag === undefined) { + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + } else { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag + } + } + }) + + it('forwards ORCA_PANE_KEY/TAB_ID/WORKTREE_ID over SSH when feature flag is on', async () => { + const sshSpawn = vi.fn(async (_opts: { env: Record }) => ({ + id: 'ssh-pty' + })) + registerSshPtyProvider('ssh-1', { + spawn: sshSpawn, + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + handlers.clear() + registerPtyHandlers(mainWindow as never) + const prevFlag = process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + try { + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + env: { + FOO: 'bar', + ORCA_PANE_KEY: 'tab-2:0', + ORCA_TAB_ID: 'tab-2', + ORCA_WORKTREE_ID: 'wt-2' + }, + connectionId: 'ssh-1' + }) + const env = sshSpawn.mock.calls.at(-1)![0].env + expect(env.ORCA_PANE_KEY).toBe('tab-2:0') + expect(env.ORCA_TAB_ID).toBe('tab-2') + expect(env.ORCA_WORKTREE_ID).toBe('wt-2') + // Local hook server coords still must NOT cross the wire — the + // relay is the source of truth for those. + expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined() + expect(env.ORCA_AGENT_HOOK_PORT).toBeUndefined() + } finally { + if (prevFlag === undefined) { + delete process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS + } else { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = prevFlag + } + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 011bdb33642..7c483712e69 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -36,6 +36,7 @@ import { launchSourceSchema, requestKindSchema } from '../../shared/telemetry-events' +import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay' import { readShellStartupEnvVar } from '../pty/shell-startup-env' // ─── Provider Registry ────────────────────────────────────────────── @@ -1059,7 +1060,30 @@ export function registerPtyHandlers( const isMintedSessionId = args.sessionId === undefined && isDaemonHostSpawn const effectiveSessionId = args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined) - const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env + // Why: the renderer unconditionally sets ORCA_PANE_KEY/TAB_ID/WORKTREE_ID + // on every spawn, including SSH ones (see pty-connection.ts). When the + // remote-agent-hook feature is OFF, the relay-side hook server is not + // wired up and forwarding these vars across the SSH wire would let a + // future relay build start posting hook events Orca cannot route. Strip + // them on the SSH path while the flag is off; flag ON keeps them so the + // relay's pty-handler sees the paneKey on spawn env. See + // docs/design/agent-status-over-ssh.md §8 (commit #6 gate location a). + let sshSourceEnv = args.env + if (args.connectionId && !isRemoteAgentHooksEnabled()) { + if ( + sshSourceEnv && + ('ORCA_PANE_KEY' in sshSourceEnv || + 'ORCA_TAB_ID' in sshSourceEnv || + 'ORCA_WORKTREE_ID' in sshSourceEnv) + ) { + const stripped = { ...sshSourceEnv } + delete stripped.ORCA_PANE_KEY + delete stripped.ORCA_TAB_ID + delete stripped.ORCA_WORKTREE_ID + sshSourceEnv = stripped + } + } + const baseEnv = claudeAuth ? { ...sshSourceEnv, ...claudeAuth.envPatch } : sshSourceEnv let env: Record | undefined = baseEnv const preAllocatedHandle = runtime && !(provider instanceof LocalPtyProvider) diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts new file mode 100644 index 00000000000..2aa5347ec6f --- /dev/null +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { Store } from '../persistence' +import type { SshPortForwardManager } from './ssh-port-forward' +import type { SshConnection } from './ssh-connection' +import type { MultiplexerTransport } from './ssh-channel-multiplexer' +import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import { RelayDispatcher } from '../../relay/dispatcher' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD, + ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, + REMOTE_AGENT_HOOK_ENV +} from '../../shared/agent-hook-relay' +import { agentHookServer, _internals as agentHookInternals } from '../agent-hooks/server' +import { getSshPtyProvider } from '../ipc/pty' + +vi.mock('./ssh-relay-deploy', () => ({ + deployAndLaunchRelay: vi.fn() +})) + +const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +const { SshRelaySession } = await import('./ssh-relay-session') + +type CapturedStatus = { + paneKey: string + tabId?: string + worktreeId?: string + connectionId: string | null + payload: { + state: string + prompt: string + agentType?: string + toolName?: string + } +} + +type FakeRelay = { + transport: MultiplexerTransport + dispatcher: RelayDispatcher + ptySpawnRequests: Record[] + replayEnvelopes: AgentHookRelayEnvelope[] + notifyAgentHook: (envelope: AgentHookRelayEnvelope | Record) => void + dispose: () => void +} + +// Why: mock below SSH at the relay transport boundary so CI covers session, +// mux, provider, and hook-ingest wiring without relying on a local sshd. +function createFakeRelay(): FakeRelay { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + const clientCloseCallbacks: (() => void)[] = [] + const ptySpawnRequests: Record[] = [] + const replayEnvelopes: AgentHookRelayEnvelope[] = [] + + const transport: MultiplexerTransport = { + write: (data) => { + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: (cb) => { + clientCloseCallbacks.push(cb) + }, + close: () => { + for (const cb of clientCloseCallbacks) { + cb() + } + } + } + + const dispatcher = new RelayDispatcher((data) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }) + relayFeed = (data) => dispatcher.feed(data) + + dispatcher.onRequest('session.resolveHome', async (params) => ({ + resolvedPath: params.path === '~' ? '/home/orca' : params.path + })) + dispatcher.onRequest('git.listWorktrees', async () => []) + dispatcher.onRequest('ports.detect', async () => ({ ports: [], platform: 'linux' })) + dispatcher.onRequest('pty.spawn', async (params) => { + ptySpawnRequests.push(params) + return { id: `remote-pty-${ptySpawnRequests.length}` } + }) + dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { + // Why: relay replay must arrive after Orca wires its listener and before + // the request resolves, matching the real relay ordering contract. + for (const envelope of replayEnvelopes) { + dispatcher.notify( + AGENT_HOOK_NOTIFICATION_METHOD, + envelope as unknown as Record + ) + } + return { replayed: replayEnvelopes.length } + }) + + return { + transport, + dispatcher, + ptySpawnRequests, + replayEnvelopes, + notifyAgentHook: (envelope) => { + dispatcher.notify(AGENT_HOOK_NOTIFICATION_METHOD, envelope as Record) + }, + dispose: () => dispatcher.dispose() + } +} + +function createSession(targetId: string): InstanceType { + const store = { + getRepos: vi.fn().mockReturnValue([]), + getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyLeases: vi.fn() + } as unknown as Store + const portForwardManager = { + removeAllForwards: vi.fn().mockResolvedValue(undefined) + } as unknown as SshPortForwardManager + const getMainWindow = vi.fn().mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + return new SshRelaySession(targetId, getMainWindow, store, portForwardManager) +} + +async function waitForStatusCount(events: CapturedStatus[], count: number): Promise { + await vi.waitFor(() => expect(events).toHaveLength(count), { timeout: 1500 }) +} + +function captureAgentStatuses(events: CapturedStatus[]): void { + agentHookServer.setListener((event) => { + events.push({ + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + connectionId: event.connectionId, + payload: { + state: event.payload.state, + prompt: event.payload.prompt, + agentType: event.payload.agentType, + toolName: event.payload.toolName + } + }) + }) +} + +function makeEnvelope(overrides: Partial = {}): AgentHookRelayEnvelope { + return { + source: 'codex', + paneKey: 'tab-ssh:0', + tabId: 'tab-ssh', + worktreeId: 'wt-ssh', + connectionId: null, + env: REMOTE_AGENT_HOOK_ENV, + version: '1', + payload: { + state: 'working', + prompt: 'remote prompt', + agentType: 'codex' + }, + ...overrides + } +} + +describe('SshRelaySession agent hooks over a fake relay transport', () => { + let previousRemoteHooksFlag: string | undefined + let warnSpy: ReturnType + let session: InstanceType | null = null + let relay: FakeRelay | null = null + + beforeEach(() => { + vi.clearAllMocks() + previousRemoteHooksFlag = process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] + process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = '1' + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + agentHookServer.setListener(null) + agentHookInternals.resetCachesForTests() + }) + + afterEach(() => { + session?.dispose() + relay?.dispose() + session = null + relay = null + agentHookServer.setListener(null) + agentHookInternals.resetCachesForTests() + warnSpy.mockRestore() + if (previousRemoteHooksFlag === undefined) { + delete process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] + } else { + process.env[ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV] = previousRemoteHooksFlag + } + }) + + it('establishes through a fake relay, spawns a remote PTY, and forwards agent status', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + + session = createSession('conn-fake') + await session.establish({} as SshConnection) + + const provider = getSshPtyProvider('conn-fake') + expect(provider).toBeDefined() + const spawn = await provider!.spawn({ + cols: 120, + rows: 40, + cwd: '/home/orca/project', + env: { + ORCA_PANE_KEY: 'tab-ssh:0', + ORCA_TAB_ID: 'tab-ssh', + ORCA_WORKTREE_ID: 'wt-ssh' + } + }) + + expect(spawn.id).toBe('remote-pty-1') + expect(relay.ptySpawnRequests).toHaveLength(1) + expect(relay.ptySpawnRequests[0]).toMatchObject({ + cwd: '/home/orca/project', + env: { + ORCA_PANE_KEY: 'tab-ssh:0', + ORCA_TAB_ID: 'tab-ssh', + ORCA_WORKTREE_ID: 'wt-ssh' + } + }) + + relay.notifyAgentHook(makeEnvelope()) + + await waitForStatusCount(events, 1) + expect(events[0]).toEqual({ + paneKey: 'tab-ssh:0', + tabId: 'tab-ssh', + worktreeId: 'wt-ssh', + connectionId: 'conn-fake', + payload: { + state: 'working', + prompt: 'remote prompt', + agentType: 'codex', + toolName: undefined + } + }) + }) + + it('asks the fake relay for cached hook replay after the session wires its listener', async () => { + relay = createFakeRelay() + relay.replayEnvelopes.push( + makeEnvelope({ + paneKey: 'tab-replay:0', + tabId: 'tab-replay', + worktreeId: 'wt-replay', + payload: { + state: 'waiting', + prompt: 'cached remote prompt', + agentType: 'claude', + toolName: 'Bash' + } + }) + ) + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + + session = createSession('conn-replay') + await session.establish({} as SshConnection) + + await waitForStatusCount(events, 1) + expect(events[0]).toMatchObject({ + paneKey: 'tab-replay:0', + tabId: 'tab-replay', + worktreeId: 'wt-replay', + connectionId: 'conn-replay', + payload: { + state: 'waiting', + prompt: 'cached remote prompt', + agentType: 'claude', + toolName: 'Bash' + } + }) + }) + + it('drops malformed remote hook notifications at Orca main before caching', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + + session = createSession('conn-validate') + await session.establish({} as SshConnection) + + relay.notifyAgentHook({ + source: 'codex', + paneKey: 'tab-bad:0', + connectionId: null, + env: REMOTE_AGENT_HOOK_ENV, + version: '1', + payload: { + state: 'not-a-real-state', + prompt: 'should not be cached', + agentType: 'codex' + } + }) + + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(0) + expect(agentHookServer.getStatusSnapshot()).toEqual([]) + }) +}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index acceb7e55b1..61074ed65ea 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -16,6 +16,12 @@ import { SshChannelMultiplexer } from './ssh-channel-multiplexer' import { SshPtyProvider, isSshPtyNotFoundError } from '../providers/ssh-pty-provider' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' import { SshGitProvider } from '../providers/ssh-git-provider' +import { agentHookServer } from '../agent-hooks/server' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD, + isRemoteAgentHooksEnabled +} from '../../shared/agent-hook-relay' import { registerSshPtyProvider, unregisterSshPtyProvider, @@ -409,9 +415,81 @@ export class SshRelaySession { registerSshGitProvider(this.targetId, gitProvider) this.wireUpPtyEvents(ptyProvider) + this.wireUpAgentHookEvents(mux) return true } + // Why: route the relay's `agent.hook` JSON-RPC notification into Orca's + // shared `agentHookServer` via `ingestRemote`. The wire envelope carries + // `connectionId: null` (the relay does not know Orca's local handle); we + // stamp the real value here from `this.targetId` so the renderer can drop + // in-flight events for connections that have torn down. After wiring is + // in place we kick off a request-driven replay so any cached payload from + // before the channel was up survives the reconnect — see §5 Path 3. + // + // The Orca-side mux's `notificationHandlers` is a flat array — each + // handler must filter by method name itself. + private wireUpAgentHookEvents(mux: SshChannelMultiplexer): void { + if (!isRemoteAgentHooksEnabled()) { + return + } + mux.onNotification((method, params) => { + if (method !== AGENT_HOOK_NOTIFICATION_METHOD) { + return + } + const envelope = params as { + paneKey?: unknown + tabId?: unknown + worktreeId?: unknown + env?: unknown + version?: unknown + payload?: unknown + } + if (typeof envelope.paneKey !== 'string') { + return + } + // Why: forward env/version verbatim so Orca's warn-once cross-build / + // dev-vs-prod diagnostics fire on remote events the same as on local + // ones — see docs/design/agent-status-over-ssh.md §3 ("Replay / + // version mismatch") and the relay's wire envelope at + // src/shared/agent-hook-relay.ts. + agentHookServer.ingestRemote( + { + paneKey: envelope.paneKey, + tabId: typeof envelope.tabId === 'string' ? envelope.tabId : undefined, + worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined, + env: typeof envelope.env === 'string' ? envelope.env : undefined, + version: typeof envelope.version === 'string' ? envelope.version : undefined, + payload: envelope.payload + }, + this.targetId + ) + }) + + // Why: ask the relay to replay every cached paneKey it remembers. Issued + // *after* the handler is wired so the request-driven replay shape + // strictly trails our subscription on the dispatcher's single write + // callback. Best-effort: a relay that does not know the method + // (e.g. older relay binary) returns -32601, which we swallow. + void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch((err) => { + const code = (err as { code?: unknown })?.code + if (code === -32601) { + return + } + // Why: a normal disconnect/teardown rejects the in-flight request with + // "Multiplexer disposed"; suppress the warn for that path so reconnect + // cycles aren't noisy. + if (mux.isDisposed()) { + return + } + console.warn( + `[ssh-relay-session] agent_hook.requestReplay failed for ${this.targetId}: ${ + err instanceof Error ? err.message : String(err) + }` + ) + }) + } + private teardownProviders(reason: 'shutdown' | 'connection_lost'): void { this.muxDisposeCleanup?.() this.muxDisposeCleanup = null diff --git a/src/relay/agent-hook-integration.test.ts b/src/relay/agent-hook-integration.test.ts new file mode 100644 index 00000000000..29466576c54 --- /dev/null +++ b/src/relay/agent-hook-integration.test.ts @@ -0,0 +1,203 @@ +/** + * End-to-end agent-status-over-SSH integration test. + * + * Wires Orca's main-side SshChannelMultiplexer to the relay-side + * RelayDispatcher through an in-memory pipe and starts a real + * RelayAgentHookServer. POSTs a hook event to the relay's loopback HTTP + * receiver and asserts the parsed payload arrives in `agentHookServer`'s + * onAgentStatus listener through the `agent.hook` JSON-RPC notification path. + * + * This is the test the design doc (§9 step 5/6) calls "the SSH provider gets + * at least one integration test that round-trips a hook event from a remote + * PTY back to AgentStatus state." + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { RelayDispatcher } from './dispatcher' +import { RelayAgentHookServer } from './agent-hook-server' +import { + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD +} from '../shared/agent-hook-relay' +import { AgentHookServer } from '../main/agent-hooks/server' + +describe('Integration: relay hook server → mux → AgentHookServer.ingestRemote', () => { + let tmpDir: string + let mux: SshChannelMultiplexer + let dispatcher: RelayDispatcher + let hookServer: RelayAgentHookServer + let orcaServer: AgentHookServer + + beforeEach(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'agent-hook-e2e-')) + + let relayFeedFn: ((data: Buffer) => void) | undefined + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + setImmediate(() => relayFeedFn?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + // Why: MultiplexerTransport.onClose is a required field; the test never + // simulates a transport close, so register a no-op rather than tracking + // callbacks that nothing invokes. + onClose: () => {} + } + + dispatcher = new RelayDispatcher((data: Buffer) => { + setImmediate(() => { + for (const cb of clientDataCallbacks) { + cb(data) + } + }) + }) + relayFeedFn = (data: Buffer) => dispatcher.feed(data) + + hookServer = new RelayAgentHookServer({ + endpointDir: tmpDir, + forward: (envelope) => { + dispatcher.notify( + AGENT_HOOK_NOTIFICATION_METHOD, + envelope as unknown as Record + ) + } + }) + await hookServer.start() + + dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { + const replayed = hookServer.replayCachedPayloadsForPanes() + return { replayed } + }) + + mux = new SshChannelMultiplexer(clientTransport) + + orcaServer = new AgentHookServer() + // Why: Orca-side never starts an HTTP server in this test — `ingestRemote` + // is the entry point we exercise. setListener registers the IPC fanout + // sink we assert against. Server is otherwise inert. + mux.onNotification((method, params) => { + if (method === AGENT_HOOK_NOTIFICATION_METHOD) { + // Why: `connectionId` is normally derived from the mux identity at + // the call site. For the in-memory test we use a fixed string. + orcaServer.ingestRemote( + params as unknown as { + paneKey: string + tabId?: string + worktreeId?: string + payload: unknown + }, + 'conn-test' + ) + } + }) + }) + + afterEach(async () => { + mux.dispose() + dispatcher.dispose() + hookServer.stop() + orcaServer.stop() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('forwards a Claude UserPromptSubmit POST through to ingestRemote', async () => { + const events: { paneKey: string; payload: unknown; connectionId: string | null }[] = [] + orcaServer.setListener((event) => { + events.push({ + paneKey: event.paneKey, + payload: event.payload, + connectionId: event.connectionId + }) + }) + + const { port, token } = hookServer.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-7:0', + tabId: 'tab-7', + worktreeId: 'wt-7', + env: 'remote', + version: '1', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'roundtrip' } + }) + }) + expect(res.status).toBe(204) + + // Why: dispatcher → transport setImmediate → mux feed → handler all run + // on the next tick(s); spin until our sink captures the event or we + // hit a generous timeout. + const start = Date.now() + while (events.length === 0 && Date.now() - start < 1500) { + await new Promise((r) => setImmediate(r)) + } + expect(events).toHaveLength(1) + expect(events[0].paneKey).toBe('tab-7:0') + expect(events[0].connectionId).toBe('conn-test') + const payload = events[0].payload as { state: string; prompt: string; agentType: string } + expect(payload.state).toBe('working') + expect(payload.prompt).toBe('roundtrip') + expect(payload.agentType).toBe('claude') + }) + + it('replays the cached last-status on agent_hook.requestReplay', async () => { + // Why: register the listener BEFORE the initial POST so live notifications + // are observed. setListener on a non-empty cache replays cached entries + // synchronously; if we set it AFTER the POST drains, the assertion below + // would pass without the relay's replay actually crossing the wire. + const events: { paneKey: string; payload: unknown }[] = [] + orcaServer.setListener((event) => { + events.push({ paneKey: event.paneKey, payload: event.payload }) + }) + + const { port, token } = hookServer.getCoordinates() + await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-9:0', + payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cached' } + }) + }) + + // Why: spin until the live notification arrives so the replay request + // below produces a strictly-second event in `events`. + const liveStart = Date.now() + while (events.length === 0 && Date.now() - liveStart < 1500) { + await new Promise((r) => setImmediate(r)) + } + expect(events).toHaveLength(1) + + const result = (await mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD)) as { + replayed: number + } + expect(result.replayed).toBe(1) + + // Why: relay-side replay produces a fresh notification; spin until it + // arrives so the assertion below proves the round-trip (relay → wire → + // mux → ingestRemote → listener) rather than just the relay-side count. + const replayStart = Date.now() + while (events.length < 2 && Date.now() - replayStart < 1500) { + await new Promise((r) => setImmediate(r)) + } + expect(events).toHaveLength(2) + expect(events[1].paneKey).toBe('tab-9:0') + }) +}) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 700cb6426fa..51fc046525b 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' -import { RelayAgentHookServer } from './agent-hook-server' +import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' describe('RelayAgentHookServer', () => { @@ -14,6 +14,15 @@ describe('RelayAgentHookServer', () => { rmSync(dir, { recursive: true, force: true }) }) + it('scopes endpoint files by relay socket path', () => { + const first = endpointDirForRelaySocket(join(dir, 'relay-a.sock')) + const second = endpointDirForRelaySocket(join(dir, 'relay-b.sock')) + + expect(first).toBe(join(dir, 'agent-hooks', 'relay-a.sock')) + expect(second).toBe(join(dir, 'agent-hooks', 'relay-b.sock')) + expect(first).not.toBe(second) + }) + it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) @@ -44,8 +53,8 @@ describe('RelayAgentHookServer', () => { expect(envelope.connectionId).toBeNull() expect(envelope.payload.state).toBe('working') expect(envelope.payload.prompt).toBe('hi') - // Why: the relay forwards body env/version verbatim so Orca's existing - // warn-once cross-build / dev-vs-prod diagnostics still fire on remote. + // Why: the relay forwards body env/version so Orca's warn-once + // protocol diagnostics and remote-location marker survive the wire. expect(envelope.env).toBe('remote') expect(envelope.version).toBe('1') } finally { @@ -100,8 +109,7 @@ describe('RelayAgentHookServer', () => { expect(forward).toHaveBeenCalledTimes(1) expect(forward.mock.calls[0][0].payload.prompt).toBe('cache me') // Why: replay must preserve the wire envelope's env/version (and source) - // so Orca's warn-once cross-build / dev-vs-prod diagnostics fire on - // replayed events the same as on live POST events. + // so protocol diagnostics and the remote-location marker survive replay. expect(forward.mock.calls[0][0].source).toBe('claude') expect(forward.mock.calls[0][0].env).toBe('remote') expect(forward.mock.calls[0][0].version).toBe('1') @@ -137,6 +145,39 @@ describe('RelayAgentHookServer', () => { } }) + // Why: the relay should still drop malformed HTTP events before they reach + // the wire, even though Orca main re-validates at the SSH trust boundary. + it('does not forward when normalizeHookPayload rejects the event', async () => { + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const res = await fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: 'tab-1:0', + tabId: 'tab-1', + worktreeId: 'wt-1', + // Why: bogus hook_event_name — normalizeClaudeEvent returns null for + // any value outside its known set, which propagates up so + // normalizeHookPayload returns null. + payload: { hook_event_name: 'BogusEvent', prompt: 'ignored' } + }) + }) + // Why: hook server fails open with 204 even on rejected input — the + // contract is "never block the agent", not "tell the agent it lost". + expect(res.status).toBe(204) + expect(forward).not.toHaveBeenCalled() + } finally { + server.stop() + } + }) + it('exposes ORCA_AGENT_HOOK_* env vars after start', async () => { const forward = vi.fn() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index ca7e3ead63c..7865d018b54 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -10,7 +10,7 @@ // request-driven replay) for the rationale. import { createServer, type IncomingMessage, type ServerResponse } from 'http' import { randomUUID } from 'crypto' -import { join } from 'path' +import { basename, dirname, join } from 'path' import { homedir } from 'os' import { ORCA_HOOK_PROTOCOL_VERSION } from '../shared/agent-hook-types' @@ -27,7 +27,11 @@ import { type AgentHookEventPayload, type HookListenerState } from '../shared/agent-hook-listener' -import type { AgentHookRelayEnvelope, AgentHookSource } from '../shared/agent-hook-relay' +import { + REMOTE_AGENT_HOOK_ENV, + type AgentHookRelayEnvelope, + type AgentHookSource +} from '../shared/agent-hook-relay' export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void @@ -38,15 +42,25 @@ export type RelayHookForward = (envelope: AgentHookRelayEnvelope) => void const RELAY_HOOKS_DIR_NAME = '.orca-relay' const RELAY_HOOKS_SUBDIR = 'agent-hooks' +// Why: cap env/version metadata at 64 chars so a misbehaving agent CLI +// cannot grow lastEnvelopeMetaByPaneKey unboundedly per pane via the cache +// + replay path. Canonical values are short ('production'/'development', +// '1'/'999'); anything longer is treated as absent. +const MAX_HOOK_META_LEN = 64 + function defaultEndpointDir(): string { return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) } +export function endpointDirForRelaySocket(sockPath: string): string { + return join(dirname(sockPath), RELAY_HOOKS_SUBDIR, basename(sockPath)) +} + export type RelayHookServerOptions = { /** Where to put endpoint.env / endpoint.cmd. Defaults to `$HOME/.orca-relay/agent-hooks`. */ endpointDir?: string - /** Env tag forwarded into hook payloads (warn-once cross-build diagnostic). - * Defaults to "remote" — distinct from Orca's local 'production'/'development'. */ + /** Env tag forwarded into hook payloads. Defaults to "remote", a relay + * location marker that main excludes from dev-vs-prod mismatch warnings. */ env?: string /** Called once per parsed payload. The relay wires this to * `dispatcher.notify('agent.hook', envelope)`. */ @@ -76,7 +90,7 @@ export class RelayAgentHookServer { private forward: RelayHookForward constructor(options: RelayHookServerOptions) { - this.env = options.env ?? 'remote' + this.env = options.env ?? REMOTE_AGENT_HOOK_ENV this.endpointDir = options.endpointDir ?? defaultEndpointDir() this.endpointFilePath = join(this.endpointDir, getEndpointFileName()) this.forward = options.forward @@ -252,7 +266,10 @@ export class RelayAgentHookServer { return undefined } const v = (body as Record).env - return typeof v === 'string' && v.length > 0 ? v : undefined + if (typeof v !== 'string' || v.length === 0 || v.length > MAX_HOOK_META_LEN) { + return undefined + } + return v } private bodyVersion(body: unknown): string | undefined { @@ -260,6 +277,9 @@ export class RelayAgentHookServer { return undefined } const v = (body as Record).version - return typeof v === 'string' && v.length > 0 ? v : undefined + if (typeof v !== 'string' || v.length === 0 || v.length > MAX_HOOK_META_LEN) { + return undefined + } + return v } } diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index c204ac2958b..ec34881e9a2 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -409,6 +409,91 @@ describe('PtyHandler', () => { }) }) + it('applies env augmenters after process.env and renderer-supplied env (augmenter wins on key conflict)', async () => { + handler.addEnvAugmenter(() => ({ + ORCA_AGENT_HOOK_PORT: '12345', + ORCA_AGENT_HOOK_TOKEN: 'abc-uuid', + // Why: also override a key the renderer supplied below so the test pins + // the documented "augmenter wins on key conflict" invariant — see the + // doc-comment on addEnvAugmenter in pty-handler.ts. + ORCA_PANE_KEY: 'augmenter-wins' + })) + + await dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + env: { ORCA_PANE_KEY: 'tab-1:0', ORCA_TAB_ID: 'tab-1' } + }) + + expect(mockPtySpawn).toHaveBeenCalled() + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') + expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') + // Augmenter override beats the renderer-supplied value: + expect(callArgs.env.ORCA_PANE_KEY).toBe('augmenter-wins') + // Renderer-supplied keys not in augmenter map flow through: + expect(callArgs.env.ORCA_TAB_ID).toBe('tab-1') + }) + + it('revive restores pane identity env alongside hook-server coordinates', async () => { + await dispatcher.callRequest('pty.spawn', { + cols: 90, + rows: 30, + cwd: '/tmp', + env: { + ORCA_PANE_KEY: 'tab-5:1', + ORCA_TAB_ID: 'tab-5', + ORCA_WORKTREE_ID: 'wt-5' + } + }) + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_AGENT_HOOK_PORT: '12345', + ORCA_AGENT_HOOK_TOKEN: 'abc-uuid' + })) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + expect(mockPtySpawn).toHaveBeenCalledTimes(1) + const callArgs = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(callArgs.env.ORCA_PANE_KEY).toBe('tab-5:1') + expect(callArgs.env.ORCA_TAB_ID).toBe('tab-5') + expect(callArgs.env.ORCA_WORKTREE_ID).toBe('wt-5') + expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') + expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') + }) + + it('invokes the exit listener with the spawn-time paneKey', async () => { + let onExitCb: ((evt: { exitCode: number }) => void) | undefined + mockPtySpawn.mockReturnValue({ + ...mockPtyInstance, + onData: vi.fn(), + onExit: vi.fn((cb: (evt: { exitCode: number }) => void) => { + onExitCb = cb + }) + }) + + const exits: { id: string; paneKey?: string }[] = [] + handler.setExitListener((evt) => exits.push(evt)) + + await dispatcher.callRequest('pty.spawn', { + env: { ORCA_PANE_KEY: 'tab-2:1' } + }) + expect(onExitCb).toBeDefined() + onExitCb!({ exitCode: 0 }) + + expect(exits).toEqual([{ id: 'pty-1', paneKey: 'tab-2:1' }]) + }) + it('dispose kills all PTYs with SIGKILL', async () => { const mockKill = vi.fn() mockPtySpawn.mockReturnValue({ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 1949db301c8..b36b7b43180 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -39,6 +39,12 @@ type ManagedPty = { * entry-point calls into a clean "not found" error instead of a silent no-op * (POSIX proc.kill is neutralized inside disposeManagedPty). */ disposed?: boolean + /** Renderer-supplied paneKey from spawn env (ORCA_PANE_KEY). Captured so + * external observers (the relay-hook-server cache) can evict per-pane + * state when this PTY exits. Symmetric with Orca's local pty.ts. */ + paneKey?: string + tabId?: string + worktreeId?: string } function disposeManagedPty(managed: ManagedPty): void { @@ -84,7 +90,19 @@ const ALLOWED_SIGNALS = new Set([ 'SIGUSR2' ]) -type SerializedPtyEntry = { id: string; pid: number; cols: number; rows: number; cwd: string } +type SerializedPtyEntry = { + id: string + pid: number + cols: number + rows: number + cwd: string + paneKey?: string + tabId?: string + worktreeId?: string +} + +export type PtyExitListener = (event: { id: string; paneKey?: string }) => void +export type PtyEnvAugmenter = () => Record export class PtyHandler { private ptys = new Map() @@ -92,6 +110,17 @@ export class PtyHandler { private dispatcher: RelayDispatcher private graceTimeMs: number private graceTimer: ReturnType | null = null + // Why: external observers (the relay's hook-server cache) need to drop + // per-pane state when a PTY exits. Multiple listeners is unnecessary today + // — the hook server is the only consumer — so a single optional callback + // keeps the surface tight. A throw inside the listener is swallowed so it + // can never block disposeManagedPty / map cleanup. + private exitListener: PtyExitListener | null = null + // Why: env augmenters injected at relay boot (currently the relay-hook + // server's ORCA_AGENT_HOOK_* coords). Run on every spawn so every PTY + // sees the live hook coordinates without the dispatcher needing to know + // about agent hooks. + private envAugmenters: PtyEnvAugmenter[] = [] constructor(dispatcher: RelayDispatcher, graceTimeMs = DEFAULT_GRACE_TIME_MS) { this.dispatcher = dispatcher @@ -99,6 +128,48 @@ export class PtyHandler { this.registerHandlers() } + /** Subscribe to PTY-exit events. Used by the relay-hook server to evict + * per-paneKey cached payloads when the backing PTY ends. */ + setExitListener(listener: PtyExitListener | null): void { + this.exitListener = listener + } + + /** Register an env augmenter whose return value is merged into every spawn + * env *after* `process.env` and the renderer-supplied env. Used by the + * relay-hook server to inject ORCA_AGENT_HOOK_PORT/TOKEN/ENV/VERSION/ + * ENDPOINT — values the agent CLI inside the PTY needs to find the local + * hook receiver. See docs/design/agent-status-over-ssh.md §3. */ + addEnvAugmenter(augmenter: PtyEnvAugmenter): () => void { + this.envAugmenters.push(augmenter) + return () => { + const idx = this.envAugmenters.indexOf(augmenter) + if (idx !== -1) { + this.envAugmenters.splice(idx, 1) + } + } + } + + /** Build the augmented spawn env. Augmenter values override `process.env` + * and any renderer-supplied env (the augmenter contract — see + * addEnvAugmenter doc-comment). Used by both spawn() and revive() so the + * relationship between process.env, renderer env, and augmenters cannot + * drift between the two paths — revived shells after a relay restart must + * see the fresh ORCA_AGENT_HOOK_* coords just like freshly-spawned ones, + * otherwise agent-status over SSH silently breaks on every revive. */ + private buildSpawnEnv(rendererEnv?: Record): Record { + const augmented: Record = {} + for (const augmenter of this.envAugmenters) { + try { + Object.assign(augmented, augmenter()) + } catch (err) { + process.stderr.write( + `[pty-handler] env augmenter threw: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } + return { ...process.env, ...rendererEnv, ...augmented } as Record + } + /** Wire onData/onExit listeners for a managed PTY and store it. */ private wireAndStore(managed: ManagedPty): void { this.ptys.set(managed.id, managed) @@ -128,6 +199,19 @@ export class PtyHandler { managed.killTimer = undefined } this.dispatcher.notify('pty.exit', { id: managed.id, code: exitCode }) + // Why: notify external observers BEFORE deleting the map entry so a + // listener that needs to read paneKey from the managed entry still + // can. Wrap in try/catch so a throwing listener cannot block fd + // release or map cleanup. + if (this.exitListener) { + try { + this.exitListener({ id: managed.id, paneKey: managed.paneKey }) + } catch (err) { + process.stderr.write( + `[pty-handler] onExit listener threw: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + } this.ptys.delete(managed.id) // Why: release the ptmx fd on the natural-exit path. Without this the // node-pty wrapper's _socket stays alive until GC and the master fd @@ -178,6 +262,12 @@ export class PtyHandler { const shell = resolveDefaultShell() const id = `pty-${this.nextId++}` + // Why: server-side augmenter values (ORCA_AGENT_HOOK_*) override any + // renderer-supplied env so the live hook-server coords always reach the + // agent CLI — they come from the relay, not the renderer. See + // buildSpawnEnv for the precedence contract. + const spawnEnv = this.buildSpawnEnv(env) + // Why: SSH exec channels give the relay a minimal environment without // .zprofile/.bash_profile sourced. Spawning a login shell ensures PATH // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). @@ -186,10 +276,25 @@ export class PtyHandler { cols, rows, cwd, - env: { ...process.env, ...env } as Record + env: spawnEnv }) - const managed: ManagedPty = { id, pty: term, initialCwd: cwd, buffered: '' } + // Why: capture the renderer-supplied paneKey on the managed entry so the + // exit listener can evict per-pane caches without the relay needing a + // separate ptyId→paneKey map. ORCA_PANE_KEY is shaped `${tabId}:${paneId}` + // and is bounded by the renderer; the relay treats it as opaque. + const paneKey = typeof env?.ORCA_PANE_KEY === 'string' ? env.ORCA_PANE_KEY : undefined + const tabId = typeof env?.ORCA_TAB_ID === 'string' ? env.ORCA_TAB_ID : undefined + const worktreeId = typeof env?.ORCA_WORKTREE_ID === 'string' ? env.ORCA_WORKTREE_ID : undefined + const managed: ManagedPty = { + id, + pty: term, + initialCwd: cwd, + buffered: '', + paneKey, + tabId, + worktreeId + } this.wireAndStore(managed) if (context?.isStale()) { // Why: if the client reconnected while pty.spawn was in flight, the @@ -394,7 +499,16 @@ export class PtyHandler { continue } const { pid, cols, rows } = managed.pty - entries.push({ id, pid, cols, rows, cwd: managed.initialCwd }) + entries.push({ + id, + pid, + cols, + rows, + cwd: managed.initialCwd, + paneKey: managed.paneKey, + tabId: managed.tabId, + worktreeId: managed.worktreeId + }) } return JSON.stringify(entries) } @@ -417,14 +531,36 @@ export class PtyHandler { if (!ptyMod) { continue } + // Why: revive must apply the same hook env as spawn(). The hook-server + // coords come from augmenters, while pane identity comes from the + // serialized PTY entry because managed hook scripts exit without + // ORCA_PANE_KEY. + const revivedEnv: Record = {} + if (entry.paneKey) { + revivedEnv.ORCA_PANE_KEY = entry.paneKey + } + if (entry.tabId) { + revivedEnv.ORCA_TAB_ID = entry.tabId + } + if (entry.worktreeId) { + revivedEnv.ORCA_WORKTREE_ID = entry.worktreeId + } const term = ptyMod.spawn(resolveDefaultShell(), ['-l'], { name: 'xterm-256color', cols: entry.cols, rows: entry.rows, cwd: entry.cwd, - env: process.env as Record + env: this.buildSpawnEnv(revivedEnv) + }) + this.wireAndStore({ + id: entry.id, + pty: term, + initialCwd: entry.cwd, + buffered: '', + paneKey: entry.paneKey, + tabId: entry.tabId, + worktreeId: entry.worktreeId }) - this.wireAndStore({ id: entry.id, pty: term, initialCwd: entry.cwd, buffered: '' }) // Why: nextId starts at 1 and is only incremented by spawn(). Revived // PTYs carry their original IDs (e.g. "pty-3"), so without this bump the diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 3df1555ee9b..df6e075adb7 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -23,6 +23,12 @@ import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' import { PortScanHandler } from './port-scan-handler' +import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' +import { + AGENT_HOOK_INSTALL_PLUGINS_METHOD, + AGENT_HOOK_NOTIFICATION_METHOD, + AGENT_HOOK_REQUEST_REPLAY_METHOD +} from '../shared/agent-hook-relay' const DEFAULT_GRACE_MS = 5 * 60 * 1000 const SOCK_NAME = 'relay.sock' @@ -128,7 +134,7 @@ function runConnectMode(sockPath: string): void { // ── Normal mode ────────────────────────────────────────────────────── -function main(): void { +async function main(): Promise { const { graceTimeMs, connectMode, detached, sockPath } = parseArgs(process.argv) if (connectMode) { @@ -218,6 +224,70 @@ function main(): void { const _portScanHandler = new PortScanHandler(dispatcher) void _portScanHandler + // ── Agent-hook server ───────────────────────────────────────────── + // Why: hosts a loopback HTTP receiver inside the relay process so agent + // CLIs running in remote PTYs can post hook events without leaving the + // host. Each parsed payload is forwarded to Orca via an `agent.hook` + // JSON-RPC notification on the existing SSH channel — see + // docs/design/agent-status-over-ssh.md §2-§5. + const hookServer = new RelayAgentHookServer({ + // Why: a remote account can host multiple target-specific relay daemons. + // Scope endpoint.env/cmd by the daemon socket path so their hook tokens + // cannot overwrite each other. + endpointDir: endpointDirForRelaySocket(sockPath), + forward: (envelope) => { + // Why: dispatcher.notify is fire-and-forget — when the SSH channel is + // mid-reconnect the write callback no-ops and the notification is + // silently dropped. The per-paneKey cache inside `hookServer` lets us + // replay the last status for each live pane after Orca re-wires its + // handler post-`--connect`. + dispatcher.notify( + AGENT_HOOK_NOTIFICATION_METHOD, + envelope as unknown as Record + ) + } + }) + // Why: wait for hook-server startup before the readiness sentinel. A PTY + // spawned before the augmenter exists can never receive ORCA_AGENT_HOOK_* + // later, so success registers the augmenter first; failure is the deliberate + // fail-open path where agent status is disabled for this relay process. + try { + await hookServer.start() + ptyHandler.addEnvAugmenter(() => hookServer.buildPtyEnv()) + } catch (err) { + process.stderr.write( + `[relay] agent-hook server failed to start: ${err instanceof Error ? err.message : String(err)}\n` + ) + } + + // Why: evict the per-pane last-status cache when the backing PTY exits so + // a terminated pane's last working/done payload cannot resurface as a + // ghost event after a later reconnect — see §5 Path 3. + ptyHandler.setExitListener(({ paneKey }) => { + if (paneKey) { + hookServer.clearPaneState(paneKey) + } + }) + + // Why: request-driven replay. Orca issues this *after* it re-wires the + // `agent.hook` filter on the new mux post-`--connect`. We forward each + // cached entry as a fresh notification BEFORE returning so the response + // strictly trails all replays on the dispatcher's single write callback — + // closing the race the push-on-`setWrite` shape would have lost. See + // docs/design/agent-status-over-ssh.md §5 Path 3. + dispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => { + const replayed = hookServer.replayCachedPayloadsForPanes() + return { replayed } + }) + + // Why: stub for the plugin-source sync handler used by OpenCode/Pi. The + // real implementation is wired in commit #7 (deferred to keep this commit + // tight). Stubbed out here as a method-found handler so a probing client + // can detect support without -32601 noise on first connect. + dispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => { + return { installed: false } + }) + // ── Socket server for reconnection ────────────────────────────────── // Why: the relay's original stdin/stdout is tied to the SSH exec channel. // When the app restarts that channel is gone. A Unix domain socket lets @@ -378,6 +448,7 @@ function main(): void { dispatcher.dispose() ptyHandler.dispose() fsHandler.dispose() + hookServer.stop() if (socketServer) { socketServer.close() } @@ -415,4 +486,9 @@ function cleanupSocket(sockPath: string): void { } } -main() +void main().catch((err) => { + process.stderr.write( + `[relay] Fatal startup error: ${err instanceof Error ? err.message : String(err)}\n` + ) + process.exit(1) +}) diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 795eeb2ff1d..3d15886062d 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1610,6 +1610,7 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput?: string lastAssistantMessage?: string interrupted?: boolean + connectionId?: string | null receivedAt: number stateStartedAt: number } @@ -1650,6 +1651,8 @@ describe('useIpcEvents agent status snapshot integration', () => { removeSshCredentialRequest: vi.fn(), clearTabPtyId: vi.fn(), runtimePaneTitlesByTabId: {}, + repos: [], + worktreesByRepo: {}, tabsByWorktree: {}, workspaceSessionReady: false, settings: { terminalFontSize: 13 }, @@ -2025,4 +2028,194 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).not.toHaveBeenCalled() }) + + it('forwards events whose connectionId matches the live repo connection', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + repos: [{ id: 'repo-1', connectionId: 'conn-1' }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] + }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 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() + + onSetListenerRef.current?.({ + paneKey: 'tab-1:0', + connectionId: 'conn-1', + state: 'working', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).toHaveBeenCalledWith( + 'tab-1:0', + expect.objectContaining({ state: 'working' }), + 'Terminal 1', + { updatedAt: 1_700_000_000_100, stateStartedAt: 1_699_999_999_100 } + ) + }) + + it('drops events whose connectionId no longer matches the live local repo', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] + }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 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() + + onSetListenerRef.current?.({ + paneKey: 'tab-1:0', + connectionId: 'conn-stale', + state: 'working', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + + it('drops remote-stamped events when the owning worktree is no longer in worktreesByRepo', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + repos: [{ id: 'repo-1', connectionId: 'conn-1' }], + worktreesByRepo: { 'repo-1': [] }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 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() + + onSetListenerRef.current?.({ + paneKey: 'tab-1:0', + connectionId: 'conn-other', + state: 'working', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + + it('accepts events without a stamped connectionId for preload compatibility', async () => { + const setAgentStatus = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + workspaceSessionReady: true, + repos: [{ id: 'repo-1', connectionId: 'conn-1' }], + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] + }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Terminal 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() + + onSetListenerRef.current?.({ + paneKey: 'tab-1:0', + state: 'working', + receivedAt: 1_700_000_000_100, + stateStartedAt: 1_699_999_999_100 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 06370172829..5c0a130faa6 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1,6 +1,7 @@ /* oxlint-disable max-lines -- Why: this App-level IPC bridge intentionally keeps the renderer's main-process event contract in one place so shortcut, runtime, updater, and agent-status wiring do not drift across files. */ import { useEffect } from 'react' import { useAppStore } from '../store' +import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors' import { applyUIZoom } from '@/lib/ui-zoom' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' @@ -1010,7 +1011,7 @@ export function useIpcEvents(): void { if (!payload) { return } - const { exists, title } = resolvePaneKey(store, data.paneKey) + const { exists, title, repoConnectionId } = 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 @@ -1023,6 +1024,18 @@ export function useIpcEvents(): void { } return } + // Why: drop in-flight events from a connection that no longer owns + // this pane. After an SSH disconnect (or tab destroy/recreate during + // reconnect), notifications may still arrive stamped with the + // connectionId of the dead connection. The renderer compares the + // stamped connectionId against the live repo's connectionId for the + // pane's worktree — see docs/design/agent-status-over-ssh.md §5. + // 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) { + return + } store.setAgentStatus(data.paneKey, payload, title, { updatedAt: data.receivedAt, stateStartedAt: data.stateStartedAt @@ -1110,20 +1123,24 @@ export function useIpcEvents(): void { }, []) } -/** Resolve a paneKey (tabId:paneId) to both a liveness check and the current - * terminal title, in a single walk of tabsByWorktree. 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. - * Why combined: callers need both pieces per hook event, and hook events can - * fire many times per second during a tool-use run. Two separate O(N) scans - * over the same map is wasteful; one pass returns both. */ +/** Resolve a paneKey (tabId:paneId) to a liveness check, the current terminal + * title, and the connectionId of the repo that owns the pane's worktree. + * 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 + * 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, paneKey: string -): { exists: boolean; title: string | undefined } { +): { exists: boolean; title: string | undefined; repoConnectionId: string | null } { const [tabId, paneIdRaw] = paneKey.split(':') if (!tabId) { - return { exists: false, title: undefined } + return { exists: false, title: undefined, repoConnectionId: null } } // Why: split panes track per-pane titles in runtimePaneTitlesByTabId; prefer // the pane's own title over the tab-level (last-winning) title so agent type @@ -1138,11 +1155,13 @@ function resolvePaneKey( const paneTitle = rawPaneTitle && rawPaneTitle.length > 0 ? rawPaneTitle : undefined let exists = false let tabTitle: string | undefined - for (const tabs of Object.values(store.tabsByWorktree)) { + let owningWorktreeId: string | undefined + for (const [worktreeId, tabs] of Object.entries(store.tabsByWorktree)) { for (const tab of tabs) { if (tab.id === tabId) { exists = true tabTitle = tab.title + owningWorktreeId = worktreeId break } } @@ -1150,5 +1169,17 @@ function resolvePaneKey( break } } - return { exists, title: paneTitle ?? tabTitle } + // 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. + let repoConnectionId: string | null = null + if (owningWorktreeId !== undefined) { + const worktree = getWorktreeMapFromState(store).get(owningWorktreeId) + if (worktree) { + const repo = getRepoMapFromState(store).get(worktree.repoId) + repoConnectionId = repo?.connectionId ?? null + } + } + return { exists, title: paneTitle ?? tabTitle, repoConnectionId } } diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ade69a6d639..f8469ee723d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -28,7 +28,7 @@ import { join } from 'path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' -import type { AgentHookSource } from './agent-hook-relay' +import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -85,6 +85,41 @@ export function clearAllListenerCaches(state: HookListenerState): void { state.warnedEnvs.clear() } +/** Emit warn-once diagnostics for cross-build (`version`) and dev-vs-prod + * (`env`) mismatches. Shared between the local HTTP path + * (`normalizeHookPayload`) and the relay-forwarded path + * (`AgentHookServer.ingestRemote`) so a remote-sourced event triggers the + * same diagnostic noise as a local one. The relay's "remote" marker is a + * location tag, not a build env, so it must not look like stale local hooks. */ +export function warnOnHookEnvOrVersionMismatch( + state: HookListenerState, + fields: { version?: string; env?: string; expectedEnv: string } +): void { + const { version, env, expectedEnv } = fields + if ( + version && + version !== ORCA_HOOK_PROTOCOL_VERSION && + !state.warnedVersions.has(version) && + state.warnedVersions.size < MAX_WARNED_KEYS + ) { + state.warnedVersions.add(version) + console.warn( + `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + + 'Reinstall agent hooks from Settings to upgrade the managed script.' + ) + } + if (env && env !== REMOTE_AGENT_HOOK_ENV && env !== expectedEnv) { + const key = `${env}->${expectedEnv}` + if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { + state.warnedEnvs.add(key) + console.warn( + `[agent-hooks] received ${env} hook on ${expectedEnv} server. ` + + 'Likely a stale terminal from another Orca install.' + ) + } + } +} + export type AgentHookEventPayload = { paneKey: string tabId?: string @@ -1144,31 +1179,11 @@ export function normalizeHookPayload( return null } - const version = readStringField(record, 'version') - if ( - version && - version !== ORCA_HOOK_PROTOCOL_VERSION && - !state.warnedVersions.has(version) && - state.warnedVersions.size < MAX_WARNED_KEYS - ) { - state.warnedVersions.add(version) - console.warn( - `[agent-hooks] received hook v${version}; server expects v${ORCA_HOOK_PROTOCOL_VERSION}. ` + - 'Reinstall agent hooks from Settings to upgrade the managed script.' - ) - } - - const clientEnv = readStringField(record, 'env') - if (clientEnv && clientEnv !== expectedEnv) { - const key = `${clientEnv}->${expectedEnv}` - if (!state.warnedEnvs.has(key) && state.warnedEnvs.size < MAX_WARNED_KEYS) { - state.warnedEnvs.add(key) - console.warn( - `[agent-hooks] received ${clientEnv} hook on ${expectedEnv} server. ` + - 'Likely a stale terminal from another Orca install.' - ) - } - } + warnOnHookEnvOrVersionMismatch(state, { + version: readStringField(record, 'version'), + env: readStringField(record, 'env'), + expectedEnv + }) const tabId = readStringField(record, 'tabId') const worktreeId = readStringField(record, 'worktreeId') diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 734eb5575c1..a0d78bdccf8 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -10,16 +10,17 @@ // // Per the design doc: // - The relay normalizes; Orca routes. The envelope's `payload` field has -// already been through `normalizeHookPayload` on the relay side; Orca's -// ingestRemote re-runs the canonical normalizer at the trust boundary -// (defense-in-depth) before feeding the event into the same `onAgentStatus` -// fanout the local HTTP path uses. +// already been through `normalizeHookPayload` (which calls +// `parseAgentStatusPayload` → `normalizeAgentStatusObject`) on the relay +// side. Orca's `ingestRemote` re-runs the canonical payload normalizer at +// the SSH trust boundary before caching or persisting, so relay skew or a +// buggy remote process cannot poison main-process state. // - The wire `connectionId` is **always `null`**: a `connectionId` is Orca's // local handle on an `ssh2` connection, not a wire identity. Orca stamps the // real value on receive from `mux` identity inside `ingestRemote`. -// - The wire `version` and `env` fields are forwarded verbatim from the agent -// CLI's POST body so Orca's existing warn-once cross-build / dev-vs-prod -// diagnostics still fire on remote-sourced events. +// - The wire `version` and `env` fields are forwarded from the agent CLI's +// POST body so Orca's warn-once protocol diagnostics still fire. The relay +// default env is `remote`, a location marker ignored by dev-vs-prod checks. import type { ParsedAgentStatusPayload } from './agent-status-types' @@ -31,6 +32,10 @@ import type { ParsedAgentStatusPayload } from './agent-status-types' // that consumes it from the relay side). export type AgentHookSource = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' | 'pi' | 'droid' +/** Env marker used by the remote relay. It is a transport/location marker, not + * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ +export const REMOTE_AGENT_HOOK_ENV = 'remote' as const + /** Wire envelope for a single hook event flowing relay → Orca. */ export type AgentHookRelayEnvelope = { source: AgentHookSource @@ -39,16 +44,14 @@ export type AgentHookRelayEnvelope = { worktreeId?: string /** Always `null` on the wire — relay does not know Orca's local connectionId. */ connectionId: null - /** Forwarded verbatim from the agent CLI POST body (e.g. 'production', - * 'development'). Lets Orca's warn-once env-mismatch diagnostic fire on - * remote events the same as on local. */ + /** Forwarded from the agent CLI POST body. The relay default is `remote`, + * which marks transport location rather than dev/prod build env. */ env?: string /** Forwarded verbatim from the agent CLI POST body. Lets Orca's warn-once * protocol-version diagnostic fire on remote events the same as on local. */ version?: string /** Pre-normalized status payload from the relay's `normalizeHookPayload`. - * Orca's `ingestRemote` re-validates via `normalizeAgentStatusPayload` at - * the trust boundary as defense-in-depth. */ + * Orca's `ingestRemote` validates it again at the SSH trust boundary. */ payload: ParsedAgentStatusPayload } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index cfb96a1b2a6..197aae1b0d5 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -38,6 +38,18 @@ export default function globalSetup(): void { console.log('[e2e] Build complete.') } + if (process.env.ORCA_E2E_SSH_LOCALHOST === '1') { + // Why: the localhost SSH spec deploys Orca's relay from out/relay. The + // normal Electron E2E build does not produce that bundle, so build it only + // for the explicit local-machine SSH run. + console.log('[e2e] Building SSH relay bundle for localhost SSH E2E...') + execSync('pnpm run build:relay', { + cwd: root, + stdio: 'inherit', + timeout: 120_000 + }) + } + // ── 2. Create a seeded test git repo ─────────────────────────────── // Why: each test run gets its own git repo so the suite is fully // idempotent. No test depends on whatever repos the user has open. diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index e8eee35bc8d..d92b5752575 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -212,10 +212,16 @@ export const test = base.extend({ // Why: ORCA_E2E_HEADLESS suppresses mainWindow.show() for CI/headless // runs. ORCA_E2E_HEADFUL overrides this for tests that need a visible // window (e.g. pointer-capture drag tests). + // Why: local SSH E2E deploys the relay from the dev build output. The + // Electron app's getAppPath() points at the compiled main bundle in E2E, + // so pass the repo-root relay path explicitly for this opt-in suite. env: { ...cleanEnv, NODE_ENV: 'development', ORCA_E2E_USER_DATA_DIR: userDataDir, + ...(process.env.ORCA_E2E_SSH_LOCALHOST === '1' && !cleanEnv.ORCA_RELAY_PATH + ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } + : {}), ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts new file mode 100644 index 00000000000..95d5eeb4af9 --- /dev/null +++ b/tests/e2e/ssh-localhost.spec.ts @@ -0,0 +1,298 @@ +import os from 'os' +import path from 'path' + +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + execInTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type LocalhostSshTarget = { + label: string + host: string + port: number + username: string + configHost?: string + identityFile?: string +} + +const RUN_LOCALHOST_SSH = process.env.ORCA_E2E_SSH_LOCALHOST === '1' +const RUN_REMOTE_HOOKS = + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS !== undefined && + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS.trim() !== '' && + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS.trim() !== '0' + +function parsePort(value: string | undefined): number { + const parsed = Number(value ?? '22') + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return parsed + } + throw new Error(`Invalid ORCA_E2E_SSH_PORT: ${value}`) +} + +function currentUsername(): string { + return ( + process.env.ORCA_E2E_SSH_USER ?? + process.env.USER ?? + process.env.USERNAME ?? + os.userInfo().username + ) +} + +function readLocalhostSshTarget(): LocalhostSshTarget { + const configHost = process.env.ORCA_E2E_SSH_CONFIG_HOST?.trim() + const host = process.env.ORCA_E2E_SSH_HOST?.trim() ?? (configHost ? '' : '127.0.0.1') + const identityFile = process.env.ORCA_E2E_SSH_IDENTITY_FILE?.trim() + + return { + label: `Localhost SSH E2E ${Date.now()}`, + host, + port: parsePort(process.env.ORCA_E2E_SSH_PORT), + username: currentUsername(), + ...(configHost ? { configHost } : {}), + ...(identityFile ? { identityFile } : {}) + } +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +function marker(name: string): string { + return `__ORCA_${name}_${Date.now()}__` +} + +function emitMarkerCommand(value: string): string { + const midpoint = Math.floor(value.length / 2) + return `printf '%s%s\\n' ${shellQuote(value.slice(0, midpoint))} ${shellQuote( + value.slice(midpoint) + )}` +} + +test.describe('Localhost SSH', () => { + test.skip( + !RUN_LOCALHOST_SSH, + 'Set ORCA_E2E_SSH_LOCALHOST=1 to run this local-machine-only SSH E2E test.' + ) + test.skip( + !RUN_REMOTE_HOOKS, + 'Set ORCA_FEATURE_REMOTE_AGENT_HOOKS=1 so remote PTYs keep pane identity and forward hook events.' + ) + test.skip(process.platform === 'win32', 'Localhost SSH hook E2E uses POSIX hook scripts.') + + test('routes a terminal and agent-hook status over localhost SSH', async ({ + orcaPage, + testRepoPath + }) => { + test.slow() + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const target = readLocalhostSshTarget() + const remote = await orcaPage.evaluate( + async ({ remotePath, target }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + + const credentialUnsub = window.api.ssh.onCredentialRequest((request) => { + void window.api.ssh.submitCredential({ requestId: request.requestId, value: null }) + }) + + try { + const createdTarget = await window.api.ssh.addTarget({ + target: { + ...target, + // Why: local-only E2E should not leave a long-lived relay process + // behind if the Electron app is killed between cleanup hooks. + relayGracePeriodSeconds: 1 + } + }) + + let state + try { + state = await window.api.ssh.connect({ targetId: createdTarget.id }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `Failed to connect to localhost SSH target ${target.username}@${target.host || target.configHost}:${target.port}. ` + + `Ensure sshd is running and key/agent auth is non-interactive. ${message}` + ) + } + + if (!state || state.status !== 'connected') { + throw new Error(`SSH target did not reach connected state: ${JSON.stringify(state)}`) + } + + store.getState().setSshConnectionState(createdTarget.id, state) + const labels = new Map(store.getState().sshTargetLabels) + labels.set(createdTarget.id, createdTarget.label) + store.getState().setSshTargetLabels(labels) + + const result = await window.api.repos.addRemote({ + connectionId: createdTarget.id, + remotePath, + displayName: 'Localhost SSH E2E' + }) + if ('error' in result) { + throw new Error(result.error) + } + + await store.getState().fetchRepos() + await store.getState().fetchWorktrees(result.repo.id) + + const worktrees = store.getState().worktreesByRepo[result.repo.id] ?? [] + const worktree = + worktrees.find((candidate) => candidate.path === result.repo.path) ?? worktrees[0] + if (!worktree) { + throw new Error(`No remote worktree found for ${result.repo.path}`) + } + + store.getState().setActiveWorktree(worktree.id) + if ((store.getState().tabsByWorktree[worktree.id] ?? []).length === 0) { + store.getState().createTab(worktree.id) + } + store.getState().setActiveTabType('terminal') + + return { + targetId: createdTarget.id, + repoId: result.repo.id, + worktreeId: worktree.id + } + } finally { + credentialUnsub() + } + }, + { remotePath: testRepoPath, target } + ) + + await expect(remote.targetId).toBeTruthy() + await ensureTerminalVisible(orcaPage, 30_000) + await waitForActiveTerminalManager(orcaPage, 45_000) + const ptyId = await waitForActivePanePtyId(orcaPage, 45_000) + const paneKey = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : (state.activeTabIdByWorktree?.[worktreeId] ?? tabs[0]?.id) + if (!tabId) { + throw new Error('No active terminal tab') + } + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] + if (!pane) { + throw new Error('No active terminal pane') + } + return `${tabId}:${pane.id}` + }) + + const terminalMarker = marker('LOCALHOST_SSH') + await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker)) + await waitForTerminalOutput(orcaPage, terminalMarker, 20_000) + + const e2eConfig = await orcaPage.evaluate(() => window.api.e2e.getConfig()) + if (!e2eConfig.userDataDir) { + throw new Error('E2E userDataDir unavailable') + } + const codexHookPath = path.join(e2eConfig.userDataDir, 'agent-hooks', 'codex-hook.sh') + const quotedCodexHookPath = shellQuote(codexHookPath) + const codexHookStatus = await orcaPage.evaluate(() => window.api.agentHooks.codexStatus()) + expect(codexHookStatus.state).toBe('installed') + const installMarker = marker('CODEX_HOOK_INSTALLED') + const installFailedMarker = marker('CODEX_HOOK_INSTALL_FAILED') + await execInTerminal( + orcaPage, + ptyId, + [ + `if [ -x ${quotedCodexHookPath} ] && grep -F ${quotedCodexHookPath} "$HOME/.codex/hooks.json" >/dev/null 2>&1; then`, + ` ${emitMarkerCommand(installMarker)}`, + 'else', + ` ${emitMarkerCommand(installFailedMarker)}`, + 'fi' + ].join('\n') + ) + await waitForTerminalOutput(orcaPage, installMarker, 20_000) + + const envMarker = marker('AGENT_HOOK_ENV_OK') + const envFailedMarker = marker('AGENT_HOOK_ENV_BAD') + await execInTerminal( + orcaPage, + ptyId, + [ + `if [ "$ORCA_PANE_KEY" = ${shellQuote(paneKey)} ] && [ -n "$ORCA_AGENT_HOOK_PORT" ] && [ -n "$ORCA_AGENT_HOOK_TOKEN" ] && /bin/sh -c 'test -n "$ORCA_PANE_KEY" && test -n "$ORCA_AGENT_HOOK_PORT" && test -n "$ORCA_AGENT_HOOK_TOKEN"'; then`, + ` ${emitMarkerCommand(envMarker)}`, + 'else', + ' token_state=${ORCA_AGENT_HOOK_TOKEN:+set}', + ` printf '%s pane=%s port=%s token=%s endpoint=%s\\n' ${shellQuote(envFailedMarker)} "$ORCA_PANE_KEY" "$ORCA_AGENT_HOOK_PORT" "$token_state" "$ORCA_AGENT_HOOK_ENDPOINT"`, + 'fi' + ].join('\n') + ) + await waitForTerminalOutput(orcaPage, envMarker, 20_000) + + const prompt = `orca ssh e2e prompt ${Date.now()}` + const hookPostedMarker = marker('AGENT_HOOK_POSTED') + const hookPayloadFile = `/tmp/orca-e2e-hook-payload-${Date.now()}.json` + await execInTerminal( + orcaPage, + ptyId, + [ + `if [ ! -x ${quotedCodexHookPath} ]; then`, + ' echo __ORCA_CODEX_HOOK_SCRIPT_MISSING__', + 'elif [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' echo __ORCA_AGENT_HOOK_ENV_MISSING__', + 'else', + ` printf '%s' ${shellQuote( + JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }) + )} > ${shellQuote(hookPayloadFile)}`, + ` /bin/sh ${quotedCodexHookPath} < ${shellQuote(hookPayloadFile)}`, + ' hook_status=$?', + ` rm -f ${shellQuote(hookPayloadFile)}`, + ` if [ "$hook_status" -eq 0 ]; then ${emitMarkerCommand(hookPostedMarker)}; fi`, + 'fi' + ].join('\n') + ) + await waitForTerminalOutput(orcaPage, hookPostedMarker, 20_000) + + await expect + .poll( + async () => + orcaPage.evaluate( + ({ paneKey, prompt, targetId, worktreeId }) => { + const state = window.__store?.getState() + const entries = Object.values(state?.agentStatusByPaneKey ?? {}) + return entries.some( + (entry) => + entry.paneKey === paneKey && + entry.prompt === prompt && + entry.agentType === 'codex' && + entry.state === 'working' && + state?.repos.some((repo) => repo.connectionId === targetId) === true && + Object.values(state?.worktreesByRepo ?? {}) + .flat() + .some((worktree) => worktree.id === worktreeId) + ) + }, + { paneKey, prompt, targetId: remote.targetId, worktreeId: remote.worktreeId } + ), + { + timeout: 20_000, + message: 'Remote Codex hook status did not reach the renderer agent-status store' + } + ) + .toBe(true) + }) +})