Files
orca/src/relay/agent-hook-server.test.ts
T
98d02bca47 fix: support windows ssh hosts (#5004)
* feat: add windows ssh relay base support

* feat: support windows ssh relay runtime services

* fix: default windows ssh pty cwd to user profile

* fix: support windows hosts over system ssh

* fix: preserve degraded windows relay native deps

* fix: gate windows shell args by relay platform

* fix: preserve windows relay fallback pipes

* test: align windows native deps relay fixture

* fix: build valid windows install lock command

* fix: address windows SSH relay review findings

Resolve correctness, efficiency, and reuse issues found reviewing the
Windows SSH native-host support:

- GC liveness on Windows now probes the actual named pipe (via node
  net.connect against markers + deterministic candidates) instead of
  substring-matching Win32_Process command lines, which could remove a
  live relay dir. Reports ALIVE conservatively only when there is no
  liveness signal at all (no markers and no seed pipes).
- Resolve the remote node path once per deploy and thread it through
  install/repair/launch instead of re-resolving 3-7x.
- Replace the 200ms node -e poll loop with a single long-lived remote
  wait process during Windows relay startup.
- Skip the no-op executable command on Windows in uploadRelay.
- Make the Windows fallback pipe name deterministic and recoverable
  (drop the global counter), with an extra reconnect attempt.
- Normalize the prepended node bin dir to backslashes on Windows PATH.
- Batch the system-SSH Windows directory upload into a single streamed
  JSON package instead of one ssh process per file.
- Extract relay endpoint/marker helpers into ssh-relay-endpoints.ts and
  consolidate the PowerShell EncodedCommand encoding into the shared
  powershell-command-encoding module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Support cancellation and timeouts in Windows port scanning

- Propagate the request AbortSignal and a 5-second timeout to both
  PowerShell and netstat child processes during Windows port scanning.
- Avoid spawning the netstat fallback process if the port scan has
  already been aborted.
- Wrap the .NET OSArchitecture check in a try/catch block during SSH
  Windows platform detection to robustly fall back to environment
  variables if needed.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-06-09 01:17:34 -07:00

335 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { homedir, tmpdir } from 'os'
import { join } from 'path'
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay'
import { makePaneKey } from '../shared/stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
describe('RelayAgentHookServer', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'relay-hook-server-'))
})
afterEach(() => {
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('keeps named-pipe endpoint files on a real filesystem path', () => {
const endpointDir = endpointDirForRelaySocket('\\\\.\\pipe\\orca-relay-abc123')
expect(endpointDir).toBe(join(homedir(), '.orca-relay', 'agent-hooks', 'orca-relay-abc123'))
expect(endpointDir).not.toContain('\\\\.\\pipe')
})
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 })
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: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'hi' }
})
})
expect(res.status).toBe(204)
expect(forward).toHaveBeenCalledTimes(1)
const envelope = forward.mock.calls[0][0]
expect(envelope.source).toBe('claude')
expect(envelope.paneKey).toBe(PANE_KEY)
expect(envelope.tabId).toBe('tab-1')
expect(envelope.connectionId).toBeNull()
expect(envelope.payload.state).toBe('working')
expect(envelope.payload.prompt).toBe('hi')
// 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 {
server.stop()
}
})
it('rejects requests with the wrong bearer token (403)', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port } = 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': 'wrong'
},
body: '{}'
})
expect(res.status).toBe(403)
expect(forward).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
it('replays cached payloads on demand', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.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: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'cache me' }
})
})
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(1)
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 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')
expect(forward.mock.calls[0][0].isReplay).toBe(true)
} finally {
server.stop()
}
})
it('does not replay paneKeys after clearPaneState', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start()
try {
const { port, token } = server.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: PANE_KEY,
payload: { hook_event_name: 'UserPromptSubmit', prompt: 'gone' }
})
})
server.clearPaneState(PANE_KEY)
forward.mockClear()
const replayed = server.replayCachedPayloadsForPanes()
expect(replayed).toBe(0)
expect(forward).not.toHaveBeenCalled()
} finally {
server.stop()
}
})
// 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 })
await server.start()
try {
const env = server.buildPtyEnv()
expect(env.ORCA_AGENT_HOOK_PORT).toMatch(/^\d+$/)
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeTruthy()
expect(env.ORCA_AGENT_HOOK_ENV).toBe('remote')
expect(env.ORCA_AGENT_HOOK_VERSION).toBe('1')
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
} finally {
server.stop()
}
})
it('can defer endpoint file publication until relay socket ownership is proven', async () => {
const forward = vi.fn()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
await server.start({ publishEndpoint: false })
try {
expect(server.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
expect(server.publishEndpointFile()).toBe(true)
expect(server.buildPtyEnv().ORCA_AGENT_HOOK_ENDPOINT).toBeTruthy()
} finally {
server.stop()
}
})
it('keeps Copilot transcript retry alive across a following SessionEnd event', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
const transcriptPath = join(dir, 'events.jsonl')
writeFileSync(transcriptPath, '')
await server.start()
try {
const { port, token } = server.getCoordinates()
await fetch(`http://127.0.0.1:${port}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'Stop', transcriptPath }
})
})
await fetch(`http://127.0.0.1:${port}/hook/copilot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hook_event_name: 'SessionEnd', reason: 'complete' }
})
})
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined()
writeFileSync(
transcriptPath,
`${JSON.stringify({
type: 'assistant.message',
data: { content: 'Relay transcript completed.' }
})}\n`
)
await new Promise((resolve) => setTimeout(resolve, 120))
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe(
'Relay transcript completed.'
)
} finally {
server.stop()
}
})
it('retries Grok chat history on the relay without blocking the hook POST', async () => {
const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>()
const server = new RelayAgentHookServer({ endpointDir: dir, forward })
const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528'
const cwd = join(dir, 'workspace')
const sessionDir = join(dir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId)
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, 'chat_history.jsonl'), '')
vi.stubEnv('HOME', dir)
vi.stubEnv('USERPROFILE', dir)
await server.start()
try {
const { port, token } = server.getCoordinates()
await fetch(`http://127.0.0.1:${port}/hook/grok`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hookEventName: 'user_prompt_submit', prompt: 'hihi' }
})
})
const response = await fetch(`http://127.0.0.1:${port}/hook/grok`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Orca-Agent-Hook-Token': token
},
body: JSON.stringify({
paneKey: PANE_KEY,
tabId: 'tab-1',
env: 'remote',
version: '1',
payload: { hookEventName: 'Stop', sessionId, cwd }
})
})
expect(response.status).toBe(204)
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined()
writeFileSync(
join(sessionDir, 'chat_history.jsonl'),
`${JSON.stringify({ type: 'assistant', content: 'Relay Grok reply.' })}\n`
)
await new Promise((resolve) => setTimeout(resolve, 120))
expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe('Relay Grok reply.')
} finally {
server.stop()
vi.unstubAllEnvs()
}
})
})