From 58bcfe2a33bff0a136e796f8750045b3fb7b5ad8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:59:09 -0700 Subject: [PATCH] perf(runtime): gate PTY path-candidate extraction on mobile-connected (#9422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(runtime): gate PTY path-candidate extraction on mobile-connected The 3-regex path extractor ran on every PTY chunk of every session for a mobile-only consumer. Now gated behind a sticky mobile-connected flag; first onReady synchronously backfills candidates from the retained 64KB window per original chunk boundary (lazy safety-net fallback). ~28.5us/chunk (~99.8%) saved on the desktop-only hot path; mobile parity preserved (Tier-1 raw-window read unchanged + backfill before any file RPC). 5-round review converged 2-consecutive-clean; independently verified tsc+902 tests. Co-authored-by: Orca * fix(runtime): guard path-candidate activation on the remote runtime proxy MobileSocketWiring.onReady called this.runtime.activateRecentPtyPathCandidateTracking() unconditionally, throwing on the E2EE/remote proxy (which lacks the method) and breaking the remote runtime connection (CI verify failure). Guarded with optional chaining (runtime-rpc.ts:900) — activation is a local-host concern. Mutation-verified E2EE-proxy regression test. Integration test + 3 typechecks green. Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/runtime/orca-runtime.test.ts | 175 ++++++++++++++++++ src/main/runtime/orca-runtime.ts | 67 ++++++- .../runtime/recent-pty-output-buffer.test.ts | 77 ++++++++ src/main/runtime/recent-pty-output-buffer.ts | 61 +++++- src/main/runtime/runtime-rpc.test.ts | 157 ++++++++++++++++ src/main/runtime/runtime-rpc.ts | 10 +- 6 files changed, 535 insertions(+), 12 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index da5644ec0ad..d6cdb4d08d0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8580,6 +8580,181 @@ describe('OrcaRuntimeService', () => { ) }) + it('resolves paths without candidate activation via the lazy safety net', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/lazy-activation-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + // No mobile connect ever happened; the query itself must activate+backfill. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills candidates on activation so scrolled-off paths still resolve', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/backfilled-artifact.json' + + // Path arrives while tracking is inactive (desktop-only phase). + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + // First mobile connect: backfill from the retained raw window. + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window past the path with pathless output. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Only the backfilled candidate tier can answer now. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('backfills per retained chunk so chunk boundaries match the eager extractor', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/a.json' + + // Two chunks whose join would parse as one different candidate + // (/tmp/a.jsonsuffix.txt). The eager per-chunk extractor kept /tmp/a.json. + runtime.onPtyData('pty-1', `wrote ${artifactPath}`, 100) + runtime.onPtyData('pty-1', 'suffix.txt', 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw 64KB window so only the backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('extracts candidates per chunk after activation for scrolled-off paths', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/post-activation-artifact.json' + + runtime.activateRecentPtyPathCandidateTracking() + // Idempotent: a second activation must not disturb live tracking. + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('does not retain pre-activation paths that scrolled past the raw window', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/pre-activation-scrolled-artifact.json' + + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Documented accepted loss: output that scrolled past the raw window + // before the first-ever mobile connect yields no candidates. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill does not mint candidates from an over-limit line shortened by the window trim', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // One chunk with a >4KiB line whose tail is the path: the eager + // extractor skipped it under the line-length guard. + runtime.onPtyData('pty-1', `${'a'.repeat(5000)} ${artifactPath}\n`, 100) + // Newline-free filler trims the window to ~1KiB before the path, so a + // trimmed-head replay would see an under-limit line ending in the path. + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + // Scroll the raw window so only backfilled candidates can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the over-limit line never yielded a + // candidate, so the grant must stay denied after the raw window scrolls. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + false + ) + }) + + it('backfill replays the full head chunk including its window-trimmed prefix', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/trimmed-prefix-artifact.json' + + // Path sits in the head chunk's prefix, which the window trim drops from + // read() but the eager extractor saw at append time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n${'b'.repeat(3000)}\n`, 100) + runtime.onPtyData('pty-1', 'y'.repeat(64 * 1024 - 1000), 150) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + // Parity with eager extraction: the append-time candidate outlived the + // raw window, so backfill must recover it from the intact head chunk. + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + + it('matches eager extraction exactly for a pre-sliced oversized chunk', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const cutLinePath = '/tmp/cut-line.json' + const keptPath = '/tmp/kept-after-cut.json' + + // Single >64KiB append is stored pre-sliced, so its original text is + // unrecoverable at activation time. Extraction runs eagerly at append + // instead: cutLinePath sat on an over-4KiB line the extractor's line + // guard rejects (and the slice leaves an under-4KiB tail of it that must + // NOT mint a candidate later), while keptPath sat on a short line and + // must survive the raw window scrolling. + const keptLine = `wrote ${keptPath}\n` + const afterFirstLine = `${keptLine}${'z'.repeat(62 * 1024 - keptLine.length)}` + const oversized = `${'a'.repeat(5 * 1024)} ${cutLinePath}\n${afterFirstLine}` + runtime.onPtyData('pty-1', oversized, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, cutLinePath, cutLinePath)).toBe( + false + ) + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, keptPath, keptPath)).toBe(true) + }) + + it('keeps a candidate from the short first line of an oversized chunk after the window scrolls', async () => { + const runtime = createRuntime() + syncSinglePty(runtime, 'pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + const artifactPath = '/tmp/result.json' + + // A short first line of a >64KiB chunk loses only its `wrote ` prefix to + // the pre-slice; the path itself stays in the retained window. The old + // eager extractor recorded it from the intact original chunk, so it must + // stay authorized after the raw window scrolls — parity requires the + // append-time extraction for oversized chunks, not backfill replay. + const firstLine = `wrote ${artifactPath}\n` + runtime.onPtyData('pty-1', `${firstLine}${'f'.repeat(64 * 1024 + 6 - firstLine.length)}`, 100) + runtime.activateRecentPtyPathCandidateTracking() + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + expect(runtime.hasRecentTerminalOutputPath(terminal.handle, artifactPath, artifactPath)).toBe( + true + ) + }) + it('replaces suffix-only headless state with the recovered renderer snapshot', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 587fd80b7f3..1784cede32d 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -370,7 +370,7 @@ import type { import type { AutomationService } from '../automations/service' import { RuntimeBrowserCommands } from './orca-runtime-browser' import { buildHeadlessTerminalSplitLayout } from './headless-terminal-split-layout' -import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' +import { RECENT_PTY_OUTPUT_LIMIT, RecentPtyOutputBuffer } from './recent-pty-output-buffer' import { buildHeadlessTabGroupMove, buildHeadlessTabGroupSplit @@ -2417,6 +2417,10 @@ export class OrcaRuntimeService { private ptyLifecycleGenerationById = new Map() private nextPtyLifecycleGeneration = 1 private recentPtyPathCandidatesById = new Map() + // Why: candidates only feed mobile file-tap provenance; desktop-only + // sessions skip the 3-regex extraction on every PTY chunk until a + // mobile/remote client authenticates (sticky, backfilled on activation). + private recentPtyPathCandidateTrackingActive = false // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. @@ -8707,14 +8711,60 @@ export class OrcaRuntimeService { private recordRecentPtyOutputForPathProvenance(ptyId: string, data: string): void { let recentOutputBuffer = this.recentPtyOutputById.get(ptyId) if (!recentOutputBuffer) { - recentOutputBuffer = new RecentPtyOutputBuffer() + // Boundaries are only owed to the one-time activation backfill; once + // tracking is live, new buffers keep the read-collapsing hot path. + recentOutputBuffer = new RecentPtyOutputBuffer({ + preserveChunkBoundaries: !this.recentPtyPathCandidateTrackingActive + }) this.recentPtyOutputById.set(ptyId, recentOutputBuffer) } recentOutputBuffer.append(data) - this.recentPtyPathCandidatesById.set( - ptyId, - appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) - ) + if ( + this.recentPtyPathCandidateTrackingActive || + // Why: an over-window chunk is stored pre-sliced, so activation backfill + // could never replay its original text. Extract while intact; oversized + // chunks are rare, so the desktop-only gate still skips the hot path. + data.length > RECENT_PTY_OUTPUT_LIMIT + ) { + this.recentPtyPathCandidatesById.set( + ptyId, + appendRecentPtyPathCandidates(this.recentPtyPathCandidatesById.get(ptyId), data) + ) + } + } + + activateRecentPtyPathCandidateTracking(): void { + if (this.recentPtyPathCandidateTrackingActive) { + return + } + this.recentPtyPathCandidateTrackingActive = true + // Why: synchronous backfill from the retained raw windows so a file tap + // right after first mobile connect resolves exactly as before the gate. + // Replay each retained chunk in its original full form: joining or + // trimming chunks would change the candidate set (e.g. a window cut can + // shorten an over-4KiB line under the extractor's line guard, minting + // candidates the eager extractor rejected). + // Accepted best-effort loss: output that scrolled past the raw window + // before the first-ever connect no longer yields candidates. + for (const [ptyId, buffer] of this.recentPtyOutputById) { + let candidates = this.recentPtyPathCandidatesById.get(ptyId) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + for (let index = 0; index < chunks.length; index += 1) { + if (index === 0 && headChunkIsPartial) { + // A pre-sliced over-window chunk was already extracted eagerly at + // append time (while its original text was intact); replaying its + // truncated remainder would mint or drop candidates spuriously. + continue + } + candidates = appendRecentPtyPathCandidates(candidates, chunks[index]!) + } + if (candidates) { + this.recentPtyPathCandidatesById.set(ptyId, candidates) + } + // Chunk boundaries were owed only to this one-time backfill; return + // the buffer to the compact read-collapsing steady state. + buffer.compact() + } } resolveTerminalContext( @@ -8757,6 +8807,11 @@ export class OrcaRuntimeService { } hasRecentTerminalOutputPath(handle: string, pathText: string, absolutePath: string): boolean { + // Why: safety net for any query path that never saw a mobile onReady — + // lazily backfill so the answer matches pre-gate behavior. + if (!this.recentPtyPathCandidateTrackingActive) { + this.activateRecentPtyPathCandidateTracking() + } const ptyId = this.resolveLeafForHandle(handle)?.ptyId const recentOutput = ptyId ? this.recentPtyOutputById.get(ptyId)?.read() : null if (recentOutput && recentTerminalOutputIncludesPath(recentOutput, pathText, absolutePath)) { diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 1778c7aee42..6c21284e163 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -165,6 +165,83 @@ describe('RecentPtyOutputBuffer', () => { expect(buffer.read()).toBe(reference ?? '') }) + it('retainedChunks preserves original chunk boundaries within the window', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('wrote /tmp/a.json') + buffer.append('suffix.txt') + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + expect(buffer.read()).toBe('wrote /tmp/a.jsonsuffix.txt') + // read() must not collapse the chunk array before compact(): boundaries + // survive a read so the backfill can still replay original chunks. + expect(buffer.retainedChunks().chunks).toEqual(['wrote /tmp/a.json', 'suffix.txt']) + }) + + it('retainedChunks keeps the full original head chunk when the window trims mid-chunk', () => { + const buffer = new RecentPtyOutputBuffer() + const head = 'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 5) + buffer.append(head) + buffer.append('bbbbbbbbbb') + buffer.append('cc') + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + // The head chunk is intact (its original text, trimmed prefix included) + // so candidate backfill sees the same input the eager extractor saw. + expect(chunks).toEqual([head, 'bbbbbbbbbb', 'cc']) + expect(headChunkIsPartial).toBe(false) + expect(buffer.read()).toBe(`${'a'.repeat(RECENT_PTY_OUTPUT_LIMIT - 12)}bbbbbbbbbbcc`) + }) + + it('retainedChunks flags a pre-sliced oversized head chunk as partial', () => { + const buffer = new RecentPtyOutputBuffer() + buffer.append('z'.repeat(RECENT_PTY_OUTPUT_LIMIT + 100)) + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + buffer.append('tail') + expect(buffer.retainedChunks().headChunkIsPartial).toBe(true) + // Once the partial head chunk is fully evicted the flag clears. + buffer.append('w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)) + const { chunks, headChunkIsPartial } = buffer.retainedChunks() + expect(headChunkIsPartial).toBe(false) + expect(chunks).toEqual(['tail', 'w'.repeat(RECENT_PTY_OUTPUT_LIMIT - 1)]) + // An exactly cap-sized append is stored whole, not partial. + const exact = new RecentPtyOutputBuffer() + exact.append('q'.repeat(RECENT_PTY_OUTPUT_LIMIT)) + expect(exact.retainedChunks().headChunkIsPartial).toBe(false) + }) + + it('compact collapses to a single chunk and restores read-time defragmentation', () => { + const buffer = new RecentPtyOutputBuffer() + let reference: string | undefined + for (let i = 0; i < 200; i++) { + const chunk = `${i}:${'p'.repeat(700)}\n` + buffer.append(chunk) + reference = referenceAppend(reference, chunk) + } + buffer.compact() + expect(buffer.retainedChunks().chunks).toEqual([reference]) + expect(buffer.read()).toBe(reference) + // After compact, a read re-collapses fragmentation from later appends. + buffer.append('after') + reference = referenceAppend(reference, 'after') + expect(buffer.read()).toBe(reference) + expect(buffer.retainedChunks().chunks).toEqual([reference]) + }) + + it('retainedChunks window-trimmed join always equals read()', () => { + const rng = mulberry32(0xfeed) + const buffer = new RecentPtyOutputBuffer() + const joinRetained = (): string => { + const { chunks } = buffer.retainedChunks() + const joined = chunks.join('') + return joined.slice(Math.max(0, joined.length - RECENT_PTY_OUTPUT_LIMIT)) + } + for (let i = 0; i < 300; i++) { + buffer.append(String.fromCharCode(33 + (i % 90)).repeat(Math.floor(rng() * 1500))) + if (i % 11 === 0) { + expect(joinRetained()).toBe(buffer.read()) + } + } + expect(joinRetained()).toBe(buffer.read()) + }) + it('stays equivalent under randomized chunk sizes straddling the cap', () => { const rng = mulberry32(0xc0ffee) for (let round = 0; round < 5; round++) { diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index 29af04f1daf..a10559b75c9 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -14,20 +14,33 @@ const DROPPED_HEAD_COMPACT_THRESHOLD = 1024 export class RecentPtyOutputBuffer { private chunks: string[] = [] private headIndex = 0 - // Code units already trimmed off the front of the head chunk. Deferred to - // read() so repeated small trims never allocate a substring per append. + // Code units already trimmed off the front of the head chunk. Deferred so + // repeated small trims never allocate a substring per append, and so the + // head chunk's original text stays available for candidate backfill. private headOffset = 0 private totalLen = 0 + // True when the stored head chunk is not the full original PTY chunk (a + // single over-limit append is stored pre-sliced), so backfill replay knows + // the original line context of its leading text is gone. + private headChunkIsPartial = false + // Original chunk boundaries are owed only to the one-time path-candidate + // backfill; compact() ends that obligation and lets read() collapse. + private preserveChunkBoundaries: boolean + + constructor(options?: { preserveChunkBoundaries?: boolean }) { + this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true + } append(data: string): void { + if (data.length === 0) { + return + } if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] this.headIndex = 0 this.headOffset = 0 this.totalLen = RECENT_PTY_OUTPUT_LIMIT - return - } - if (data.length === 0) { + this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT return } this.chunks.push(data) @@ -40,6 +53,7 @@ export class RecentPtyOutputBuffer { this.chunks[this.headIndex] = '' this.headIndex += 1 this.headOffset = 0 + this.headChunkIsPartial = false this.totalLen -= headRemaining } else { this.headOffset += excess @@ -53,6 +67,19 @@ export class RecentPtyOutputBuffer { } read(): string { + if (this.preserveChunkBoundaries) { + // Join without mutating: boundaries and the original head chunk are + // still owed to retainedChunks(); reads are rare before compact(). + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + } + return retained.join('') + } + const head = this.chunks[this.headIndex] ?? '' + return this.headOffset > 0 ? head.slice(this.headOffset) : head + } if (this.chunks.length - this.headIndex > 1) { // Collapse to the joined tail so repeated reads stay O(1). const retained = this.chunks.slice(this.headIndex) @@ -69,4 +96,28 @@ export class RecentPtyOutputBuffer { } return this.chunks[this.headIndex] ?? '' } + + /** + * Retained chunks with original PTY boundaries. The head chunk is its full + * original text (any window-trimmed prefix included) unless + * headChunkIsPartial. Why: path-candidate backfill must replay the eager + * per-chunk extraction exactly — trimming or joining chunks changes the + * candidate set. Only meaningful before compact(). + */ + retainedChunks(): { chunks: string[]; headChunkIsPartial: boolean } { + return { + chunks: this.chunks.slice(this.headIndex), + headChunkIsPartial: this.headChunkIsPartial + } + } + + /** + * Ends the chunk-boundary obligation after the one-time backfill and + * collapses immediately, so the append/read hot path returns to the + * compact single-chunk steady state. + */ + compact(): void { + this.preserveChunkBoundaries = false + this.read() + } } diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 2c5d069b941..54b775b3178 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: this integration-style RPC test keeps the request/response contract together so regressions in the external CLI surface are easier to spot. */ import { existsSync, mkdtempSync } from 'node:fs' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createConnection, type Socket } from 'node:net' @@ -3273,6 +3274,162 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('authorizes a mobile artifact tap after first-connect backfill even once the raw window scrolls', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getCwd: async () => '/tmp/worktree-a', + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + // Real artifact under the temp root so the grant path stats it. + const artifactPath = join(tmpdir(), `orca-artifact-${process.pid}-${Date.now()}.json`) + await writeFile(artifactPath, '{"ok":true}') + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + title: 'Agent', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'repo-1::/tmp/worktree-a', + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1' + } + ] + }) + // Path printed before any mobile client exists: tracking is inactive, so + // only the retained raw window knows it at connect time. + runtime.onPtyData('pty-1', `wrote ${artifactPath}\n`, 100) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Full direct E2EE authentication drives MobileSocketWiring.onReady (the + // relay transport attaches through the same wiring), which must backfill + // candidates from the raw window without any direct activation call. + const phone = await authenticateMobileWsSession(offer.pairingUrl) + const phoneResponses = createEncryptedWsResponseReader(phone) + try { + // Post-connect pathless output scrolls the artifact out of the raw + // 64KiB window; only the connect-time backfilled candidate can answer. + runtime.onPtyData('pty-1', 'x'.repeat(70 * 1024), 200) + + sendEncryptedWsRequest(phone, { + id: 'phone_terminals', + method: 'terminal.list', + params: { worktree: 'id:repo-1::/tmp/worktree-a' } + }) + const listResponse = await phoneResponses.next('phone_terminals') + const handle = (listResponse.result as { terminals: { handle: string }[] }).terminals[0]! + .handle + expect(handle).toBeTruthy() + + sendEncryptedWsRequest(phone, { + id: 'phone_tap', + method: 'files.resolveTerminalPath', + params: { + worktree: 'id:repo-1::/tmp/worktree-a', + pathText: artifactPath, + terminal: handle + } + }) + await expect(phoneResponses.next('phone_tap')).resolves.toMatchObject({ + ok: true, + result: { + exists: true, + isDirectory: false, + openTarget: { + kind: 'absolute-file', + provider: 'local', + grantId: expect.any(String) + } + } + }) + } finally { + phoneResponses.dispose() + phone.ws.close() + await server.stop() + await rm(artifactPath, { force: true }) + } + }) + + it('completes remote E2EE authentication against a runtime proxy without activateRecentPtyPathCandidateTracking', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + // Why: a remote-host runtime proxy only implements RPC-forwarded methods; + // activation is a local-host concern, so the proxy legitimately lacks + // activateRecentPtyPathCandidateTracking and onReady must not throw. + const runtimeProxy = { + getRuntimeId: () => 'proxy-runtime-test', + getStartedAt: () => 1, + getStatus: () => ({ graphStatus: 'unavailable' }), + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {} + } as unknown as OrcaRuntimeService + expect( + (runtimeProxy as { activateRecentPtyPathCandidateTracking?: unknown }) + .activateRecentPtyPathCandidateTracking + ).toBeUndefined() + const server = new OrcaRuntimeRpcServer({ + runtime: runtimeProxy, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + const offer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'remote', + scope: 'runtime' + }) + expect(offer.available).toBe(true) + if (!offer.available) { + throw new Error('WebSocket pairing unavailable') + } + // Real E2EE pairing + authentication drives MobileSocketWiring.onReady + // before e2ee_authenticated is sent; a throwing onReady never authenticates. + const session = await authenticateMobileWsSession(offer.pairingUrl) + const responses = createEncryptedWsResponseReader(session) + try { + sendEncryptedWsRequest(session, { id: 'proxy_status', method: 'status.get' }) + await expect(responses.next('proxy_status')).resolves.toMatchObject({ + id: 'proxy_status', + ok: true, + result: { graphStatus: 'unavailable' } + }) + } finally { + responses.dispose() + session.ws.close() + await server.stop() + } + }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const writes: { terminal: string; text: string }[] = [] diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f94ba79705c..020eec5183c 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -851,7 +851,15 @@ export class OrcaRuntimeRpcServer { ) }, onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), - onReady: () => this.mobileRelayPairingProvider?.onDemandStateChanged?.(), + onReady: () => { + // Why: first authenticated mobile/remote client (direct WS and + // cloud relay both attach here) starts path-candidate tracking. + // Activation is a local-host concern: candidate buffers live on the + // buffer-owning host's runtime, so a remote runtime proxy may + // legitimately lack this method (its own server activates it). + this.runtime.activateRecentPtyPathCandidateTracking?.() + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + }, onClose: (socket, hasOtherConnections) => { if (!socket) { return