feat(ssh): agent-status over SSH (#1702)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-05-13 22:09:32 -07:00
committed by GitHub
co-authored by Orca
parent e7bdd18e2f
commit ba0a5b47ed
19 changed files with 1925 additions and 80 deletions
+178 -1
View File
@@ -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
+15 -8
View File
@@ -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,
+117
View File
@@ -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<string, string> }) => ({
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<string, string> }) => ({
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
}
}
})
})
})
+25 -1
View File
@@ -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<string, string> | undefined = baseEnv
const preAllocatedHandle =
runtime && !(provider instanceof LocalPtyProvider)
@@ -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<string, unknown>[]
replayEnvelopes: AgentHookRelayEnvelope[]
notifyAgentHook: (envelope: AgentHookRelayEnvelope | Record<string, unknown>) => 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<string, unknown>[] = []
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<string, unknown>
)
}
return { replayed: replayEnvelopes.length }
})
return {
transport,
dispatcher,
ptySpawnRequests,
replayEnvelopes,
notifyAgentHook: (envelope) => {
dispatcher.notify(AGENT_HOOK_NOTIFICATION_METHOD, envelope as Record<string, unknown>)
},
dispose: () => dispatcher.dispose()
}
}
function createSession(targetId: string): InstanceType<typeof SshRelaySession> {
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<void> {
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> = {}): 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<typeof vi.spyOn>
let session: InstanceType<typeof SshRelaySession> | 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([])
})
})
+78
View File
@@ -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
+203
View File
@@ -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<string, unknown>
)
}
})
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')
})
})
+46 -5
View File
@@ -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 })
+27 -7
View File
@@ -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<string, unknown>).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<string, unknown>).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
}
}
+85
View File
@@ -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<string, string> }
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<string, string> }
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({
+142 -6
View File
@@ -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<string, string>
export class PtyHandler {
private ptys = new Map<string, ManagedPty>()
@@ -92,6 +110,17 @@ export class PtyHandler {
private dispatcher: RelayDispatcher
private graceTimeMs: number
private graceTimer: ReturnType<typeof setTimeout> | 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<string, string>): Record<string, string> {
const augmented: Record<string, string> = {}
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<string, string>
}
/** 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<string, string>
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<string, string> = {}
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<string, string>
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
+78 -2
View File
@@ -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<void> {
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<string, unknown>
)
}
})
// 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)
})
+193
View File
@@ -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)
})
})
+43 -12
View File
@@ -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<typeof useAppStore.getState>,
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 }
}
+41 -26
View File
@@ -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')
+15 -12
View File
@@ -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
}
+12
View File
@@ -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.
+6
View File
@@ -212,10 +212,16 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
// 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' })
}
})
+298
View File
@@ -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)
})
})