mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat(ssh): agent-status over SSH (#1702)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user