mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
perf(runtime): gate PTY path-candidate extraction on mobile-connected (#9422)
* 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 <help@stably.ai> * 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 <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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<string, number>()
|
||||
private nextPtyLifecycleGeneration = 1
|
||||
private recentPtyPathCandidatesById = new Map<string, string[]>()
|
||||
// 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)) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user