fix(runtime): attribute destructive close requests (#12238)
Attribute destructive runtime and daemon close diagnostics to the requesting client and exact target, record outcomes only after completion, and add reliability-gated attribution regressions.
This commit is contained in:
parent
026ed921c1
commit
79d3c847bd
|
|
@ -1815,13 +1815,15 @@
|
|||
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
|
||||
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/daemon-server-kill-attribution.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
|
||||
"src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs.test.ts",
|
||||
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
|
||||
"src/main/runtime/rpc/e2ee-channel.test.ts",
|
||||
|
|
@ -1843,6 +1845,19 @@
|
|||
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/daemon-server-kill-attribution.test.ts",
|
||||
"assertions": [
|
||||
"successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
|
||||
"assertions": [
|
||||
"remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential",
|
||||
"close attribution adds no terminal inventory scan or provider fanout"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
|
||||
"assertions": [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { SessionNotFoundError, type DaemonRequest } from './types'
|
||||
|
||||
type DaemonServerPrivate = {
|
||||
host: { kill: (sessionId: string, opts?: { immediate?: boolean }) => void | Promise<void> }
|
||||
pendingPtySpawnPreparations: Map<string, Set<{ canceled: boolean; clientId: string }>>
|
||||
routeRequest(clientId: string, request: DaemonRequest): Promise<unknown>
|
||||
}
|
||||
|
||||
describe('daemon kill attribution', () => {
|
||||
let server: DaemonServer
|
||||
let dir: string
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('records the requesting control client in the session-killed log', async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
|
||||
const killLog = { log: vi.fn(), close: vi.fn() }
|
||||
server = new DaemonServer({
|
||||
socketPath: join(dir, 'daemon.sock'),
|
||||
tokenPath: join(dir, 'daemon.token'),
|
||||
log: killLog,
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('not used')
|
||||
}
|
||||
})
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
let finishKill!: () => void
|
||||
const killFinished = new Promise<void>((resolve) => {
|
||||
finishKill = resolve
|
||||
})
|
||||
vi.spyOn(daemon.host, 'kill').mockReturnValue(killFinished)
|
||||
|
||||
const request = daemon.routeRequest('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(daemon.host.kill).toHaveBeenCalledOnce())
|
||||
expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything())
|
||||
finishKill()
|
||||
await request
|
||||
|
||||
expect(killLog.log).toHaveBeenCalledWith('session-killed', {
|
||||
sessionId: 'agent-session',
|
||||
immediate: true,
|
||||
clientId: 'control-42'
|
||||
})
|
||||
})
|
||||
|
||||
it('attributes a failed kill without claiming the session was killed', async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
|
||||
const killLog = { log: vi.fn(), close: vi.fn() }
|
||||
server = new DaemonServer({
|
||||
socketPath: join(dir, 'daemon.sock'),
|
||||
tokenPath: join(dir, 'daemon.token'),
|
||||
log: killLog,
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('not used')
|
||||
}
|
||||
})
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new Error('kill refused'))
|
||||
|
||||
await expect(
|
||||
daemon.routeRequest('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
})
|
||||
).rejects.toThrow('kill refused')
|
||||
|
||||
expect(killLog.log).toHaveBeenCalledWith('session-kill-failed', {
|
||||
sessionId: 'agent-session',
|
||||
immediate: true,
|
||||
clientId: 'control-42'
|
||||
})
|
||||
expect(killLog.log).not.toHaveBeenCalledWith('session-killed', expect.anything())
|
||||
})
|
||||
|
||||
it('attributes a tolerated kill after canceling a pending spawn', async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-kill-attribution-'))
|
||||
const killLog = { log: vi.fn(), close: vi.fn() }
|
||||
server = new DaemonServer({
|
||||
socketPath: join(dir, 'daemon.sock'),
|
||||
tokenPath: join(dir, 'daemon.token'),
|
||||
log: killLog,
|
||||
spawnSubprocess: () => {
|
||||
throw new Error('not used')
|
||||
}
|
||||
})
|
||||
const daemon = server as unknown as DaemonServerPrivate
|
||||
const pendingPreparation = { canceled: false, clientId: 'control-42' }
|
||||
daemon.pendingPtySpawnPreparations.set('agent-session', new Set([pendingPreparation]))
|
||||
vi.spyOn(daemon.host, 'kill').mockRejectedValue(new SessionNotFoundError('agent-session'))
|
||||
|
||||
await daemon.routeRequest('control-42', {
|
||||
id: 'kill-1',
|
||||
type: 'kill',
|
||||
payload: { sessionId: 'agent-session', immediate: true }
|
||||
})
|
||||
|
||||
expect(pendingPreparation.canceled).toBe(true)
|
||||
expect(killLog.log).toHaveBeenCalledWith('session-killed', {
|
||||
sessionId: 'agent-session',
|
||||
immediate: true,
|
||||
clientId: 'control-42'
|
||||
})
|
||||
expect(killLog.log).not.toHaveBeenCalledWith('session-kill-failed', expect.anything())
|
||||
})
|
||||
})
|
||||
|
|
@ -935,18 +935,22 @@ export class DaemonServer {
|
|||
request.payload.sessionId
|
||||
)
|
||||
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
||||
this.log.log('session-killed', {
|
||||
const attribution = {
|
||||
sessionId: request.payload.sessionId,
|
||||
immediate: request.payload.immediate === true
|
||||
})
|
||||
immediate: request.payload.immediate === true,
|
||||
// Daemon control identity, not the paired-device bearer credential.
|
||||
clientId
|
||||
}
|
||||
try {
|
||||
await this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
|
||||
} catch (error) {
|
||||
// Why: a kill that wins before session registration already canceled the pending spawn, so its intent is done.
|
||||
if (!(canceledPendingSpawn && error instanceof SessionNotFoundError)) {
|
||||
this.log.log('session-kill-failed', attribution)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
this.log.log('session-killed', attribution)
|
||||
return {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,11 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
|||
kind: 'client',
|
||||
attributes: {
|
||||
attribution: 'session-tab-close',
|
||||
runtimeId: context.runtime.getRuntimeId(),
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
deviceId: context.pairedDeviceId ?? 'in-process',
|
||||
worktree: params.worktree,
|
||||
tabId: params.tabId,
|
||||
closeReason:
|
||||
params.reason ??
|
||||
(requiresIntent
|
||||
|
|
@ -85,7 +89,12 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
|
|||
kind: 'client',
|
||||
attributes: {
|
||||
attribution: 'session-tab-lifecycle-close',
|
||||
runtimeId: context.runtime.getRuntimeId(),
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
deviceId: context.pairedDeviceId ?? 'in-process',
|
||||
worktree: params.worktree,
|
||||
tabId: params.tabId,
|
||||
terminal: params.terminal,
|
||||
closeReason: params.reason,
|
||||
connectionGeneration: context.connectionId ?? 'in-process',
|
||||
requestId: context.requestId ?? 'in-process',
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import {
|
|||
type TerminalOutputSourceRange
|
||||
} from '../../../../shared/terminal-output-source-range'
|
||||
import type { RemoteTerminalSourceRangeReplacementReservation } from '../../remote-terminal-source-range-consumer'
|
||||
import { withTerminalCloseAttribution } from '../terminal-close-attribution'
|
||||
|
||||
const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024
|
||||
const TERMINAL_OUTPUT_FLUSH_MS = 5
|
||||
|
|
@ -1499,15 +1500,27 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
|
|||
defineMethod({
|
||||
name: 'terminal.close',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
close: await runtime.closeTerminal(params.terminal)
|
||||
handler: async (params, context) => ({
|
||||
close: await withTerminalCloseAttribution(
|
||||
'terminal.close',
|
||||
context,
|
||||
'terminal',
|
||||
params.terminal,
|
||||
() => context.runtime.closeTerminal(params.terminal)
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'terminal.closeTab',
|
||||
params: TerminalHandle,
|
||||
handler: async (params, { runtime }) => ({
|
||||
close: await runtime.closeTerminalTab(params.terminal)
|
||||
handler: async (params, context) => ({
|
||||
close: await withTerminalCloseAttribution(
|
||||
'terminal.closeTab',
|
||||
context,
|
||||
'terminal-tab',
|
||||
params.terminal,
|
||||
() => context.runtime.closeTerminalTab(params.terminal)
|
||||
)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import type { RpcRequest } from './core'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import { SESSION_TAB_METHODS } from './methods/session-tabs'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
|
||||
const BEARER_CLIENT_ID = 'secret-bearer-client-id'
|
||||
|
||||
type SpanRecord = {
|
||||
name: string
|
||||
attributes: Record<string, unknown>
|
||||
exit: { _tag: string }
|
||||
}
|
||||
|
||||
function request(id: string, method: string, params: unknown): RpcRequest {
|
||||
return { id, authToken: 'test-token', method, params }
|
||||
}
|
||||
|
||||
describe('runtime close attribution topology', () => {
|
||||
const records: SpanRecord[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
records.length = 0
|
||||
setActiveSink({
|
||||
push: (record) => records.push(record as SpanRecord),
|
||||
flush: vi.fn(),
|
||||
close: vi.fn()
|
||||
} satisfies TracerSink)
|
||||
})
|
||||
|
||||
afterEach(() => _resetTracerForTests())
|
||||
|
||||
it('identifies the authoritative runtime and target for remote, stale, and legacy closes', async () => {
|
||||
const closeMobileSessionTab = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ closed: true, refused: true, refusalReason: 'stale-terminal' })
|
||||
.mockResolvedValue({ closed: true })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-owner-1',
|
||||
closeMobileSessionTab,
|
||||
refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
request('stale-1', 'session.tabs.closeLifecycle', {
|
||||
worktree: 'id:worktree-a',
|
||||
tabId: 'tab-a',
|
||||
reason: 'cleanup',
|
||||
publicationEpoch: 'epoch-old',
|
||||
terminal: 'terminal-old'
|
||||
}),
|
||||
vi.fn(),
|
||||
{
|
||||
clientId: BEARER_CLIENT_ID,
|
||||
pairedDeviceId: 'device-1',
|
||||
connectionId: 'connection-before-reconnect',
|
||||
clientKind: 'runtime'
|
||||
}
|
||||
)
|
||||
await dispatcher.dispatchStreaming(
|
||||
request('legacy-1', 'session.tabs.close', {
|
||||
worktree: 'id:worktree-b',
|
||||
tabId: 'tab-b'
|
||||
}),
|
||||
vi.fn(),
|
||||
{
|
||||
clientId: BEARER_CLIENT_ID,
|
||||
pairedDeviceId: 'device-1',
|
||||
connectionId: 'connection-after-reconnect',
|
||||
clientKind: 'runtime',
|
||||
clientCapabilities: []
|
||||
}
|
||||
)
|
||||
|
||||
expect(records).toHaveLength(2)
|
||||
expect(records[0]).toMatchObject({
|
||||
name: 'runtime.session-tabs.close-lifecycle',
|
||||
attributes: {
|
||||
runtimeId: 'runtime-owner-1',
|
||||
worktree: 'id:worktree-a',
|
||||
tabId: 'tab-a',
|
||||
terminal: 'terminal-old',
|
||||
deviceId: 'device-1',
|
||||
connectionGeneration: 'connection-before-reconnect',
|
||||
requestId: 'stale-1',
|
||||
decision: 'refused-stale-terminal'
|
||||
},
|
||||
exit: { _tag: 'Success' }
|
||||
})
|
||||
expect(records[1]).toMatchObject({
|
||||
name: 'runtime.session-tabs.close',
|
||||
attributes: {
|
||||
runtimeId: 'runtime-owner-1',
|
||||
worktree: 'id:worktree-b',
|
||||
tabId: 'tab-b',
|
||||
deviceId: 'device-1',
|
||||
connectionGeneration: 'connection-after-reconnect',
|
||||
requestId: 'legacy-1',
|
||||
closeReason: 'legacy-runtime-user',
|
||||
decision: 'allowed'
|
||||
}
|
||||
})
|
||||
expect(JSON.stringify(records)).not.toContain(BEARER_CLIENT_ID)
|
||||
})
|
||||
|
||||
it('keeps concurrent cross-worktree request and target identities distinct', async () => {
|
||||
const closeMobileSessionTab = vi.fn().mockResolvedValue({ closed: true })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-owner-2',
|
||||
closeMobileSessionTab
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
await Promise.all(
|
||||
(
|
||||
[
|
||||
['request-a', 'device-a', 'connection-a', 'worktree-a', 'tab-a'],
|
||||
['request-b', 'device-b', 'connection-b', 'worktree-b', 'tab-b']
|
||||
] as const
|
||||
).map(([id, deviceId, connectionId, worktree, tabId]) =>
|
||||
dispatcher.dispatchStreaming(
|
||||
request(id, 'session.tabs.close', {
|
||||
worktree: `id:${worktree}`,
|
||||
tabId,
|
||||
reason: 'user'
|
||||
}),
|
||||
vi.fn(),
|
||||
{ clientKind: 'runtime', pairedDeviceId: deviceId, connectionId }
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expect(records.map((record) => record.attributes)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
runtimeId: 'runtime-owner-2',
|
||||
worktree: 'id:worktree-a',
|
||||
tabId: 'tab-a',
|
||||
deviceId: 'device-a',
|
||||
connectionGeneration: 'connection-a',
|
||||
requestId: 'request-a'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
runtimeId: 'runtime-owner-2',
|
||||
worktree: 'id:worktree-b',
|
||||
tabId: 'tab-b',
|
||||
deviceId: 'device-b',
|
||||
connectionGeneration: 'connection-b',
|
||||
requestId: 'request-b'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(closeMobileSessionTab).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('records resolved terminal outcome and failed unowned requests without extra inventory', async () => {
|
||||
const closeTerminal = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ handle: 'terminal-live', tabId: 'tab-live', ptyKilled: true })
|
||||
.mockRejectedValueOnce(new Error('terminal_handle_stale'))
|
||||
const listSessions = vi.fn()
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'runtime-owner-3',
|
||||
closeTerminal,
|
||||
listSessions
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const context = {
|
||||
clientKind: 'runtime' as const,
|
||||
pairedDeviceId: 'device-terminal',
|
||||
connectionId: 'connection-terminal'
|
||||
}
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
request('terminal-live-request', 'terminal.close', { terminal: 'terminal-live' }),
|
||||
vi.fn(),
|
||||
context
|
||||
)
|
||||
await dispatcher.dispatchStreaming(
|
||||
request('terminal-stale-request', 'terminal.close', { terminal: 'terminal-stale' }),
|
||||
vi.fn(),
|
||||
context
|
||||
)
|
||||
|
||||
expect(records[0]).toMatchObject({
|
||||
attributes: {
|
||||
runtimeId: 'runtime-owner-3',
|
||||
terminal: 'terminal-live',
|
||||
tabId: 'tab-live',
|
||||
ptyKilled: true,
|
||||
decision: 'allowed',
|
||||
outcome: 'succeeded'
|
||||
},
|
||||
exit: { _tag: 'Success' }
|
||||
})
|
||||
expect(records[1]).toMatchObject({
|
||||
attributes: {
|
||||
runtimeId: 'runtime-owner-3',
|
||||
terminal: 'terminal-stale',
|
||||
decision: 'allowed',
|
||||
outcome: 'failed'
|
||||
},
|
||||
exit: { _tag: 'Failure' }
|
||||
})
|
||||
expect(closeTerminal).toHaveBeenCalledTimes(2)
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../../observability/tracer'
|
||||
import type { OrcaRuntimeService } from '../orca-runtime'
|
||||
import { RpcDispatcher } from './dispatcher'
|
||||
import type { RpcRequest } from './core'
|
||||
import { SESSION_TAB_METHODS } from './methods/session-tabs'
|
||||
import { TERMINAL_METHODS } from './methods/terminal'
|
||||
|
||||
const BEARER_CLIENT_ID = 'secret-bearer-client-id'
|
||||
|
||||
type CapturingSink = TracerSink & { records: unknown[] }
|
||||
|
||||
function capturingSink(): CapturingSink {
|
||||
const records: unknown[] = []
|
||||
return {
|
||||
records,
|
||||
push: (record) => records.push(record),
|
||||
flush: vi.fn(),
|
||||
close: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function request(method: string, params: unknown): RpcRequest {
|
||||
return { id: 'close-request-1', authToken: 'test-token', method, params }
|
||||
}
|
||||
|
||||
describe('runtime close attribution', () => {
|
||||
let sink: CapturingSink
|
||||
|
||||
beforeEach(() => {
|
||||
sink = capturingSink()
|
||||
setActiveSink(sink)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTracerForTests()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminal.close', 'closeTerminal', 'terminal'],
|
||||
['terminal.closeTab', 'closeTerminalTab', 'terminal-tab']
|
||||
] as const)(
|
||||
'records %s with the device identity and without the bearer credential',
|
||||
async (method, call, targetKind) => {
|
||||
const close = vi.fn().mockResolvedValue({ handle: 'term-1', ptyKilled: true })
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
[call]: close
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
|
||||
const replies: string[] = []
|
||||
|
||||
await dispatcher.dispatchStreaming(
|
||||
request(method, { terminal: 'term-1' }),
|
||||
(reply) => replies.push(reply),
|
||||
{
|
||||
clientId: BEARER_CLIENT_ID,
|
||||
pairedDeviceId: 'device-uuid-123',
|
||||
connectionId: 'connection-7',
|
||||
clientKind: 'runtime'
|
||||
}
|
||||
)
|
||||
|
||||
expect(JSON.parse(replies[0]!)).toMatchObject({ ok: true })
|
||||
expect(close).toHaveBeenCalledWith('term-1')
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'effect-span',
|
||||
name: method,
|
||||
attributes: expect.objectContaining({
|
||||
attribution: 'terminal-close',
|
||||
origin: 'runtime',
|
||||
deviceId: 'device-uuid-123',
|
||||
connectionGeneration: 'connection-7',
|
||||
requestId: 'close-request-1',
|
||||
targetKind,
|
||||
terminal: 'term-1',
|
||||
decision: 'allowed'
|
||||
}),
|
||||
exit: { _tag: 'Success' }
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[
|
||||
'session.tabs.close',
|
||||
{ worktree: 'id:wt-1', tabId: 'tab-1', reason: 'user' },
|
||||
'runtime.session-tabs.close'
|
||||
],
|
||||
[
|
||||
'session.tabs.closeLifecycle',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
tabId: 'tab-1',
|
||||
reason: 'pty-exit',
|
||||
publicationEpoch: 'epoch-1',
|
||||
terminal: 'term-1'
|
||||
},
|
||||
'runtime.session-tabs.close-lifecycle'
|
||||
]
|
||||
] as const)(
|
||||
'records the device identity for %s without the bearer credential',
|
||||
async (method, params, spanName) => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
|
||||
|
||||
await dispatcher.dispatchStreaming(request(method, params), vi.fn(), {
|
||||
clientId: BEARER_CLIENT_ID,
|
||||
pairedDeviceId: 'device-uuid-123',
|
||||
connectionId: 'connection-7',
|
||||
clientKind: 'runtime'
|
||||
})
|
||||
|
||||
expect(sink.records).toEqual([
|
||||
expect.objectContaining({
|
||||
name: spanName,
|
||||
attributes: expect.objectContaining({ deviceId: 'device-uuid-123' })
|
||||
})
|
||||
])
|
||||
expect(JSON.stringify(sink.records)).not.toContain(BEARER_CLIENT_ID)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { withSpan } from '../../observability/tracer'
|
||||
import type { RuntimeTerminalClose } from '../../../shared/runtime-types'
|
||||
import type { RpcContext } from './core'
|
||||
|
||||
type TerminalCloseMethod = 'terminal.close' | 'terminal.closeTab'
|
||||
type TerminalCloseTargetKind = 'terminal' | 'terminal-tab'
|
||||
|
||||
export function withTerminalCloseAttribution(
|
||||
method: TerminalCloseMethod,
|
||||
context: Pick<
|
||||
RpcContext,
|
||||
'runtime' | 'clientKind' | 'pairedDeviceId' | 'connectionId' | 'requestId'
|
||||
>,
|
||||
targetKind: TerminalCloseTargetKind,
|
||||
terminal: string,
|
||||
close: () => Promise<RuntimeTerminalClose>
|
||||
): Promise<RuntimeTerminalClose> {
|
||||
return withSpan(
|
||||
method,
|
||||
async (span) => {
|
||||
span.setAttribute('decision', 'allowed')
|
||||
try {
|
||||
const result = await close()
|
||||
span.setAttribute('outcome', 'succeeded')
|
||||
span.setAttribute('tabId', result.tabId)
|
||||
span.setAttribute('ptyKilled', result.ptyKilled)
|
||||
if (result.closeMode) {
|
||||
span.setAttribute('closeMode', result.closeMode)
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
span.setAttribute('outcome', 'failed')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
{
|
||||
kind: 'client',
|
||||
attributes: {
|
||||
attribution: 'terminal-close',
|
||||
runtimeId: context.runtime.getRuntimeId(),
|
||||
origin: context.clientKind ?? 'in-process',
|
||||
deviceId: context.pairedDeviceId ?? 'in-process',
|
||||
connectionGeneration: context.connectionId ?? 'in-process',
|
||||
requestId: context.requestId ?? 'in-process',
|
||||
targetKind,
|
||||
terminal
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue