mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
Merge commit '33c14bc7164ccfa32fefb89126786a13a464ee5e' into brennanb2025/mobile-i18n-audit
This commit is contained in:
@@ -3048,6 +3048,105 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "terminal-scroll.streaming-refocus-intent",
|
||||
"title": "Streaming refocus preserves follow-output viewport intent",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "renderer-unit-and-electron-e2e",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"window focus recovery",
|
||||
"hidden-to-visible resume",
|
||||
"xterm write backlog",
|
||||
"scrollback"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon"],
|
||||
"coverageNotes": "Unit coverage proves provider-independent ordering for any PaneManager. Live Electron coverage exercises a local PTY through the daemon on macOS; SSH, WSL, remote-runtime, Linux, and Windows remain unproved for this exact race.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/11753",
|
||||
"https://github.com/stablyai/orca/pull/11915"
|
||||
],
|
||||
"invariant": "When output is queued during a focus or visibility transition, Orca records the pre-flush viewport intent before xterm parses backlog writes, so a follow-output terminal stays at the bottom and a pinned terminal keeps its prior position.",
|
||||
"oracle": "Unit tests require exactly one intent sync before each queued-output flush. The Electron test injects a transient top-of-buffer xterm wobble during refocus and requires every presented scrollbar frame, including the final rendered output, to remain at the bottom.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"pnpm exec electron-vite build --mode e2e",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"tests/e2e/terminal-streaming-refocus-viewport.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": [
|
||||
"window-wake recovery synchronizes viewport intent exactly once before flushing queued output",
|
||||
"heavy visibility resume synchronizes intent exactly once before flushing queued output"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-streaming-refocus-viewport.spec.ts",
|
||||
"assertions": [
|
||||
"phase-one scrollback is visibly ready at the bottom without a fixed sleep",
|
||||
"no presented animation frame moves the scrollbar thumb away from the bottom during refocus",
|
||||
"the final streamed marker renders with the visible scrollbar still at the bottom"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.096,
|
||||
"summary": "14 tests passed, including exact sync count and pre-flush ordering for wake and heavy visibility resume."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-01",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-streaming-refocus-viewport.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=5",
|
||||
"result": "passed",
|
||||
"durationSeconds": 35.2,
|
||||
"summary": "Five consecutive Electron iterations passed after replacing fixed-time readiness and stale tab capture with deterministic viewport and pane-identity oracles."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused renderer unit test or one Electron E2E iteration"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "soaking",
|
||||
"evidence": "Five consecutive local Electron iterations passed; CI soak history is still required before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The controlled xterm viewport wobble reproduces the pinned-top failure with post-flush intent sampling and passes when intent is latched before the queued write flush."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Wake recovery keeps one O(panes) intent pass before the existing bounded 64 KiB-per-pane flush. Heavy resume removes its second intent pass and keeps the existing bounded 256 KiB-per-pane flush; no polling, timers, subprocesses, IPC, output parsing, fit, or repaint work is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect stable CI soak history for the Electron race gate.",
|
||||
"Run the live oracle on Linux and Windows terminal backends.",
|
||||
"Add live SSH or remote-runtime coverage for queued output during refocus."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The deterministic wobble uses xterm private buffer state and must be updated if that internal contract changes.",
|
||||
"Live Linux, Windows, SSH, WSL, and remote-runtime execution is not covered for this exact race.",
|
||||
"The Electron gate proves follow-output behavior; adjacent scroll-intent coverage protects pinned viewport behavior."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the Electron oracle flakes without a product bug or harness bug filed to terminal-rendering."
|
||||
},
|
||||
{
|
||||
"id": "startup-upgrade.persisted-session-corpus",
|
||||
"title": "Current Orca preserves or recovers old production persisted sessions",
|
||||
|
||||
@@ -206,6 +206,8 @@ export const electronViteConfig: UserConfig = {
|
||||
external: isExternalMainModule,
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
|
||||
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
|
||||
|
||||
@@ -1512,6 +1512,38 @@ describe('browserManager', () => {
|
||||
expect(browserManager.getGuestWebContentsId('browser-destroyed-before-register')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a destroyed primary guest from its tab registration maps', () => {
|
||||
const guest = {
|
||||
id: 306,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-destroyed-after-register',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
const destroyedHandler = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'destroyed'
|
||||
)?.[1] as (() => void) | undefined
|
||||
destroyedHandler?.()
|
||||
|
||||
expect(browserManager.getGuestWebContentsId('browser-destroyed-after-register')).toBeNull()
|
||||
const managerState = browserManager as unknown as {
|
||||
tabIdByWebContentsId: Map<number, string>
|
||||
}
|
||||
expect(managerState.tabIdByWebContentsId.has(guest.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('fully unregisters stale guests discovered during authorization', () => {
|
||||
const guest = {
|
||||
id: 305,
|
||||
|
||||
@@ -640,7 +640,6 @@ export class BrowserManager {
|
||||
|
||||
// Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes.
|
||||
const disposeAntiDetection = this.injectAntiDetection(guest)
|
||||
|
||||
// Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty.
|
||||
guest.setBackgroundThrottling(false)
|
||||
const installClickedLinkRouting = (): void => {
|
||||
@@ -940,11 +939,15 @@ export class BrowserManager {
|
||||
private retireStaleGuestWebContents(previousWebContentsId: number): void {
|
||||
// Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session.
|
||||
this.cleanupGuestPolicyAttachment(previousWebContentsId)
|
||||
this.tabIdByWebContentsId.delete(previousWebContentsId)
|
||||
}
|
||||
|
||||
private cleanupGuestPolicyAttachment(guestWebContentsId: number): void {
|
||||
const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId)
|
||||
const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId)
|
||||
const isPrimaryGuest = browserTabId !== undefined
|
||||
if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) {
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
}
|
||||
this.tabIdByWebContentsId.delete(guestWebContentsId)
|
||||
this.certificateTrustController?.onGuestRetired(guestWebContentsId)
|
||||
const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId)
|
||||
if (policyCleanup) {
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
const testState = { dir: '' }
|
||||
|
||||
const fsCalls = vi.hoisted(() => {
|
||||
const blocker = new Int32Array(new SharedArrayBuffer(4))
|
||||
const calls = {
|
||||
recording: false,
|
||||
dirPrefix: '',
|
||||
/** When > 0, every recorded sync call parks the main thread this long — a stalled mount in miniature. */
|
||||
stallMs: 0,
|
||||
syncCalls: [] as string[],
|
||||
asyncCalls: [] as string[],
|
||||
failAsync: null as ((fn: string, target: string) => NodeJS.ErrnoException | null) | null,
|
||||
beforeAsync: null as ((fn: string, target: string) => void) | null,
|
||||
waitAsync: null as ((fn: string, target: string) => Promise<void> | null) | null,
|
||||
reset(): void {
|
||||
calls.syncCalls.length = 0
|
||||
calls.asyncCalls.length = 0
|
||||
calls.stallMs = 0
|
||||
calls.failAsync = null
|
||||
calls.beforeAsync = null
|
||||
calls.waitAsync = null
|
||||
},
|
||||
inScope(target: unknown): target is string {
|
||||
return (
|
||||
calls.recording &&
|
||||
typeof target === 'string' &&
|
||||
calls.dirPrefix !== '' &&
|
||||
target.startsWith(calls.dirPrefix)
|
||||
)
|
||||
},
|
||||
recordSync(fn: string, target: unknown): void {
|
||||
if (!calls.inScope(target)) {
|
||||
return
|
||||
}
|
||||
calls.syncCalls.push(`${fn}:${target}`)
|
||||
if (calls.stallMs > 0) {
|
||||
// Atomics.wait blocks the thread the way an uninterruptible syscall does.
|
||||
Atomics.wait(blocker, 0, 0, calls.stallMs)
|
||||
}
|
||||
},
|
||||
recordAsync(fn: string, target: unknown): NodeJS.ErrnoException | null {
|
||||
if (!calls.inScope(target)) {
|
||||
return null
|
||||
}
|
||||
calls.asyncCalls.push(`${fn}:${target}`)
|
||||
calls.beforeAsync?.(fn, target)
|
||||
return calls.failAsync?.(fn, target) ?? null
|
||||
}
|
||||
}
|
||||
return calls
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of Object.keys(actual)) {
|
||||
const original = (actual as unknown as Record<string, unknown>)[name]
|
||||
if (!name.endsWith('Sync') || typeof original !== 'function') {
|
||||
continue
|
||||
}
|
||||
const fn = original as (...args: unknown[]) => unknown
|
||||
const wrapper = (...args: unknown[]): unknown => {
|
||||
fsCalls.recordSync(name, args[0])
|
||||
return fn(...args)
|
||||
}
|
||||
patched[name] = Object.assign(wrapper, fn)
|
||||
}
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
const patched: Record<string, unknown> = { ...actual }
|
||||
for (const name of ['stat', 'access', 'rename', 'copyFile', 'rm', 'mkdir', 'open']) {
|
||||
const fn = (actual as unknown as Record<string, (...args: unknown[]) => unknown>)[name]
|
||||
patched[name] = async (...args: unknown[]): Promise<unknown> => {
|
||||
const failure = fsCalls.recordAsync(name, args[0])
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
if (typeof args[0] === 'string') {
|
||||
await fsCalls.waitAsync?.(name, args[0])
|
||||
}
|
||||
return fn(...args)
|
||||
}
|
||||
}
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: vi.fn(),
|
||||
sshConfigHostsToTargets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 })
|
||||
}))
|
||||
|
||||
// Deterministic cipher so two stores driven through identical mutations produce identical bytes.
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length)
|
||||
}
|
||||
}))
|
||||
|
||||
const BACKUP_COUNT = 5
|
||||
const BACKUP_MIN_INTERVAL_MS = 60 * 60 * 1000
|
||||
const PAST_ROTATION_INTERVAL_MS = BACKUP_MIN_INTERVAL_MS * 2
|
||||
const SAVE_DEBOUNCE_MS = 1_000
|
||||
|
||||
const ROTATION_INTERLEAVE_CASES = [
|
||||
['initial access', 'access', ''],
|
||||
['oldest removal', 'rm', '.bak.4'],
|
||||
['slot access', 'access', '.bak.0'],
|
||||
['slot rename', 'rename', '.bak.0'],
|
||||
['final copy', 'copyFile', '']
|
||||
] as const
|
||||
|
||||
type TestStore = {
|
||||
updateUI(updates: { sidebarWidth: number }): void
|
||||
waitForPendingWrite(): Promise<void>
|
||||
flushOrThrow(): void
|
||||
}
|
||||
|
||||
async function createStore(dir: string): Promise<TestStore> {
|
||||
testState.dir = dir
|
||||
vi.resetModules()
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store() as unknown as TestStore
|
||||
}
|
||||
|
||||
function dataFile(dir: string): string {
|
||||
return join(dir, 'orca-data.json')
|
||||
}
|
||||
|
||||
function seedStaleBackup(dir: string): void {
|
||||
const path = `${dataFile(dir)}.bak.0`
|
||||
writeFileSync(path, '{"stale":true}', 'utf-8')
|
||||
const staleSeconds = (Date.now() - PAST_ROTATION_INTERVAL_MS) / 1000
|
||||
utimesSync(path, staleSeconds, staleSeconds)
|
||||
}
|
||||
|
||||
function ringSnapshot(dir: string): Record<string, string> {
|
||||
const snapshot: Record<string, string> = {}
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
if (name === 'orca-data.json' || name.startsWith('orca-data.json.bak.')) {
|
||||
snapshot[name] = readFileSync(join(dir, name), 'utf-8')
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
describe('async persistence write path avoids synchronous fs syscalls', () => {
|
||||
const dirs: string[] = []
|
||||
|
||||
function makeDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-async-write-'))
|
||||
dirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fsCalls.recording = false
|
||||
fsCalls.dirPrefix = ''
|
||||
fsCalls.reset()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fsCalls.recording = false
|
||||
vi.useRealTimers()
|
||||
while (dirs.length > 0) {
|
||||
rmSync(dirs.pop() as string, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function recordAsyncSave(
|
||||
store: TestStore,
|
||||
dir: string,
|
||||
sidebarWidth: number
|
||||
): Promise<void> {
|
||||
store.updateUI({ sidebarWidth })
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.recording = true
|
||||
try {
|
||||
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
fsCalls.recording = false
|
||||
}
|
||||
}
|
||||
|
||||
it('issues no sync fs syscall under the profile dir when rotation runs with an empty ring', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
|
||||
await recordAsyncSave(store, dir, 301)
|
||||
|
||||
expect(fsCalls.syncCalls).toEqual([])
|
||||
// Rotation must actually have happened, else the assertion above is vacuous.
|
||||
expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8'))
|
||||
})
|
||||
|
||||
it('uses an awaited stat, not statSync, for the backup rotation interval check', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
|
||||
await recordAsyncSave(store, dir, 302)
|
||||
|
||||
expect(fsCalls.syncCalls).toEqual([])
|
||||
expect(fsCalls.asyncCalls).toContain(`stat:${dataFile(dir)}.bak.0`)
|
||||
})
|
||||
|
||||
it('issues no sync fs syscall while rotating a saturated ring', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
|
||||
// One more save than the ring holds, so the oldest slot is evicted and every slot renames.
|
||||
for (let i = 0; i <= BACKUP_COUNT; i++) {
|
||||
await recordAsyncSave(store, dir, 310 + i)
|
||||
}
|
||||
|
||||
expect(fsCalls.syncCalls).toEqual([])
|
||||
const ring = ringSnapshot(dir)
|
||||
expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`)
|
||||
expect(Object.keys(ring)).not.toContain(`orca-data.json.bak.${BACKUP_COUNT}`)
|
||||
})
|
||||
|
||||
it('lets a main-thread timer keep firing while a rotating save is in flight', async () => {
|
||||
// A recorded sync call stalls long enough for the heartbeat to catch it.
|
||||
vi.useRealTimers()
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
// Generous stall so ordinary scheduler/GC jitter can't reach the threshold on a loaded CI box.
|
||||
const stallMs = 500
|
||||
|
||||
let lastTick = Date.now()
|
||||
let worstGapMs = 0
|
||||
const heartbeat = setInterval(() => {
|
||||
const now = Date.now()
|
||||
worstGapMs = Math.max(worstGapMs, now - lastTick)
|
||||
lastTick = now
|
||||
}, 10)
|
||||
|
||||
try {
|
||||
store.updateUI({ sidebarWidth: 341 })
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.stallMs = stallMs
|
||||
fsCalls.recording = true
|
||||
lastTick = Date.now()
|
||||
await new Promise((resolve) => setTimeout(resolve, SAVE_DEBOUNCE_MS + 200))
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
fsCalls.recording = false
|
||||
clearInterval(heartbeat)
|
||||
}
|
||||
|
||||
expect(worstGapMs).toBeLessThan(stallMs)
|
||||
// The save really happened, so a small gap isn't just an absent write.
|
||||
expect(ringSnapshot(dir)['orca-data.json.bak.0']).toBe(readFileSync(dataFile(dir), 'utf-8'))
|
||||
}, 20_000)
|
||||
|
||||
it('skips rotation when a sync flush rotated during the rotation-interval await', async () => {
|
||||
// The acquired owner keeps rotation when the flush skips the ring.
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8')
|
||||
|
||||
store.updateUI({ sidebarWidth: 361 })
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.recording = true
|
||||
let flushed = false
|
||||
fsCalls.beforeAsync = (fn, target) => {
|
||||
if (flushed || fn !== 'stat' || !target.endsWith('.bak.0')) {
|
||||
return
|
||||
}
|
||||
flushed = true
|
||||
store.updateUI({ sidebarWidth: 362 })
|
||||
store.flushOrThrow()
|
||||
}
|
||||
try {
|
||||
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
fsCalls.recording = false
|
||||
fsCalls.beforeAsync = null
|
||||
}
|
||||
|
||||
expect(flushed).toBe(true)
|
||||
const ring = ringSnapshot(dir)
|
||||
expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json'])
|
||||
expect(ring['orca-data.json.bak.1']).toBe(staleBackup)
|
||||
expect(ring['orca-data.json.bak.2']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the due check inside ownership when a second writer starts', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
const almostDueSeconds = (Date.now() - (BACKUP_MIN_INTERVAL_MS - SAVE_DEBOUNCE_MS - 100)) / 1000
|
||||
utimesSync(`${dataFile(dir)}.bak.0`, almostDueSeconds, almostDueSeconds)
|
||||
const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8')
|
||||
const statCall = `stat:${dataFile(dir)}.bak.0`
|
||||
let releaseRotation!: () => void
|
||||
const rotationRelease = new Promise<void>((resolve) => {
|
||||
releaseRotation = resolve
|
||||
})
|
||||
let signalRotation!: () => void
|
||||
const rotationStarted = new Promise<void>((resolve) => {
|
||||
signalRotation = resolve
|
||||
})
|
||||
let held = false
|
||||
fsCalls.waitAsync = (fn, target) => {
|
||||
if (held || fn !== 'stat' || target !== `${dataFile(dir)}.bak.0`) {
|
||||
return null
|
||||
}
|
||||
held = true
|
||||
signalRotation()
|
||||
return rotationRelease
|
||||
}
|
||||
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.recording = true
|
||||
store.updateUI({ sidebarWidth: 371 })
|
||||
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
|
||||
const firstWrite = store.waitForPendingWrite()
|
||||
try {
|
||||
await rotationStarted
|
||||
store.updateUI({ sidebarWidth: 372 })
|
||||
store.flushOrThrow()
|
||||
store.updateUI({ sidebarWidth: 373 })
|
||||
vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)
|
||||
await store.waitForPendingWrite()
|
||||
expect(fsCalls.asyncCalls.filter((call) => call === statCall)).toHaveLength(1)
|
||||
} finally {
|
||||
releaseRotation()
|
||||
await firstWrite
|
||||
fsCalls.recording = false
|
||||
fsCalls.waitAsync = null
|
||||
}
|
||||
const ring = ringSnapshot(dir)
|
||||
expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json'])
|
||||
expect(ring['orca-data.json.bak.1']).toBe(staleBackup)
|
||||
expect(ring['orca-data.json.bak.2']).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(ROTATION_INTERLEAVE_CASES)(
|
||||
'keeps one rotation owner when a sync flush lands during %s',
|
||||
async (_phase, expectedFn, targetSuffix) => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
const staleBackup = readFileSync(`${dataFile(dir)}.bak.0`, 'utf-8')
|
||||
const expectedTarget = `${dataFile(dir)}${targetSuffix}`
|
||||
|
||||
store.updateUI({ sidebarWidth: 363 })
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.recording = true
|
||||
let flushed = false
|
||||
fsCalls.beforeAsync = (fn, target) => {
|
||||
if (flushed || fn !== expectedFn || target !== expectedTarget) {
|
||||
return
|
||||
}
|
||||
flushed = true
|
||||
store.updateUI({ sidebarWidth: 364 })
|
||||
store.flushOrThrow()
|
||||
}
|
||||
try {
|
||||
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
|
||||
await store.waitForPendingWrite()
|
||||
} finally {
|
||||
fsCalls.recording = false
|
||||
fsCalls.beforeAsync = null
|
||||
}
|
||||
|
||||
expect(flushed).toBe(true)
|
||||
const ring = ringSnapshot(dir)
|
||||
expect(ring['orca-data.json.bak.0']).toBe(ring['orca-data.json'])
|
||||
expect(ring['orca-data.json.bak.1']).toBe(staleBackup)
|
||||
expect(ring['orca-data.json.bak.2']).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not log for absent ring slots even when the mount rejects renames non-ENOENT', async () => {
|
||||
// Probing keeps degraded mounts from logging one error per absent slot.
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fsCalls.failAsync = (fn, target) =>
|
||||
fn === 'rename' && target.includes('.bak.')
|
||||
? Object.assign(new Error('stale NFS file handle'), { code: 'ESTALE' })
|
||||
: null
|
||||
|
||||
try {
|
||||
await recordAsyncSave(store, dir, 351)
|
||||
expect(errors).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
errors.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('logs when a ring slot that exists fails to rotate', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fsCalls.failAsync = (fn, target) =>
|
||||
fn === 'rename' && target.endsWith('.bak.0')
|
||||
? Object.assign(new Error('permission denied'), { code: 'EPERM' })
|
||||
: null
|
||||
|
||||
try {
|
||||
await recordAsyncSave(store, dir, 352)
|
||||
expect(errors).toHaveBeenCalledWith(
|
||||
'[persistence] Failed to rotate backup',
|
||||
`${dataFile(dir)}.bak.0`,
|
||||
'->',
|
||||
`${dataFile(dir)}.bak.1`,
|
||||
expect.objectContaining({ code: 'EPERM' })
|
||||
)
|
||||
} finally {
|
||||
errors.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('produces a .bak ring byte-identical to the sync path, at capacity and with ring holes', async () => {
|
||||
const asyncDir = makeDir()
|
||||
const syncDir = makeDir()
|
||||
// Match generated IDs so only rotation behavior can differ.
|
||||
const seedDir = makeDir()
|
||||
const seedStore = await createStore(seedDir)
|
||||
seedStore.updateUI({ sidebarWidth: 320 })
|
||||
seedStore.flushOrThrow()
|
||||
const seed = readFileSync(dataFile(seedDir), 'utf-8')
|
||||
for (const dir of [asyncDir, syncDir]) {
|
||||
writeFileSync(dataFile(dir), seed, 'utf-8')
|
||||
// Holes: slots 1 and 3 occupied, 0 and 2 missing — exercises the per-slot existence branch.
|
||||
writeFileSync(`${dataFile(dir)}.bak.1`, '{"old":1}', 'utf-8')
|
||||
writeFileSync(`${dataFile(dir)}.bak.3`, '{"old":3}', 'utf-8')
|
||||
}
|
||||
|
||||
const widths = [321, 322, 323, 324, 325, 326]
|
||||
const asyncStore = await createStore(asyncDir)
|
||||
for (const width of widths) {
|
||||
asyncStore.updateUI({ sidebarWidth: width })
|
||||
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
|
||||
await asyncStore.waitForPendingWrite()
|
||||
}
|
||||
|
||||
const syncStore = await createStore(syncDir)
|
||||
for (const width of widths) {
|
||||
syncStore.updateUI({ sidebarWidth: width })
|
||||
syncStore.flushOrThrow()
|
||||
vi.advanceTimersByTime(PAST_ROTATION_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const ring = ringSnapshot(asyncDir)
|
||||
expect(ring).toEqual(ringSnapshot(syncDir))
|
||||
expect(Object.keys(ring)).toContain(`orca-data.json.bak.${BACKUP_COUNT - 1}`)
|
||||
})
|
||||
|
||||
it('keeps the sync quit/crash fallback on synchronous syscalls', async () => {
|
||||
const dir = makeDir()
|
||||
const store = await createStore(dir)
|
||||
seedStaleBackup(dir)
|
||||
|
||||
store.updateUI({ sidebarWidth: 331 })
|
||||
fsCalls.dirPrefix = dir
|
||||
fsCalls.recording = true
|
||||
try {
|
||||
store.flushOrThrow()
|
||||
} finally {
|
||||
fsCalls.recording = false
|
||||
}
|
||||
|
||||
expect(fsCalls.syncCalls).toContain(`statSync:${dataFile(dir)}.bak.0`)
|
||||
expect(fsCalls.syncCalls).toContain(`existsSync:${dataFile(dir)}`)
|
||||
expect(fsCalls.asyncCalls).toEqual([])
|
||||
})
|
||||
})
|
||||
+58
-24
@@ -11,7 +11,7 @@ import {
|
||||
statSync,
|
||||
realpathSync
|
||||
} from 'node:fs'
|
||||
import { rename, mkdir, rm, copyFile, open } from 'node:fs/promises'
|
||||
import { rename, mkdir, rm, copyFile, open, stat, access } from 'node:fs/promises'
|
||||
import { renameDurable, writeFileDurableSync } from './durable-file-write'
|
||||
import { join, dirname, isAbsolute, resolve, sep } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
@@ -589,6 +589,14 @@ function backupPath(dataFile: string, index: number): string {
|
||||
return `${dataFile}.bak.${index}`
|
||||
}
|
||||
|
||||
/** existsSync's non-blocking twin: existsSync is an access(F_OK) probe, so access() is the exact analogue. */
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
function buildWorkspaceDirHistoryForUpdate(
|
||||
current: GlobalSettings,
|
||||
updates: Partial<GlobalSettings>
|
||||
@@ -2742,6 +2750,8 @@ export class Store {
|
||||
private writeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingWrite: Promise<void> | null = null
|
||||
private writeGeneration = 0
|
||||
// Prevent a sync flush from interleaving a second rotation with awaited ring mutations.
|
||||
private backupRotationInFlight = false
|
||||
// Why: after a profile transfer rewrites this file on disk, a late flush of stale in-memory state would resurrect the moved project.
|
||||
private writesFrozen = false
|
||||
// Content hash at last write, to skip no-op writes; derived from the payload with encrypted blobs normalized back to plaintext (see buildStateToSave), since encrypt() uses a random IV per call.
|
||||
@@ -2884,28 +2894,52 @@ export class Store {
|
||||
}
|
||||
}
|
||||
|
||||
// Why separate from the sync twin: a statSync here parks the Electron main thread in uninterruptible
|
||||
// sleep on a stalled SMB/NFS profile mount. Error semantics are deliberately identical: rotate.
|
||||
private async shouldRotateBackupsAsync(dataFile: string): Promise<boolean> {
|
||||
try {
|
||||
const mtime = (await stat(backupPath(dataFile, 0))).mtimeMs
|
||||
return Date.now() - mtime >= BACKUP_MIN_INTERVAL_MS
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rotate current file into the .bak ring so load() can recover if a later primary write is truncated or corrupt.
|
||||
private async rotateBackupsAsync(dataFile: string): Promise<void> {
|
||||
if (!existsSync(dataFile)) {
|
||||
if (this.backupRotationInFlight) {
|
||||
return
|
||||
}
|
||||
await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[persistence] Failed to remove oldest backup:', err)
|
||||
this.backupRotationInFlight = true
|
||||
try {
|
||||
if (!(await this.shouldRotateBackupsAsync(dataFile))) {
|
||||
return
|
||||
}
|
||||
})
|
||||
for (let i = BACKUP_COUNT - 2; i >= 0; i--) {
|
||||
const src = backupPath(dataFile, i)
|
||||
const dst = backupPath(dataFile, i + 1)
|
||||
if (existsSync(src)) {
|
||||
await rename(src, dst).catch((err) => {
|
||||
console.error('[persistence] Failed to rotate backup', src, '->', dst, err)
|
||||
})
|
||||
if (!(await exists(dataFile))) {
|
||||
return
|
||||
}
|
||||
await rm(backupPath(dataFile, BACKUP_COUNT - 1)).catch((err: unknown) => {
|
||||
if (err && (err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.error('[persistence] Failed to remove oldest backup:', err)
|
||||
}
|
||||
})
|
||||
for (let i = BACKUP_COUNT - 2; i >= 0; i--) {
|
||||
const src = backupPath(dataFile, i)
|
||||
const dst = backupPath(dataFile, i + 1)
|
||||
// Why probe instead of rename-then-swallow-ENOENT: a degraded mount rejects a rename of an
|
||||
// absent slot with ESTALE/EIO, which would log once per empty slot on every debounced save.
|
||||
if (await exists(src)) {
|
||||
await rename(src, dst).catch((err) => {
|
||||
console.error('[persistence] Failed to rotate backup', src, '->', dst, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => {
|
||||
console.error('[persistence] Failed to snapshot current file to .bak.0:', err)
|
||||
})
|
||||
} finally {
|
||||
this.backupRotationInFlight = false
|
||||
}
|
||||
await copyFile(dataFile, backupPath(dataFile, 0)).catch((err) => {
|
||||
console.error('[persistence] Failed to snapshot current file to .bak.0:', err)
|
||||
})
|
||||
}
|
||||
|
||||
private rotateBackupsSync(dataFile: string): void {
|
||||
@@ -3886,18 +3920,15 @@ export class Store {
|
||||
await rm(tmpFile).catch(() => {})
|
||||
}
|
||||
}
|
||||
// Why (issue #1158): rotate backups only after rename succeeded, and let a concurrent flush own rotation.
|
||||
// Why (#1158): rotate only after the primary rename while this write still owns its generation.
|
||||
if (this.writeGeneration !== gen) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (this.shouldRotateBackups(now, dataFile)) {
|
||||
await this.rotateBackupsAsync(dataFile)
|
||||
}
|
||||
await this.rotateBackupsAsync(dataFile)
|
||||
}
|
||||
|
||||
// Why: sync variant only for flush() at shutdown, where the process may exit before an async write completes.
|
||||
private writeToDiskSync(opts: { force?: boolean } = {}): void {
|
||||
private writeToDiskSync(opts: { force?: boolean; skipBackupRotation?: boolean } = {}): void {
|
||||
if (this.writesFrozen) {
|
||||
return
|
||||
}
|
||||
@@ -3931,7 +3962,7 @@ export class Store {
|
||||
}
|
||||
}
|
||||
const now = Date.now()
|
||||
if (this.shouldRotateBackups(now, dataFile)) {
|
||||
if (!opts.skipBackupRotation && this.shouldRotateBackups(now, dataFile)) {
|
||||
this.rotateBackupsSync(dataFile)
|
||||
}
|
||||
}
|
||||
@@ -3946,7 +3977,10 @@ export class Store {
|
||||
// Why: bump writeGeneration so an in-flight async write skips its rename and can't overwrite this sync write.
|
||||
this.writeGeneration++
|
||||
this.pendingWrite = null
|
||||
this.writeToDiskSync({ force: asyncWriteWasInFlight })
|
||||
this.writeToDiskSync({
|
||||
force: asyncWriteWasInFlight,
|
||||
skipBackupRotation: this.backupRotationInFlight
|
||||
})
|
||||
}
|
||||
|
||||
flushActiveViewPreferenceOrThrow(): void {
|
||||
|
||||
@@ -8,18 +8,26 @@ import { isOpenSshConfigBackedTarget } from './system-ssh-args'
|
||||
|
||||
// Why: ssh2 only tries keys that are explicitly provided. Users with keys in
|
||||
// standard locations (e.g. ~/.ssh/id_ed25519) but no SSH agent running would
|
||||
// fail to authenticate. Probing default paths matches VS Code's _findDefaultKeyFile.
|
||||
// fail to authenticate. Probe the regular and FIDO2 OpenSSH default paths.
|
||||
const DEFAULT_KEY_NAMES = ['id_ed25519', 'id_rsa', 'id_ecdsa', 'id_dsa', 'id_xmss']
|
||||
const DEFAULT_SECURITY_KEY_NAMES = ['id_ed25519_sk', 'id_ecdsa_sk']
|
||||
|
||||
const DEFAULT_KEY_PATHS = DEFAULT_KEY_NAMES.map((name) => `~/.ssh/${name}`)
|
||||
const DEFAULT_IDENTITY_PATHS = [...DEFAULT_KEY_NAMES, ...DEFAULT_SECURITY_KEY_NAMES].map(
|
||||
(name) => `~/.ssh/${name}`
|
||||
)
|
||||
const WINDOWS_OPENSSH_AGENT_PIPE = '\\\\.\\pipe\\openssh-ssh-agent'
|
||||
|
||||
// Why: resolved IdentityFile paths are expanded before auth resolution, so they
|
||||
// won't match the ~/... form in DEFAULT_KEY_PATHS.
|
||||
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_KEY_PATHS.map(resolveSshConfigHomePath)
|
||||
const EXPANDED_DEFAULT_KEY_PATHS = DEFAULT_IDENTITY_PATHS.map(resolveSshConfigHomePath)
|
||||
|
||||
export type PrivateKeyFile = { path: string; contents: Buffer }
|
||||
|
||||
export function listDefaultIdentityFilePaths(): string[] {
|
||||
return [...DEFAULT_IDENTITY_PATHS]
|
||||
}
|
||||
|
||||
export function findDefaultKeyFile(): PrivateKeyFile | undefined {
|
||||
for (const keyPath of DEFAULT_KEY_PATHS) {
|
||||
const resolved = resolveSshConfigHomePath(keyPath)
|
||||
@@ -97,7 +105,10 @@ function resolveExplicitPrivateKeyPaths(
|
||||
return resolvedIdentities
|
||||
}
|
||||
|
||||
function resolvePrivateKeyPaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
|
||||
export function resolveIdentityFilePaths(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
|
||||
): string[] {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return resolved.identityFile
|
||||
}
|
||||
@@ -138,7 +149,7 @@ export function resolvePrivateKeys(
|
||||
target: SshTarget,
|
||||
resolved: SshResolvedConfig | null
|
||||
): PrivateKeyFile[] {
|
||||
const keyPaths = resolvePrivateKeyPaths(target, resolved)
|
||||
const keyPaths = resolveIdentityFilePaths(target, resolved)
|
||||
if (keyPaths.length > 0 || resolved || target.identityFile) {
|
||||
return readPrivateKeys(keyPaths)
|
||||
}
|
||||
@@ -174,16 +185,6 @@ export function findEncryptedPrivateKeyPath(keys: PrivateKeyFile[]): string | un
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveIdentityFilePaths(target: SshTarget, resolved: SshResolvedConfig | null): string[] {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return resolved.identityFile
|
||||
}
|
||||
if (target.identityFile) {
|
||||
return [target.identityFile]
|
||||
}
|
||||
return resolved?.identityFile ?? []
|
||||
}
|
||||
|
||||
export function resolveAgentConfigValue(
|
||||
agentSocket: string,
|
||||
target: SshTarget,
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('findDefaultKeyFile', () => {
|
||||
expect(result!.contents).toEqual(Buffer.from('key-contents'))
|
||||
})
|
||||
|
||||
it('probes keys in VS Code order: ed25519, rsa, ecdsa, dsa, xmss', () => {
|
||||
it('probes regular and FIDO2 keys in stable default order', () => {
|
||||
const checkedPaths: string[] = []
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
checkedPaths.push(String(path))
|
||||
@@ -331,6 +331,34 @@ describe('findDefaultKeyFile', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a regular default ahead of a malformed FIDO2 default', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return (
|
||||
path === testHomePath('.ssh', 'id_rsa') || path === testHomePath('.ssh', 'id_ed25519_sk')
|
||||
)
|
||||
})
|
||||
mockReadFileSync.mockImplementation((path: unknown) => {
|
||||
if (String(path) === testHomePath('.ssh', 'id_ed25519_sk')) {
|
||||
throw new Error('malformed FIDO2 key')
|
||||
}
|
||||
return Buffer.from('rsa-key')
|
||||
})
|
||||
|
||||
expect(findDefaultKeyFile()).toEqual({
|
||||
path: '~/.ssh/id_rsa',
|
||||
contents: Buffer.from('rsa-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves FIDO2 defaults out of the ssh2 private-key fallback', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return path === testHomePath('.ssh', 'id_ed25519_sk')
|
||||
})
|
||||
|
||||
expect(findDefaultKeyFile()).toBeUndefined()
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips unreadable key files and tries next', () => {
|
||||
mockExistsSync.mockImplementation((path: unknown) => {
|
||||
return path === testHomePath('.ssh', 'id_ed25519') || path === testHomePath('.ssh', 'id_rsa')
|
||||
|
||||
@@ -16,6 +16,7 @@ let execBehavior: 'callback' | 'pending' = 'callback'
|
||||
let pendingExecCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
|
||||
let sftpBehavior: 'callback' | 'pending' = 'callback'
|
||||
let pendingSftpCallback: ((err: Error | undefined, channel: unknown) => void) | null = null
|
||||
let notifyClientCreated: (() => void) | undefined
|
||||
|
||||
type MockSshClient = {
|
||||
setNoDelay: ReturnType<typeof vi.fn>
|
||||
@@ -45,6 +46,8 @@ vi.mock('ssh2', () => {
|
||||
lastConnectConfig?: unknown
|
||||
constructor() {
|
||||
clientInstances.push(this)
|
||||
notifyClientCreated?.()
|
||||
notifyClientCreated = undefined
|
||||
}
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
const handlers = eventHandlers?.get(event) ?? new Set<(...args: unknown[]) => void>()
|
||||
@@ -168,6 +171,10 @@ import {
|
||||
} from './ssh-system-fallback'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import {
|
||||
createOpenSshPrivateKeyFixture,
|
||||
createOpenSshPublicKeyFixture
|
||||
} from './ssh-security-key-identity.test-fixture'
|
||||
|
||||
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
|
||||
return {
|
||||
@@ -278,6 +285,7 @@ describe('SshConnection', () => {
|
||||
pendingExecCallback = null
|
||||
sftpBehavior = 'callback'
|
||||
pendingSftpCallback = null
|
||||
notifyClientCreated = undefined
|
||||
clientInstances = []
|
||||
getOrcaControlSocketPathMock.mockReset()
|
||||
getOrcaControlSocketPathMock.mockReturnValue(null)
|
||||
@@ -462,10 +470,11 @@ describe('SshConnection', () => {
|
||||
const callbacks = createCallbacks()
|
||||
const conn = new SshConnection(createTarget(), callbacks)
|
||||
|
||||
const clientCreated = new Promise<void>((resolve) => {
|
||||
notifyClientCreated = resolve
|
||||
})
|
||||
const connectResult = conn.connect().catch((error: Error) => error)
|
||||
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await clientCreated
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
await conn.disconnect()
|
||||
|
||||
@@ -485,10 +494,11 @@ describe('SshConnection', () => {
|
||||
const callbacks = createCallbacks()
|
||||
const conn = new SshConnection(createTarget(), callbacks)
|
||||
|
||||
const clientCreated = new Promise<void>((resolve) => {
|
||||
notifyClientCreated = resolve
|
||||
})
|
||||
const connectResult = conn.connect().catch((error: Error) => error)
|
||||
for (let i = 0; i < 5 && clientInstances.length === 0; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await clientCreated
|
||||
expect(clientInstances).toHaveLength(1)
|
||||
await conn.disconnect()
|
||||
|
||||
@@ -1462,6 +1472,47 @@ describe('SshConnection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses system SSH before ssh2 parses a security-key private key', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-connect-'))
|
||||
const keyPath = join(directory, 'id_ed25519_sk')
|
||||
writeFileSync(
|
||||
keyPath,
|
||||
createOpenSshPrivateKeyFixture(['sk-ssh-ed25519@openssh.com'], { encrypted: true })
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ identityFile: keyPath }), createCallbacks())
|
||||
|
||||
try {
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.getState().status).toBe('connected')
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(clientInstances).toHaveLength(0)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses system SSH for an agent-backed security-key public identity', async () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-security-key-agent-connect-'))
|
||||
const identityPath = join(directory, 'id_ed25519_sk')
|
||||
writeFileSync(
|
||||
`${identityPath}.pub`,
|
||||
createOpenSshPublicKeyFixture('sk-ssh-ed25519@openssh.com')
|
||||
)
|
||||
const conn = new SshConnection(createTarget({ identityFile: identityPath }), createCallbacks())
|
||||
|
||||
try {
|
||||
await conn.connect()
|
||||
|
||||
expect(conn.usesSystemSshTransport()).toBe(true)
|
||||
expect(clientInstances).toHaveLength(0)
|
||||
expect(spawnSystemSshCommandMock).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to system SSH when ssh2 hits a local network policy reachability error', async () => {
|
||||
connectBehavior = 'error'
|
||||
connectErrorMessage = 'connect EHOSTUNREACH 192.168.0.210:22 - Local (192.168.0.2:52112)'
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
type SshConnectionCallbacks
|
||||
} from './ssh-connection-utils'
|
||||
import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication'
|
||||
import {
|
||||
requiresSystemSshForSecurityKey,
|
||||
shouldUseSystemSshTransport
|
||||
} from './ssh-transport-selection'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import {
|
||||
resolveSftpTransferPathIfMapped,
|
||||
@@ -636,7 +640,14 @@ export class SshConnection {
|
||||
const resolved = await resolveWithSshG(this.target.configHost || this.target.label).catch(
|
||||
() => null
|
||||
)
|
||||
if (shouldUseSystemSshTransport(this.target, resolved)) {
|
||||
const usesConfiguredSystemTransport = shouldUseSystemSshTransport(this.target, resolved)
|
||||
const requiresSecurityKeyTransport = usesConfiguredSystemTransport
|
||||
? false
|
||||
: await requiresSystemSshForSecurityKey(this.target, resolved)
|
||||
if (!this.isCurrentConnectAttempt(connectGeneration)) {
|
||||
throw this.createCancelledConnectAttemptError()
|
||||
}
|
||||
if (usesConfiguredSystemTransport || requiresSecurityKeyTransport) {
|
||||
await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved)
|
||||
return
|
||||
}
|
||||
@@ -1386,24 +1397,4 @@ export class SshConnection {
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseSystemSshTransport(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'> | null
|
||||
): boolean {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
resolved.proxyUseFdpass === true ||
|
||||
resolved.proxyCommand != null ||
|
||||
resolved.proxyJump != null
|
||||
)
|
||||
}
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
target.proxyCommand != null ||
|
||||
target.jumpHost != null ||
|
||||
resolved?.proxyUseFdpass === true ||
|
||||
resolved?.proxyCommand != null ||
|
||||
resolved?.proxyJump != null
|
||||
)
|
||||
}
|
||||
export { shouldUseSystemSshTransport } from './ssh-transport-selection'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
function encodeUint32(value: number): Buffer {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32BE(value)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function sshString(value: string | Buffer): Buffer {
|
||||
const contents = typeof value === 'string' ? Buffer.from(value, 'ascii') : value
|
||||
return Buffer.concat([encodeUint32(contents.length), contents])
|
||||
}
|
||||
|
||||
export function createOpenSshPrivateKeyFixture(
|
||||
keyTypes: string[],
|
||||
options: { encrypted?: boolean; cipher?: string; privateBlock?: Buffer; authTag?: Buffer } = {}
|
||||
): Buffer {
|
||||
const cipher = options.cipher ?? (options.encrypted ? 'aes256-ctr' : 'none')
|
||||
const encrypted = cipher !== 'none'
|
||||
const publicKeys = keyTypes.map((keyType) => sshString(sshString(keyType)))
|
||||
const decoded = Buffer.concat([
|
||||
Buffer.from('openssh-key-v1\0', 'ascii'),
|
||||
sshString(cipher),
|
||||
sshString(encrypted ? 'bcrypt' : 'none'),
|
||||
sshString(encrypted ? Buffer.from('fixture-kdf') : Buffer.alloc(0)),
|
||||
encodeUint32(publicKeys.length),
|
||||
...publicKeys,
|
||||
sshString(options.privateBlock ?? Buffer.from('fixture-private-block')),
|
||||
options.authTag ?? Buffer.alloc(0)
|
||||
])
|
||||
const encoded =
|
||||
decoded
|
||||
.toString('base64')
|
||||
.match(/.{1,70}/g)
|
||||
?.join('\n') ?? ''
|
||||
return Buffer.from(
|
||||
`-----BEGIN OPENSSH PRIVATE KEY-----\n${encoded}\n-----END OPENSSH PRIVATE KEY-----\n`
|
||||
)
|
||||
}
|
||||
|
||||
export function createOpenSshPublicKeyFixture(keyType: string): Buffer {
|
||||
const encoded = sshString(keyType).toString('base64')
|
||||
return Buffer.from(`${keyType} ${encoded} fixture-comment\n`)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import {
|
||||
isOpenSshSecurityKeyPrivateKey,
|
||||
isOpenSshSecurityKeyPublicKey
|
||||
} from './ssh-security-key-identity'
|
||||
import {
|
||||
createOpenSshPrivateKeyFixture,
|
||||
createOpenSshPublicKeyFixture
|
||||
} from './ssh-security-key-identity.test-fixture'
|
||||
import { requiresSystemSshForSecurityKey } from './ssh-transport-selection'
|
||||
|
||||
const { findSystemSshMock } = vi.hoisted(() => ({ findSystemSshMock: vi.fn() }))
|
||||
|
||||
vi.mock('./system-ssh-binary', () => ({ findSystemSsh: findSystemSshMock }))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<{ homedir: () => string }>()
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => process.env.ORCA_TEST_SSH_HOME || actual.homedir()
|
||||
}
|
||||
})
|
||||
|
||||
const ED25519_SECURITY_KEY = 'sk-ssh-ed25519@openssh.com'
|
||||
const ECDSA_SECURITY_KEY = 'sk-ecdsa-sha2-nistp256@openssh.com'
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function createTarget(overrides: Partial<SshTarget> = {}): SshTarget {
|
||||
return {
|
||||
id: 'target-1',
|
||||
label: 'Test Server',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'deploy',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
findSystemSshMock.mockReset()
|
||||
findSystemSshMock.mockReturnValue('/usr/bin/ssh')
|
||||
})
|
||||
|
||||
async function writeKey(contents: Buffer, filename = 'security key'): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-'))
|
||||
tempDirs.push(directory)
|
||||
const keyPath = join(directory, filename)
|
||||
await writeFile(keyPath, contents)
|
||||
return keyPath
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true })))
|
||||
})
|
||||
|
||||
describe('isOpenSshSecurityKeyPrivateKey', () => {
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'recognizes unencrypted %s keys',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('recognizes authenticated encrypted envelopes with a trailing tag', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
|
||||
cipher: 'aes256-gcm@openssh.com',
|
||||
authTag: Buffer.alloc(16, 7)
|
||||
})
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'recognizes encrypted %s keys from the public section',
|
||||
(keyType) => {
|
||||
const key = createOpenSshPrivateKeyFixture([keyType], { encrypted: true })
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ssh-rsa'])(
|
||||
'leaves regular %s keys on ssh2',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPrivateKey(createOpenSshPrivateKeyFixture([keyType]))).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('supports CRLF armored keys', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
expect(
|
||||
isOpenSshSecurityKeyPrivateKey(Buffer.from(key.toString().replaceAll('\n', '\r\n')))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes OpenSSH envelopes without optional base64 padding', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY], {
|
||||
privateBlock: Buffer.alloc(0)
|
||||
})
|
||||
const unpadded = Buffer.from(key.toString().replace(/=+(?=\n-----END)/, ''))
|
||||
expect(unpadded).not.toEqual(key)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(unpadded)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match security-key text outside a valid public-key type', () => {
|
||||
const key = createOpenSshPrivateKeyFixture(['ssh-ed25519'], {
|
||||
privateBlock: Buffer.from(ED25519_SECURITY_KEY)
|
||||
})
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key)).toBe(false)
|
||||
expect(
|
||||
isOpenSshSecurityKeyPrivateKey(Buffer.from(`${ED25519_SECURITY_KEY} AAAA comment`))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'validates the %s type inside an OpenSSH public key blob',
|
||||
(keyType) => {
|
||||
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture(keyType))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('recognizes public keys without optional base64 padding', () => {
|
||||
const key = createOpenSshPublicKeyFixture(ECDSA_SECURITY_KEY)
|
||||
const unpadded = Buffer.from(key.toString().replace(/=+(?=\s)/, ''))
|
||||
expect(unpadded).not.toEqual(key)
|
||||
expect(isOpenSshSecurityKeyPublicKey(unpadded)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects regular or mismatched OpenSSH public key blobs', () => {
|
||||
expect(isOpenSshSecurityKeyPublicKey(createOpenSshPublicKeyFixture('ssh-ed25519'))).toBe(false)
|
||||
expect(
|
||||
isOpenSshSecurityKeyPublicKey(
|
||||
Buffer.from(`${ED25519_SECURITY_KEY} ${Buffer.from('ssh-ed25519').toString('base64')}`)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed and truncated OpenSSH envelopes without throwing', () => {
|
||||
const key = createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
const malformedLength = Buffer.concat([
|
||||
Buffer.from('openssh-key-v1\0', 'ascii'),
|
||||
Buffer.from([0xff, 0xff, 0xff, 0xff])
|
||||
]).toString('base64')
|
||||
const malformedKey = Buffer.from(
|
||||
`-----BEGIN OPENSSH PRIVATE KEY-----\n${malformedLength}\n-----END OPENSSH PRIVATE KEY-----\n`
|
||||
)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(key.subarray(0, -20))).toBe(false)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(malformedKey)).toBe(false)
|
||||
expect(isOpenSshSecurityKeyPrivateKey(Buffer.from('not a private key'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requiresSystemSshForSecurityKey', () => {
|
||||
it('uses default FIDO2 identities only when config resolution is unavailable', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget(), { identityFile: [] })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a regular default ahead of a dormant FIDO2 identity', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-regular-key-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(join(directory, '.ssh', 'id_rsa'), createOpenSshPrivateKeyFixture(['ssh-rsa']))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps password and agent fallback when default FIDO2 needs unavailable OpenSSH', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-no-system-ssh-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
findSystemSshMock.mockReturnValue(null)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('ignores an orphan regular sidecar before a valid default FIDO2 identity', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-orphan-sidecar-home-'))
|
||||
tempDirs.push(directory)
|
||||
await mkdir(join(directory, '.ssh'))
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519.pub'),
|
||||
createOpenSshPublicKeyFixture('ssh-ed25519')
|
||||
)
|
||||
await writeFile(
|
||||
join(directory, '.ssh', 'id_ed25519_sk'),
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY])
|
||||
)
|
||||
vi.stubEnv('ORCA_TEST_SSH_HOME', directory)
|
||||
|
||||
await expect(requiresSystemSshForSecurityKey(createTarget(), null)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('detects a manual target identity path with spaces', async () => {
|
||||
const keyPath = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]))
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: keyPath }), null)
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('checks every fresh resolved identity for config-backed targets', async () => {
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
|
||||
const securityKey = await writeKey(
|
||||
createOpenSshPrivateKeyFixture([ECDSA_SECURITY_KEY], { encrypted: true }),
|
||||
'security'
|
||||
)
|
||||
const target = createTarget({
|
||||
source: 'ssh-config',
|
||||
configHost: 'workbox',
|
||||
identityFile: '/stale/security-key'
|
||||
})
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey, securityKey] })
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it.each([ED25519_SECURITY_KEY, ECDSA_SECURITY_KEY])(
|
||||
'detects an agent-backed %s identity from its public sidecar',
|
||||
async (keyType) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-security-key-agent-'))
|
||||
tempDirs.push(directory)
|
||||
const identityPath = join(directory, 'agent-key')
|
||||
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(keyType))
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
|
||||
).resolves.toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores a stale FIDO2 sidecar beside a regular private identity', async () => {
|
||||
const identityPath = await writeKey(
|
||||
createOpenSshPrivateKeyFixture(['ssh-ed25519']),
|
||||
'regular-with-stale-sidecar'
|
||||
)
|
||||
await writeFile(`${identityPath}.pub`, createOpenSshPublicKeyFixture(ED25519_SECURITY_KEY))
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: identityPath }), null)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale imported identity paths when fresh config has regular keys', async () => {
|
||||
const staleKey = await writeKey(createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]), 'stale')
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'regular')
|
||||
const target = createTarget({
|
||||
source: 'ssh-config',
|
||||
configHost: 'workbox',
|
||||
identityFile: staleKey
|
||||
})
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(target, { identityFile: [regularKey] })
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a manual target identity authoritative over resolved defaults', async () => {
|
||||
const regularKey = await writeKey(createOpenSshPrivateKeyFixture(['ssh-ed25519']), 'manual')
|
||||
const securityKey = await writeKey(
|
||||
createOpenSshPrivateKeyFixture([ED25519_SECURITY_KEY]),
|
||||
'resolved'
|
||||
)
|
||||
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(
|
||||
createTarget({ source: 'manual', identityFile: regularKey }),
|
||||
{ identityFile: [securityKey] }
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('degrades to ssh2 when identity files are missing or malformed', async () => {
|
||||
const malformedKey = await writeKey(Buffer.from('not a key'), 'malformed')
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(createTarget({ identityFile: malformedKey }), null)
|
||||
).resolves.toBe(false)
|
||||
await expect(
|
||||
requiresSystemSshForSecurityKey(
|
||||
createTarget({ identityFile: join(tmpdir(), 'missing-security-key') }),
|
||||
null
|
||||
)
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
const OPENSSH_PRIVATE_KEY_HEADER = '-----BEGIN OPENSSH PRIVATE KEY-----'
|
||||
const OPENSSH_PRIVATE_KEY_FOOTER = '-----END OPENSSH PRIVATE KEY-----'
|
||||
const OPENSSH_KEY_MAGIC = Buffer.from('openssh-key-v1\0', 'ascii')
|
||||
export const MAX_SSH_IDENTITY_FILE_BYTES = 1024 * 1024
|
||||
const MAX_PUBLIC_KEYS = 64
|
||||
const SECURITY_KEY_TYPES = new Set([
|
||||
'sk-ssh-ed25519@openssh.com',
|
||||
'sk-ecdsa-sha2-nistp256@openssh.com'
|
||||
])
|
||||
|
||||
type SshString = { value: Buffer; nextOffset: number }
|
||||
|
||||
function decodeBase64(value: string): Buffer | null {
|
||||
if (!value || value.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
const unpadded = value.replace(/=+$/, '')
|
||||
const decoded = Buffer.from(unpadded, 'base64')
|
||||
return decoded.toString('base64').replace(/=+$/, '') === unpadded ? decoded : null
|
||||
}
|
||||
|
||||
function readSshString(buffer: Buffer, offset: number): SshString | null {
|
||||
if (offset < 0 || offset + 4 > buffer.length) {
|
||||
return null
|
||||
}
|
||||
const length = buffer.readUInt32BE(offset)
|
||||
const start = offset + 4
|
||||
const end = start + length
|
||||
if (end < start || end > buffer.length) {
|
||||
return null
|
||||
}
|
||||
return { value: buffer.subarray(start, end), nextOffset: end }
|
||||
}
|
||||
|
||||
function decodeOpenSshPrivateKey(contents: Buffer): Buffer | null {
|
||||
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const lines = contents.toString('ascii').trim().split(/\r?\n/)
|
||||
if (lines[0] !== OPENSSH_PRIVATE_KEY_HEADER || lines.at(-1) !== OPENSSH_PRIVATE_KEY_FOOTER) {
|
||||
return null
|
||||
}
|
||||
const encoded = lines.slice(1, -1).join('')
|
||||
const decoded = decodeBase64(encoded)
|
||||
if (!decoded) {
|
||||
return null
|
||||
}
|
||||
return decoded.subarray(0, OPENSSH_KEY_MAGIC.length).equals(OPENSSH_KEY_MAGIC) ? decoded : null
|
||||
}
|
||||
|
||||
export function isOpenSshSecurityKeyPrivateKey(contents: Buffer): boolean {
|
||||
const decoded = decodeOpenSshPrivateKey(contents)
|
||||
if (!decoded) {
|
||||
return false
|
||||
}
|
||||
|
||||
let offset = OPENSSH_KEY_MAGIC.length
|
||||
for (let field = 0; field < 3; field++) {
|
||||
const value = readSshString(decoded, offset)
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
offset = value.nextOffset
|
||||
}
|
||||
if (offset + 4 > decoded.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keyCount = decoded.readUInt32BE(offset)
|
||||
offset += 4
|
||||
if (keyCount === 0 || keyCount > MAX_PUBLIC_KEYS) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasSecurityKey = false
|
||||
for (let keyIndex = 0; keyIndex < keyCount; keyIndex++) {
|
||||
const publicKey = readSshString(decoded, offset)
|
||||
if (!publicKey) {
|
||||
return false
|
||||
}
|
||||
offset = publicKey.nextOffset
|
||||
const keyType = readSshString(publicKey.value, 0)
|
||||
if (!keyType) {
|
||||
return false
|
||||
}
|
||||
hasSecurityKey ||= SECURITY_KEY_TYPES.has(keyType.value.toString('ascii'))
|
||||
}
|
||||
|
||||
const privateBlock = readSshString(decoded, offset)
|
||||
return privateBlock !== null && hasSecurityKey
|
||||
}
|
||||
|
||||
export function isOpenSshSecurityKeyPublicKey(contents: Buffer): boolean {
|
||||
if (contents.length > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return false
|
||||
}
|
||||
const [declaredType, encoded] = contents.toString('ascii').trim().split(/\s+/, 3)
|
||||
if (!declaredType || !encoded || !SECURITY_KEY_TYPES.has(declaredType)) {
|
||||
return false
|
||||
}
|
||||
const decoded = decodeBase64(encoded)
|
||||
if (!decoded) {
|
||||
return false
|
||||
}
|
||||
const keyType = readSshString(decoded, 0)
|
||||
return keyType?.value.toString('ascii') === declaredType
|
||||
}
|
||||
@@ -24,7 +24,6 @@ vi.mock('child_process', () => ({
|
||||
|
||||
import {
|
||||
buildSshArgs,
|
||||
findSystemSsh,
|
||||
downloadFileViaSystemSsh,
|
||||
spawnSystemSsh,
|
||||
spawnSystemSshCommand,
|
||||
@@ -152,22 +151,6 @@ function createMockChildProcess(): EventEmitter & {
|
||||
return child
|
||||
}
|
||||
|
||||
describe('findSystemSsh', () => {
|
||||
beforeEach(() => {
|
||||
existsSyncMock.mockReset()
|
||||
})
|
||||
|
||||
it('returns the first existing ssh path', () => {
|
||||
mockSystemSshExists()
|
||||
expect(findSystemSsh()).toBe(SYSTEM_SSH_PATH)
|
||||
})
|
||||
|
||||
it('returns null when no ssh binary is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(findSystemSsh()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('spawnSystemSsh', () => {
|
||||
let mockProc: {
|
||||
stdin: {
|
||||
@@ -827,6 +810,7 @@ describe('spawnSystemSsh', () => {
|
||||
|
||||
it('throws when no system ssh is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
vi.stubEnv('PATH', '')
|
||||
expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { open, stat } from 'node:fs/promises'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { SshResolvedConfig } from './ssh-config-parser'
|
||||
import { listDefaultIdentityFilePaths, resolveIdentityFilePaths } from './ssh-auth-resolution'
|
||||
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
|
||||
import {
|
||||
MAX_SSH_IDENTITY_FILE_BYTES,
|
||||
isOpenSshSecurityKeyPrivateKey,
|
||||
isOpenSshSecurityKeyPublicKey
|
||||
} from './ssh-security-key-identity'
|
||||
import { isOpenSshConfigBackedTarget } from './system-ssh-args'
|
||||
import { findSystemSsh } from './system-ssh-binary'
|
||||
|
||||
type TransportResolvedConfig = Pick<
|
||||
SshResolvedConfig,
|
||||
'proxyUseFdpass' | 'proxyCommand' | 'proxyJump'
|
||||
>
|
||||
|
||||
const READ_CHUNK_BYTES = 64 * 1024
|
||||
const READ_OPEN_FLAGS =
|
||||
constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
|
||||
|
||||
type IdentityInspection = { privateIdentityExists: boolean; requiresSystemSsh: boolean }
|
||||
|
||||
async function readBoundedKeyFile(path: string): Promise<Buffer | null> {
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined
|
||||
try {
|
||||
const pathStats = await stat(path)
|
||||
if (!pathStats.isFile() || pathStats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
handle = await open(path, READ_OPEN_FLAGS)
|
||||
const stats = await handle.stat()
|
||||
if (!stats.isFile() || stats.size > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
let offset = 0
|
||||
while (offset <= MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
const buffer = Buffer.alloc(
|
||||
Math.min(READ_CHUNK_BYTES, MAX_SSH_IDENTITY_FILE_BYTES + 1 - offset)
|
||||
)
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
offset += bytesRead
|
||||
if (offset > MAX_SSH_IDENTITY_FILE_BYTES) {
|
||||
return null
|
||||
}
|
||||
chunks.push(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
return Buffer.concat(chunks, offset)
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectIdentityPath(keyPath: string): Promise<IdentityInspection> {
|
||||
const resolvedPath = resolveSshConfigHomePath(keyPath)
|
||||
const identity = await readBoundedKeyFile(resolvedPath)
|
||||
if (identity !== null) {
|
||||
return {
|
||||
privateIdentityExists: true,
|
||||
requiresSystemSsh:
|
||||
isOpenSshSecurityKeyPublicKey(identity) || isOpenSshSecurityKeyPrivateKey(identity)
|
||||
}
|
||||
}
|
||||
const publicIdentity = await readBoundedKeyFile(`${resolvedPath}.pub`)
|
||||
return {
|
||||
privateIdentityExists: false,
|
||||
requiresSystemSsh: publicIdentity !== null && isOpenSshSecurityKeyPublicKey(publicIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseSystemSshTransport(
|
||||
target: SshTarget,
|
||||
resolved: TransportResolvedConfig | null
|
||||
): boolean {
|
||||
if (isOpenSshConfigBackedTarget(target) && resolved) {
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
resolved.proxyUseFdpass === true ||
|
||||
resolved.proxyCommand != null ||
|
||||
resolved.proxyJump != null
|
||||
)
|
||||
}
|
||||
return (
|
||||
process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' ||
|
||||
target.proxyCommand != null ||
|
||||
target.jumpHost != null ||
|
||||
resolved?.proxyUseFdpass === true ||
|
||||
resolved?.proxyCommand != null ||
|
||||
resolved?.proxyJump != null
|
||||
)
|
||||
}
|
||||
|
||||
export async function requiresSystemSshForSecurityKey(
|
||||
target: SshTarget,
|
||||
resolved: Pick<SshResolvedConfig, 'identityFile'> | null
|
||||
): Promise<boolean> {
|
||||
const configuredPaths = resolveIdentityFilePaths(target, resolved)
|
||||
const usesDefaultPaths = configuredPaths.length === 0 && !resolved && !target.identityFile
|
||||
const identityPaths = usesDefaultPaths ? listDefaultIdentityFilePaths() : configuredPaths
|
||||
for (const keyPath of identityPaths) {
|
||||
const inspection = await inspectIdentityPath(keyPath)
|
||||
if (inspection.requiresSystemSsh) {
|
||||
return !usesDefaultPaths || findSystemSsh() !== null
|
||||
}
|
||||
if (usesDefaultPaths && inspection.privateIdentityExists) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { accessSyncMock, existsSyncMock, statSyncMock } = vi.hoisted(() => ({
|
||||
accessSyncMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
statSyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
accessSync: accessSyncMock,
|
||||
constants: { O_RDONLY: 0, O_NONBLOCK: 4, X_OK: 1 },
|
||||
existsSync: existsSyncMock,
|
||||
statSync: statSyncMock
|
||||
}))
|
||||
|
||||
import { findSystemSsh } from './system-ssh-binary'
|
||||
|
||||
describe('findSystemSsh', () => {
|
||||
beforeEach(() => {
|
||||
accessSyncMock.mockReset()
|
||||
existsSyncMock.mockReset()
|
||||
statSyncMock.mockReset()
|
||||
statSyncMock.mockImplementation(() => {
|
||||
throw new Error('missing')
|
||||
})
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns the first existing fixed ssh path', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
existsSyncMock.mockImplementation((path: string) => path === '/usr/bin/ssh')
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('/usr/bin/ssh')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when no ssh binary is found', () => {
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
expect(findSystemSsh()).toBeNull()
|
||||
})
|
||||
|
||||
it('finds a PATH-installed ssh.exe on Windows', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('PATH', 'C:\\Git\\usr\\bin;C:\\Tools')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
statSyncMock.mockImplementation((path: string) => {
|
||||
if (path === 'C:\\Git\\usr\\bin\\ssh.exe') {
|
||||
return { isFile: () => true }
|
||||
}
|
||||
throw new Error('missing')
|
||||
})
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('C:\\Git\\usr\\bin\\ssh.exe')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('finds an executable PATH-installed ssh on POSIX', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
vi.stubEnv('PATH', '/nix/store/ssh/bin:/opt/tools')
|
||||
existsSyncMock.mockReturnValue(false)
|
||||
statSyncMock.mockImplementation((path: string) => {
|
||||
if (path === '/nix/store/ssh/bin/ssh') {
|
||||
return { isFile: () => true }
|
||||
}
|
||||
throw new Error('missing')
|
||||
})
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('/nix/store/ssh/bin/ssh')
|
||||
expect(accessSyncMock).toHaveBeenCalledWith('/nix/store/ssh/bin/ssh', expect.any(Number))
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves Windows OpenSSH from the runtime system root', () => {
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
vi.stubEnv('SystemRoot', 'D:\\Windows')
|
||||
existsSyncMock.mockImplementation(
|
||||
(path: string) => path === 'D:\\Windows\\System32\\OpenSSH\\ssh.exe'
|
||||
)
|
||||
|
||||
try {
|
||||
expect(findSystemSsh()).toBe('D:\\Windows\\System32\\OpenSSH\\ssh.exe')
|
||||
} finally {
|
||||
platformSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an explicit system ssh override authoritative', () => {
|
||||
vi.stubEnv('ORCA_SYSTEM_SSH_PATH', 'C:\\Custom\\ssh.exe')
|
||||
|
||||
expect(findSystemSsh()).toBe('C:\\Custom\\ssh.exe')
|
||||
expect(existsSyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,41 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
const SYSTEM_SSH_PATHS =
|
||||
process.platform === 'win32'
|
||||
? ['C:\\Windows\\System32\\OpenSSH\\ssh.exe', 'ssh.exe']
|
||||
: ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
|
||||
function systemSshPaths(platform: NodeJS.Platform): string[] {
|
||||
if (platform !== 'win32') {
|
||||
return ['/usr/bin/ssh', '/usr/local/bin/ssh', '/opt/homebrew/bin/ssh']
|
||||
}
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR
|
||||
return systemRoot ? [win32.join(systemRoot, 'System32', 'OpenSSH', 'ssh.exe')] : []
|
||||
}
|
||||
|
||||
function findSshOnPath(platform: NodeJS.Platform): string | null {
|
||||
const pathValue = process.env.PATH
|
||||
if (!pathValue) {
|
||||
return null
|
||||
}
|
||||
const pathApi = platform === 'win32' ? win32 : posix
|
||||
const executable = platform === 'win32' ? 'ssh.exe' : 'ssh'
|
||||
for (const entry of pathValue.split(pathApi.delimiter)) {
|
||||
const directory = entry.trim().replace(/^"|"$/g, '')
|
||||
if (!directory) {
|
||||
continue
|
||||
}
|
||||
const candidate = pathApi.join(directory, executable)
|
||||
try {
|
||||
if (!statSync(candidate).isFile()) {
|
||||
continue
|
||||
}
|
||||
if (platform !== 'win32') {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
}
|
||||
return candidate
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the system ssh binary path. Returns null if not found.
|
||||
@@ -12,10 +44,10 @@ export function findSystemSsh(): string | null {
|
||||
if (process.env.ORCA_SYSTEM_SSH_PATH) {
|
||||
return process.env.ORCA_SYSTEM_SSH_PATH
|
||||
}
|
||||
for (const candidate of SYSTEM_SSH_PATHS) {
|
||||
for (const candidate of systemSshPaths(process.platform)) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
return findSshOnPath(process.platform)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ import {
|
||||
} from './createMainWindow'
|
||||
import { ipcMain } from 'electron'
|
||||
import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification'
|
||||
import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
const original = process.platform
|
||||
@@ -248,6 +250,7 @@ describe('createMainWindow', () => {
|
||||
expect(allowBlankPrefs).toMatchObject({
|
||||
disableHtmlFullscreenWindowResize: true,
|
||||
partition: 'persist:orca-browser',
|
||||
preload: expect.stringMatching(/browser-window-close-preload\.js$/),
|
||||
sandbox: true
|
||||
})
|
||||
|
||||
@@ -262,6 +265,36 @@ describe('createMainWindow', () => {
|
||||
const guest = { marker: 'guest' }
|
||||
windowHandlers['did-attach-webview']({} as never, guest as never)
|
||||
expect(attachGuestPoliciesMock).toHaveBeenCalledWith(guest)
|
||||
|
||||
const allowWindowCloseEvent = { preventDefault: vi.fn() }
|
||||
const allowWindowCloseParams = {
|
||||
src: 'data:text/html,',
|
||||
preload: BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD
|
||||
}
|
||||
const allowWindowClosePrefs = { partition: 'persist:orca-browser' }
|
||||
windowHandlers['will-attach-webview'](
|
||||
allowWindowCloseEvent as never,
|
||||
allowWindowClosePrefs as never,
|
||||
allowWindowCloseParams as never
|
||||
)
|
||||
expect(allowWindowCloseEvent.preventDefault).not.toHaveBeenCalled()
|
||||
expect(allowWindowCloseParams.preload).toBeUndefined()
|
||||
expect(allowWindowClosePrefs).not.toHaveProperty('preload')
|
||||
|
||||
const allowWindowClosePathPrefs = {
|
||||
partition: 'persist:orca-browser',
|
||||
preload: fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD)
|
||||
}
|
||||
windowHandlers['will-attach-webview'](
|
||||
{ preventDefault: vi.fn() } as never,
|
||||
allowWindowClosePathPrefs as never,
|
||||
{ src: 'data:text/html,', preload: '' } as never
|
||||
)
|
||||
expect(allowWindowClosePathPrefs).not.toHaveProperty('preload')
|
||||
|
||||
const cliGuest = { marker: 'cli-guest' }
|
||||
windowHandlers['did-attach-webview']({} as never, cliGuest as never)
|
||||
expect(attachGuestPoliciesMock).toHaveBeenLastCalledWith(cliGuest)
|
||||
})
|
||||
|
||||
it('sets platform-specific titlebar and frame options for every desktop platform', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
screen
|
||||
} from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import type { Store } from '../persistence'
|
||||
import { getAppIconPath } from '../app-icon'
|
||||
@@ -18,6 +19,7 @@ import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
import { translateMain } from '../i18n/main-i18n'
|
||||
import { normalizeBrowserNavigationUrl } from '../../shared/browser-url'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences'
|
||||
import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../shared/browser-window-close-policy'
|
||||
import { isCrashReportReason } from '../../shared/crash-reporting'
|
||||
import {
|
||||
DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES,
|
||||
@@ -441,6 +443,8 @@ export function createMainWindow(
|
||||
// so register it with the window's other navigation policy.
|
||||
registerPluginPanelNavigationGuard(mainWindow.webContents)
|
||||
|
||||
const browserWindowClosePreload = join(__dirname, 'browser-window-close-preload.js')
|
||||
const browserWindowCloseAllowedPreloadPath = fileURLToPath(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD)
|
||||
mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => {
|
||||
const src = typeof params.src === 'string' ? params.src : ''
|
||||
const normalizedSrc = normalizeBrowserNavigationUrl(src)
|
||||
@@ -452,7 +456,18 @@ export function createMainWindow(
|
||||
return
|
||||
}
|
||||
|
||||
delete webPreferences.preload
|
||||
const allowWindowClose = [params.preload, webPreferences.preload].some(
|
||||
(preload) =>
|
||||
preload === BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD ||
|
||||
preload === browserWindowCloseAllowedPreloadPath
|
||||
)
|
||||
delete params.preload
|
||||
if (allowWindowClose) {
|
||||
delete webPreferences.preload
|
||||
} else {
|
||||
// Why: preload runs in the page's main world before inline scripts can call window.close().
|
||||
webPreferences.preload = browserWindowClosePreload
|
||||
}
|
||||
// Why: older Electron builds expose preloadURL alongside preload; delete both so the guest can't inherit the main preload bridge.
|
||||
delete (webPreferences as Record<string, unknown>).preloadURL
|
||||
webPreferences.nodeIntegration = false
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function installBrowserWindowCloseGuard(): void {
|
||||
const ignoreWindowClose = (): void => {}
|
||||
try {
|
||||
Object.defineProperty(window, 'close', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: ignoreWindowClose
|
||||
})
|
||||
} catch {
|
||||
try {
|
||||
window.close = ignoreWindowClose
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installBrowserWindowCloseGuard } from './browser-window-close-installation'
|
||||
|
||||
describe('browser window close preload', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('installs a non-replaceable window.close no-op in the page world', () => {
|
||||
const nativeClose = vi.fn()
|
||||
vi.stubGlobal('window', { close: nativeClose })
|
||||
|
||||
installBrowserWindowCloseGuard()
|
||||
|
||||
expect(window.close()).toBeUndefined()
|
||||
expect(window.close).not.toBe(nativeClose)
|
||||
expect(Reflect.set(window, 'close', nativeClose)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { installBrowserWindowCloseGuard } from './browser-window-close-installation'
|
||||
import type { ContextBridge } from 'electron'
|
||||
|
||||
// Why: raw require keeps the sandboxed preload standalone in the main-process CJS build.
|
||||
const { contextBridge } = require('electron') as { contextBridge: ContextBridge }
|
||||
|
||||
contextBridge.executeInMainWorld({ func: installBrowserWindowCloseGuard })
|
||||
@@ -3693,6 +3693,7 @@ function BrowserPagePane({
|
||||
container,
|
||||
inputLocked: inputLockedRef.current,
|
||||
webviewPartition,
|
||||
allowWindowClose: browserTab.allowWindowClose === true,
|
||||
resolveContainer: () =>
|
||||
ensureBrowserPageViewport(browserTab.id, workspaceId)?.container ?? null
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
|
||||
import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy'
|
||||
|
||||
const registryMocks = vi.hoisted(() => ({
|
||||
destroyPersistentWebview: vi.fn(),
|
||||
@@ -103,4 +104,22 @@ describe('BrowserPane webview preferences', () => {
|
||||
expect(ensuredWebview?.webview.style.pointerEvents).toBe('none')
|
||||
expect(refreshedContainer.lastElementChild).toBe(ensuredWebview?.webview as unknown as Element)
|
||||
})
|
||||
|
||||
it('marks explicitly allowed CLI pages before the guest attaches', () => {
|
||||
const container = createContainer('cli-page')
|
||||
|
||||
const ensuredWebview = ensureBrowserPageWebview({
|
||||
browserTabId: 'browser-page-cli',
|
||||
container,
|
||||
inputLocked: false,
|
||||
webviewPartition: 'persist:orca-browser',
|
||||
allowWindowClose: true,
|
||||
resolveContainer: () => container
|
||||
})
|
||||
|
||||
expect(ensuredWebview?.webview.getAttribute('preload')).toBe(
|
||||
BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD
|
||||
)
|
||||
expect(BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD).toMatch(/^file:/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../shared/browser-guest-web-preferences'
|
||||
import { BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD } from '../../../../shared/browser-window-close-policy'
|
||||
import {
|
||||
destroyPersistentWebview,
|
||||
registerPersistentWebview,
|
||||
@@ -10,12 +11,14 @@ export function ensureBrowserPageWebview({
|
||||
container,
|
||||
inputLocked,
|
||||
webviewPartition,
|
||||
allowWindowClose,
|
||||
resolveContainer
|
||||
}: {
|
||||
browserTabId: string
|
||||
container: HTMLDivElement
|
||||
inputLocked: boolean
|
||||
webviewPartition: string
|
||||
allowWindowClose?: boolean
|
||||
resolveContainer: () => HTMLDivElement | null
|
||||
}): { container: HTMLDivElement; created: boolean; webview: Electron.WebviewTag } | null {
|
||||
let webview = webviewRegistry.get(browserTabId)
|
||||
@@ -46,6 +49,10 @@ export function ensureBrowserPageWebview({
|
||||
|
||||
webview = document.createElement('webview') as Electron.WebviewTag
|
||||
webview.setAttribute('partition', webviewPartition)
|
||||
if (allowWindowClose) {
|
||||
// Why: main consumes and removes this marker in will-attach-webview before the guest sees any preload.
|
||||
webview.setAttribute('preload', BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD)
|
||||
}
|
||||
webview.setAttribute('allowpopups', '')
|
||||
// Why: Electron spreads the webpreferences keys verbatim, so the shared
|
||||
// camelCase attribute must stay intact for fullscreen containment to work.
|
||||
|
||||
@@ -151,6 +151,50 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(manager.fitAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('latches viewport intent before refocus recovery flushes streaming output', async () => {
|
||||
const terminal = { name: 'streaming-terminal' }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([{ terminal }])
|
||||
const { syncTerminalScrollIntentFromViewport } = vi.mocked(
|
||||
await import('@/lib/pane-manager/terminal-scroll-intent')
|
||||
)
|
||||
const { flushTerminalOutput } = vi.mocked(
|
||||
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
|
||||
)
|
||||
|
||||
recoverVisibleTerminalWindowWake({
|
||||
manager: manager as never as PaneManager,
|
||||
isActive: true,
|
||||
clearGlyphAtlases: false
|
||||
})
|
||||
|
||||
expect(flushTerminalOutput).toHaveBeenCalledOnce()
|
||||
expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce()
|
||||
expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('does not overwrite pre-reveal intent after queuing hidden output', async () => {
|
||||
const terminal = { name: 'hidden-streaming-terminal' }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([{ terminal }])
|
||||
const { syncTerminalScrollIntentFromViewport } = vi.mocked(
|
||||
await import('@/lib/pane-manager/terminal-scroll-intent')
|
||||
)
|
||||
const { flushTerminalOutput } = vi.mocked(
|
||||
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
|
||||
)
|
||||
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(flushTerminalOutput).toHaveBeenCalledOnce()
|
||||
expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledOnce()
|
||||
expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
flushTerminalOutput.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
|
||||
)
|
||||
})
|
||||
|
||||
it('resets each pane linkifier hover cache on window wake recovery so links recover without a scroll', () => {
|
||||
const first = { name: 'pane-a' }
|
||||
const second = { name: 'pane-b' }
|
||||
|
||||
@@ -143,6 +143,8 @@ export function recoverVisibleTerminalWindowWake({
|
||||
}: RecoverVisibleTerminalWindowWakeArgs): void {
|
||||
// Why: macOS screensaver/display wake can leave xterm visible but with a
|
||||
// stale renderer/input surface; Orca's own hidden-state resume never runs.
|
||||
// Why: backlog writes can expose transient viewport geometry while parsing.
|
||||
syncTerminalViewportIntents(manager)
|
||||
for (const pane of manager.getPanes()) {
|
||||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
|
||||
@@ -154,7 +156,6 @@ export function recoverVisibleTerminalWindowWake({
|
||||
resetTerminalLinkifierHoverState(pane.terminal)
|
||||
}
|
||||
}
|
||||
syncTerminalViewportIntents(manager)
|
||||
manager.resumeRendering()
|
||||
// Why: wake re-attaches WebGL — same transient cell-metric wobble guard as the heavy resume.
|
||||
manager.fitAllRevealedPanes()
|
||||
@@ -192,7 +193,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
|
||||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS })
|
||||
}
|
||||
syncTerminalViewportIntents(manager)
|
||||
// Intent was latched by the caller before queued writes can expose transient geometry.
|
||||
// Resume WebGL immediately so the terminal shows its last-known state
|
||||
// on the first painted frame. macOS context creation is ~5 ms; on
|
||||
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume
|
||||
|
||||
@@ -1077,6 +1077,11 @@ describe('useIpcEvents browser tab create routing', () => {
|
||||
|
||||
expect(acquireBrowserAutomationVisibility).toHaveBeenCalledWith('page-new')
|
||||
expect(acquireBrowserAutomationVisibility).not.toHaveBeenCalledWith('page-active')
|
||||
expect(state.createBrowserTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'https://example.com',
|
||||
expect.objectContaining({ allowWindowClose: true })
|
||||
)
|
||||
expect(replyTabCreate).toHaveBeenCalledWith({
|
||||
requestId: 'req-create',
|
||||
browserPageId: 'page-new'
|
||||
|
||||
@@ -2266,6 +2266,7 @@ export function useIpcEvents(): void {
|
||||
// Agent/automation opens stay in the background (activate:false) in the active browser group.
|
||||
const workspace = store.createBrowserTab(worktreeId, data.url, {
|
||||
title: data.url,
|
||||
allowWindowClose: true,
|
||||
targetGroupId: data.activate ? undefined : activeBrowserUnifiedTab?.groupId,
|
||||
sessionProfileId: data.sessionProfileId,
|
||||
sessionPartition: data.sessionPartition,
|
||||
|
||||
@@ -57,6 +57,7 @@ import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktr
|
||||
type CreateBrowserTabOptions = {
|
||||
activate?: boolean
|
||||
title?: string
|
||||
allowWindowClose?: boolean
|
||||
sessionProfileId?: string | null
|
||||
sessionPartition?: string | null
|
||||
// Place the new tab in a specific group (e.g. "Open Preview to the Side"); defaults to the worktree's active group.
|
||||
@@ -69,6 +70,7 @@ type CreateBrowserTabOptions = {
|
||||
type CreateBrowserPageOptions = {
|
||||
activate?: boolean
|
||||
title?: string
|
||||
allowWindowClose?: boolean
|
||||
browserRuntimeEnvironmentId?: string | null
|
||||
}
|
||||
|
||||
@@ -341,7 +343,8 @@ function buildBrowserPage(
|
||||
worktreeId: string,
|
||||
url: string,
|
||||
title?: string,
|
||||
browserRuntimeEnvironmentId?: string | null
|
||||
browserRuntimeEnvironmentId?: string | null,
|
||||
allowWindowClose?: boolean
|
||||
): BrowserPage {
|
||||
const normalizedUrl = normalizeUrl(url)
|
||||
return {
|
||||
@@ -357,6 +360,7 @@ function buildBrowserPage(
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: Date.now(),
|
||||
...(allowWindowClose !== undefined ? { allowWindowClose } : {}),
|
||||
...(browserRuntimeEnvironmentId !== undefined ? { browserRuntimeEnvironmentId } : {})
|
||||
}
|
||||
}
|
||||
@@ -554,7 +558,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
worktreeId,
|
||||
url,
|
||||
options?.title,
|
||||
options?.browserRuntimeEnvironmentId
|
||||
options?.browserRuntimeEnvironmentId,
|
||||
options?.allowWindowClose
|
||||
)
|
||||
// Why: with no explicit profile, inherit the user's default so a Settings preference applies to new tabs.
|
||||
const sessionProfileId =
|
||||
@@ -902,13 +907,15 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
activate: true,
|
||||
sessionProfileId,
|
||||
sessionPartition,
|
||||
browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId
|
||||
browserRuntimeEnvironmentId: firstPage.browserRuntimeEnvironmentId,
|
||||
allowWindowClose: firstPage.allowWindowClose
|
||||
})
|
||||
|
||||
for (const p of restPages) {
|
||||
get().createBrowserPage(restored.id, p.url, {
|
||||
activate: false,
|
||||
title: p.title,
|
||||
allowWindowClose: p.allowWindowClose,
|
||||
browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId
|
||||
})
|
||||
}
|
||||
@@ -987,7 +994,8 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
workspace.worktreeId,
|
||||
url,
|
||||
options?.title,
|
||||
options?.browserRuntimeEnvironmentId
|
||||
options?.browserRuntimeEnvironmentId,
|
||||
options?.allowWindowClose
|
||||
)
|
||||
|
||||
set((s) => {
|
||||
@@ -1169,6 +1177,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
return get().createBrowserPage(workspaceId, pageToRestore.url, {
|
||||
title: pageToRestore.title,
|
||||
activate: true,
|
||||
allowWindowClose: pageToRestore.allowWindowClose,
|
||||
browserRuntimeEnvironmentId: pageToRestore.browserRuntimeEnvironmentId
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Why: this marker carries the CLI's explicit close policy across <webview> attach before the guest exists.
|
||||
export const BROWSER_WINDOW_CLOSE_ALLOWED_PRELOAD = 'file:///__orca_window_close_allowed__'
|
||||
@@ -982,6 +982,8 @@ export type BrowserPage = {
|
||||
canGoForward: boolean
|
||||
loadError: BrowserLoadError | null
|
||||
createdAt: number
|
||||
// Why: CLI-created pages retain Chromium's native window.close behavior; ordinary embedded pages are guarded.
|
||||
allowWindowClose?: boolean
|
||||
// Why: remote-owned worktrees can still host client-local fallback browser
|
||||
// pages until headless remote runtimes support real browser panes.
|
||||
browserRuntimeEnvironmentId?: string | null
|
||||
|
||||
@@ -225,6 +225,7 @@ const browserPageSchema = z.object({
|
||||
canGoForward: z.boolean(),
|
||||
loadError: browserLoadErrorSchema.nullable(),
|
||||
createdAt: z.number(),
|
||||
allowWindowClose: z.boolean().optional(),
|
||||
// Why: explicit null marks a browser page as client-local even when its
|
||||
// worktree is remote-owned; older sessions omit it and keep inferred runtime.
|
||||
browserRuntimeEnvironmentId: z.string().nullable().optional(),
|
||||
|
||||
@@ -29,10 +29,11 @@ async function createBrowserTab(
|
||||
page: Parameters<typeof getActiveWorktreeId>[0],
|
||||
worktreeId: string,
|
||||
url?: string,
|
||||
title = 'New Browser Tab'
|
||||
title = 'New Browser Tab',
|
||||
allowWindowClose?: boolean
|
||||
): Promise<CreatedBrowserTab | null> {
|
||||
return page.evaluate(
|
||||
({ targetWorktreeId, targetUrl, targetTitle }) => {
|
||||
({ targetWorktreeId, targetUrl, targetTitle, allowClose }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return null
|
||||
@@ -44,12 +45,18 @@ async function createBrowserTab(
|
||||
targetUrl ?? state.browserDefaultUrl ?? 'about:blank',
|
||||
{
|
||||
title: targetTitle,
|
||||
activate: true
|
||||
activate: true,
|
||||
allowWindowClose: allowClose
|
||||
}
|
||||
)
|
||||
return { id: tab.id, pageId: tab.activePageId ?? null }
|
||||
},
|
||||
{ targetWorktreeId: worktreeId, targetUrl: url, targetTitle: title }
|
||||
{
|
||||
targetWorktreeId: worktreeId,
|
||||
targetUrl: url,
|
||||
targetTitle: title,
|
||||
allowClose: allowWindowClose
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -199,6 +206,71 @@ async function startBrowserLinkServer(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
async function startBrowserWindowCloseServer(): Promise<{
|
||||
url: string
|
||||
sourceUrl: string
|
||||
close: () => Promise<void>
|
||||
}> {
|
||||
const server = createServer((request, response) => {
|
||||
const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
const pathname = new URL(request.url ?? '/', origin).pathname
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||
if (pathname === '/source') {
|
||||
response.end(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>Close link source</title></head>
|
||||
<body><a id="window-close-link" href="${origin}/window-close" target="_blank">Open close page</a></body>
|
||||
</html>
|
||||
`)
|
||||
return
|
||||
}
|
||||
response.end(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><title>Window close repro</title></head>
|
||||
<body>
|
||||
<p id="s">Attempting close…</p>
|
||||
<script>
|
||||
window.close()
|
||||
setTimeout(() => {
|
||||
document.getElementById('s').textContent =
|
||||
'window.close() was blocked (expected).'
|
||||
}, 200)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/window-close`,
|
||||
sourceUrl: `http://127.0.0.1:${port}/source`,
|
||||
close: () => closeServer(server)
|
||||
}
|
||||
}
|
||||
|
||||
async function readBrowserWindowCloseStatus(
|
||||
page: Parameters<typeof getActiveWorktreeId>[0],
|
||||
browserTabId: string
|
||||
): Promise<string> {
|
||||
return page.evaluate(async (targetBrowserTabId) => {
|
||||
const slot = document.querySelector(`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`)
|
||||
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
|
||||
if (!webview) {
|
||||
return 'webview missing'
|
||||
}
|
||||
try {
|
||||
return (await webview.executeJavaScript(
|
||||
'document.querySelector("#s")?.textContent ?? "status missing"'
|
||||
)) as string
|
||||
} catch {
|
||||
return 'guest lost'
|
||||
}
|
||||
}, browserTabId)
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
@@ -689,6 +761,88 @@ test.describe('Browser Tab', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('blocked window.close in a link-created tab does not break tab switching', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const closeServer = await startBrowserWindowCloseServer()
|
||||
try {
|
||||
const worktreeId = (await getActiveWorktreeId(orcaPage))!
|
||||
const neighboringTab = await createBrowserTab(
|
||||
orcaPage,
|
||||
worktreeId,
|
||||
'about:blank',
|
||||
'Neighboring tab'
|
||||
)
|
||||
const sourceTab = await createBrowserTab(
|
||||
orcaPage,
|
||||
worktreeId,
|
||||
closeServer.sourceUrl,
|
||||
'Close link source'
|
||||
)
|
||||
expect(neighboringTab?.id).toBeTruthy()
|
||||
expect(sourceTab?.id).toBeTruthy()
|
||||
|
||||
await clickBrowserLink(orcaPage, sourceTab!.id, '#window-close-link')
|
||||
let closeTabId: string | null = null
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tabs = await getBrowserTabs(orcaPage, worktreeId)
|
||||
closeTabId = tabs.find((tab) => tab.url === closeServer.url)?.id ?? null
|
||||
return closeTabId
|
||||
})
|
||||
.not.toBeNull()
|
||||
|
||||
await orcaPage.locator(`[data-tab-id="${neighboringTab!.id}"]`).click()
|
||||
await expect.poll(async () => getActiveTabType(orcaPage), { timeout: 5_000 }).toBe('browser')
|
||||
await expect
|
||||
.poll(() => readBrowserWindowCloseStatus(orcaPage, closeTabId!), { timeout: 5_000 })
|
||||
.toContain('window.close() was blocked')
|
||||
} finally {
|
||||
await closeServer.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('explicitly allowed browser tabs retain replaceable window.close', async ({ orcaPage }) => {
|
||||
const closeServer = await startBrowserWindowCloseServer()
|
||||
try {
|
||||
const worktreeId = (await getActiveWorktreeId(orcaPage))!
|
||||
const allowedTab = await createBrowserTab(
|
||||
orcaPage,
|
||||
worktreeId,
|
||||
closeServer.sourceUrl,
|
||||
'Allowed close tab',
|
||||
true
|
||||
)
|
||||
expect(allowedTab?.id).toBeTruthy()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(async (targetBrowserTabId) => {
|
||||
const slot = document.querySelector(
|
||||
`[data-browser-overlay-tab-id="${targetBrowserTabId}"]`
|
||||
)
|
||||
const webview = slot?.querySelector('webview') as Electron.WebviewTag | null
|
||||
if (!webview) {
|
||||
return 'webview missing'
|
||||
}
|
||||
try {
|
||||
return (await webview.executeJavaScript(`(() => {
|
||||
window.close = () => 'replacement-called'
|
||||
return window.close() === 'replacement-called' ? 'replacement-called' : 'blocked'
|
||||
})()`)) as string
|
||||
} catch {
|
||||
return 'guest unavailable'
|
||||
}
|
||||
}, allowedTab!.id),
|
||||
{ timeout: 5_000 }
|
||||
)
|
||||
.toBe('replacement-called')
|
||||
} finally {
|
||||
await closeServer.close()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* User Prompt:
|
||||
* - Browser works and also retains state when switching tabs etc.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
execInTerminal,
|
||||
getTerminalContent,
|
||||
waitForActivePaneHookDescriptor,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection'
|
||||
import { nodeTerminalCommand } from './terminal-node-command'
|
||||
|
||||
const STREAMING_FIXTURE_PATH = path.join(
|
||||
process.cwd(),
|
||||
'tests/e2e/fixtures/streaming-scrollback-fixture.cjs'
|
||||
)
|
||||
|
||||
type RevealFrame = {
|
||||
targetPresented: boolean
|
||||
thumbTop: number | null
|
||||
maxThumbTop: number | null
|
||||
}
|
||||
|
||||
async function closeFeatureTips(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation'])
|
||||
if (store?.getState().activeModal === 'feature-tips') {
|
||||
store.getState().closeModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForPhaseOneAtBottom(page: Page, tabId: string): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((targetTabId) => {
|
||||
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
|
||||
const terminal = pane?.terminal
|
||||
if (!terminal) {
|
||||
return false
|
||||
}
|
||||
const buffer = terminal.buffer.active
|
||||
let containsMarker = false
|
||||
for (let line = buffer.baseY; line < buffer.baseY + terminal.rows; line += 1) {
|
||||
if (buffer.getLine(line)?.translateToString(true).includes('STREAM_PHASE1_DONE')) {
|
||||
containsMarker = true
|
||||
break
|
||||
}
|
||||
}
|
||||
const scrollbar = pane.container.querySelector<HTMLElement>(
|
||||
'.xterm-scrollbar.xterm-vertical'
|
||||
)
|
||||
const thumb = scrollbar?.querySelector<HTMLElement>('.xterm-slider') ?? null
|
||||
return Boolean(
|
||||
buffer.baseY > 0 &&
|
||||
buffer.viewportY === buffer.baseY &&
|
||||
containsMarker &&
|
||||
scrollbar &&
|
||||
thumb &&
|
||||
scrollbar.clientHeight - thumb.offsetHeight > 1 &&
|
||||
Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2
|
||||
)
|
||||
}, tabId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: 'phase-one output did not render with the visible viewport at the bottom'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function injectQueuedWriteAndRefocus(
|
||||
page: Page,
|
||||
tabId: string,
|
||||
paneKey: string
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ targetTabId, paneKey }) => {
|
||||
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
|
||||
if (!pane) {
|
||||
throw new Error('Hidden terminal pane unavailable')
|
||||
}
|
||||
const terminal = pane.terminal
|
||||
// Why: fail loudly if xterm moves the private buffer path that models this wobble.
|
||||
const bufferService = (
|
||||
terminal as typeof terminal & {
|
||||
_core?: {
|
||||
_bufferService?: { buffer?: { ydisp: number }; isUserScrolling: boolean }
|
||||
}
|
||||
}
|
||||
)._core?._bufferService
|
||||
const internalBuffer = bufferService?.buffer
|
||||
if (!internalBuffer || !bufferService) {
|
||||
throw new Error('xterm internal buffer unavailable')
|
||||
}
|
||||
const originalWrite = terminal.write
|
||||
let wobbleApplied = false
|
||||
terminal.write = ((data: string, callback?: () => void) => {
|
||||
terminal.write = originalWrite
|
||||
wobbleApplied = true
|
||||
internalBuffer.ydisp = 0
|
||||
bufferService.isUserScrolling = true
|
||||
if (terminal.buffer.active.viewportY !== 0) {
|
||||
throw new Error('xterm viewport wobble was not observable')
|
||||
}
|
||||
originalWrite.call(terminal, data, callback)
|
||||
}) as typeof terminal.write
|
||||
const injector = (
|
||||
window as Window & {
|
||||
__terminalPtyDataInjection?: {
|
||||
inject: (paneKey: string, data: string) => boolean
|
||||
}
|
||||
}
|
||||
).__terminalPtyDataInjection
|
||||
const rows = Array.from(
|
||||
{ length: 400 },
|
||||
(_, index) => `REFOCUS_STREAM_ROW_${String(index).padStart(4, '0')}_${'x'.repeat(80)}\n`
|
||||
).join('')
|
||||
if (!injector?.inject(paneKey, `${rows}REFOCUS_STREAM_DONE\n`)) {
|
||||
throw new Error('PTY data injector unavailable')
|
||||
}
|
||||
// Why: focus recovery must flush through terminal.write in this synchronous dispatch.
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
if (!wobbleApplied) {
|
||||
throw new Error('refocus did not flush the queued xterm write')
|
||||
}
|
||||
},
|
||||
{ targetTabId: tabId, paneKey }
|
||||
)
|
||||
}
|
||||
|
||||
async function sampleRevealFrames(page: Page, targetTabId: string): Promise<RevealFrame[]> {
|
||||
return page.evaluate(
|
||||
(targetTabId) =>
|
||||
new Promise<RevealFrame[]>((resolve) => {
|
||||
const frames: RevealFrame[] = []
|
||||
const startedAt = performance.now()
|
||||
const isPresented = (element: Element | null): boolean => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
for (
|
||||
let current: HTMLElement | null = element;
|
||||
current;
|
||||
current = current.parentElement
|
||||
) {
|
||||
const style = getComputedStyle(current)
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
style.opacity === '0'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const rect = element.getBoundingClientRect()
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.right > 0 &&
|
||||
rect.bottom > 0 &&
|
||||
rect.left < window.innerWidth &&
|
||||
rect.top < window.innerHeight
|
||||
)
|
||||
}
|
||||
const sample = (): void => {
|
||||
const pane = window.__paneManagers?.get(targetTabId)?.getPanes?.()[0]
|
||||
const targetXterm = pane?.container.querySelector('.xterm') ?? null
|
||||
const scrollbar =
|
||||
targetXterm?.querySelector<HTMLElement>('.xterm-scrollbar.xterm-vertical') ?? null
|
||||
const thumb = scrollbar?.querySelector<HTMLElement>('.xterm-slider') ?? null
|
||||
frames.push({
|
||||
targetPresented: isPresented(targetXterm),
|
||||
thumbTop: thumb?.offsetTop ?? null,
|
||||
maxThumbTop: scrollbar && thumb ? scrollbar.clientHeight - thumb.offsetHeight : null
|
||||
})
|
||||
if (performance.now() - startedAt >= 700) {
|
||||
resolve(frames)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
sample()
|
||||
}),
|
||||
targetTabId
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('terminal streaming refocus viewport', () => {
|
||||
test('keeps follow-output at the bottom through a queued-write refocus wobble', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await closeFeatureTips(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
const tabId = paneKey.slice(0, paneKey.indexOf(':'))
|
||||
await waitForTerminalPtyDataInjector(orcaPage, paneKey)
|
||||
await execInTerminal(orcaPage, ptyId, nodeTerminalCommand([STREAMING_FIXTURE_PATH]))
|
||||
await waitForPhaseOneAtBottom(orcaPage, tabId)
|
||||
|
||||
const framesPromise = sampleRevealFrames(orcaPage, tabId)
|
||||
await injectQueuedWriteAndRefocus(orcaPage, tabId, paneKey)
|
||||
const frames = await framesPromise
|
||||
|
||||
expect(frames.filter((frame) => !frame.targetPresented)).toEqual([])
|
||||
expect(
|
||||
frames.filter(
|
||||
(frame) =>
|
||||
frame.thumbTop === null ||
|
||||
frame.maxThumbTop === null ||
|
||||
Math.abs(frame.maxThumbTop - frame.thumbTop) > 2
|
||||
)
|
||||
).toEqual([])
|
||||
expect(frames.some((frame) => (frame.maxThumbTop ?? 0) > 1)).toBe(true)
|
||||
expect(
|
||||
frames.filter((frame) => (frame.maxThumbTop ?? 0) > 1 && (frame.thumbTop ?? 0) <= 1)
|
||||
).toEqual([])
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage), { timeout: 15_000 })
|
||||
.toContain('REFOCUS_STREAM_DONE')
|
||||
const visibleScrollbar = orcaPage.locator('.xterm-scrollbar.xterm-vertical:visible').first()
|
||||
await expect(visibleScrollbar).toBeVisible()
|
||||
expect(
|
||||
await visibleScrollbar.evaluate((scrollbar) => {
|
||||
const thumb = scrollbar.querySelector<HTMLElement>('.xterm-slider')
|
||||
return Boolean(
|
||||
thumb && Math.abs(scrollbar.clientHeight - thumb.offsetHeight - thumb.offsetTop) <= 2
|
||||
)
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user