From 79d3c847bd4fc5be734f2d38479596ea09ef08b9 Mon Sep 17 00:00:00 2001 From: BingZ Date: Tue, 4 Aug 2026 11:35:40 +0800 Subject: [PATCH] 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. --- config/reliability-gates.jsonc | 17 +- .../daemon-server-kill-attribution.test.ts | 119 ++++++++++ src/main/daemon/daemon-server.ts | 10 +- .../rpc/methods/session-tab-close-methods.ts | 9 + src/main/runtime/rpc/methods/terminal.ts | 21 +- ...runtime-close-attribution-topology.test.ts | 211 ++++++++++++++++++ .../rpc/runtime-close-attribution.test.ts | 129 +++++++++++ .../runtime/rpc/terminal-close-attribution.ts | 50 +++++ 8 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 src/main/daemon/daemon-server-kill-attribution.test.ts create mode 100644 src/main/runtime/rpc/runtime-close-attribution-topology.test.ts create mode 100644 src/main/runtime/rpc/runtime-close-attribution.test.ts create mode 100644 src/main/runtime/rpc/terminal-close-attribution.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 802526681..c84075e6c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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": [ diff --git a/src/main/daemon/daemon-server-kill-attribution.test.ts b/src/main/daemon/daemon-server-kill-attribution.test.ts new file mode 100644 index 000000000..8b3240433 --- /dev/null +++ b/src/main/daemon/daemon-server-kill-attribution.test.ts @@ -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 } + pendingPtySpawnPreparations: Map> + routeRequest(clientId: string, request: DaemonRequest): Promise +} + +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((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()) + }) +}) diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 90b319576..4c9ffd509 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -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 {} } diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index eabffa24a..8b17d946b 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index d20524749..edef5f440 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -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({ diff --git a/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts new file mode 100644 index 000000000..4e1232e73 --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution-topology.test.ts @@ -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 + 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() + }) +}) diff --git a/src/main/runtime/rpc/runtime-close-attribution.test.ts b/src/main/runtime/rpc/runtime-close-attribution.test.ts new file mode 100644 index 000000000..0073348ec --- /dev/null +++ b/src/main/runtime/rpc/runtime-close-attribution.test.ts @@ -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) + } + ) +}) diff --git a/src/main/runtime/rpc/terminal-close-attribution.ts b/src/main/runtime/rpc/terminal-close-attribution.ts new file mode 100644 index 000000000..01eaa4e05 --- /dev/null +++ b/src/main/runtime/rpc/terminal-close-attribution.ts @@ -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 +): Promise { + 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 + } + } + ) +}