Merge remote-tracking branch 'origin/main' into remote-orca-servers-load-issue

# Conflicts:
#	config/reliability-gates.jsonc
#	tests/e2e/helpers/paired-terminal-parking-oracle.ts
#	tests/e2e/paired-remote-terminal-retention-memory.spec.ts
This commit is contained in:
OrcaWin
2026-07-29 23:58:13 -07:00
375 changed files with 22838 additions and 3634 deletions
+2
View File
@@ -9,6 +9,7 @@ on:
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
@@ -84,6 +85,7 @@ jobs:
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
src/main/computer/computer-provider-lifecycle.test.ts
+1
View File
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+1
View File
@@ -153,6 +153,7 @@ module.exports = {
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
+96
View File
@@ -705,6 +705,102 @@
],
"demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit."
},
{
"id": "runtime.websocket-heartbeat-cadence",
"title": "Runtime WebSockets enter an owned shared heartbeat cadence",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "websocket-transport-lifecycle",
"surfaces": [
"headed paired runtime",
"headless orca serve",
"web and mobile runtime clients"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Deterministic transport tests cover listener-before-probe ownership, first- and later-socket shared cadence, responsive and unresponsive cleanup, pause/resume, connection caps, pre-auth expiry, and close/error races. Headed paired-runtime and headless serve validation passed on macOS; live Linux and Windows heartbeat evidence remains uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11298",
"https://github.com/stablyai/orca/pull/11300"
],
"invariant": "Every accepted runtime socket installs message, pong, close, and error ownership before any heartbeat probe. With uninterrupted timer delivery, the first socket that arms an idle heartbeat is probed immediately and an unresponsive socket is reaped within one interval. Later sockets join the existing shared cadence without another timer or immediate sweep and are reaped within two intervals. Responsive sockets survive, pause recovery grants a fresh probe, and close or error-to-close releases connection listeners and timers.",
"oracle": "With one fake clock and exact socket identities, accept the first socket at 0 ms and require an immediate owned probe plus reaping at 100 ms when unresponsive. Keep a responsive first socket, accept an unresponsive later socket at 50 ms, require the same shared timer, its first probe at 100 ms, no early reap, and termination at 200 ms. Inject synchronous message, pong, close, and error events, then require exact heartbeat membership and zero retained timers/listeners after final close. Production transport tests independently cover real socket round trips, pre-auth and capacity bounds, revocation, shutdown, and half-open cleanup.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"src/main/runtime/rpc/ws-transport.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/ws-transport-accept-order.test.ts",
"assertions": [
"the first synchronous probe observes message, pong, close, and error ownership",
"the first unresponsive socket is reaped at one interval",
"a later socket keeps the original shared timer, is first probed on the shared tick, and is reaped within two intervals",
"final close releases heartbeat membership, listeners, and timers"
]
},
{
"file": "src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts",
"assertions": [
"one missed probe reaps only the unresponsive client",
"event-loop resume grants clients a fresh probe"
]
},
{
"file": "src/main/runtime/rpc/ws-transport.test.ts",
"assertions": [
"heartbeat arming and shutdown follow accepted connection membership",
"pre-auth, raw TCP, and accepted WebSocket resource bounds remain enforced",
"error and close races finalize membership once"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-29",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/ws-transport-accept-order.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/ws-transport.test.ts",
"result": "passed",
"durationSeconds": 0.91,
"summary": "Three runtime transport files and 35 tests passed with deterministic first- and later-socket cadence, exact listener/timer ownership, pause recovery, security bounds, and cleanup."
}
],
"runtimeBudget": {
"p95Seconds": 2,
"scope": "focused runtime WebSocket heartbeat and transport tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Deterministic fake-clock and local socket coverage passed on macOS; CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On latest main and with the listener-order fix disabled, the first synchronous probe observed no message, pong, close, or error owner. The structural listener-order fix passed those assertions. The published delayed-first-sweep alternative missed the first-socket one-interval cleanup bound. A later-socket oracle now separately pins the intended shared cadence at a 100 ms first probe and 200 ms reap after acceptance at 50 ms."
},
"performanceBudget": {
"required": true,
"evidence": "The transport retains exactly one shared interval, performs one O(N) sweep per tick, and adds no per-socket heartbeat timer, polling, subprocess, network request, or immediate later-socket sweep. Each accepted socket retains only its existing lifecycle listeners and pre-auth timer; deterministic assertions preserve the shared timer identity and bound final cleanup to zero timers."
},
"promotionCriteria": [
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
"Collect live Linux and Windows paired-runtime heartbeat evidence.",
"Add a multi-client live fanout run that confirms shared-cadence cleanup under connection churn."
],
"knownGaps": [
"Live paired-runtime validation currently covers macOS only.",
"The later-socket bound is deterministic fake-clock coverage; live scheduler jitter is not measured.",
"Multi-client churn and sleep/wake fanout remain live-test gaps."
],
"demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers."
},
{
"id": "editor.live-log-append-stability",
"title": "Long live session logs retain their Monaco viewport while appending",
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
'config/scripts/computer-use-smoke.test.mjs',
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.test.mjs',
'src/main/computer/computer-provider-lifecycle.test.ts',
@@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
function source(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
}
describe('computer-use modifier safety', () => {
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
const mouseInput = sourceBetween(
macOS,
'private static func mouse(',
'private static func keyEvent('
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
expect(clickInput).not.toContain('down: true')
})
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
const windows = source('native/computer-use-windows/runtime.ps1')
const modifiedClick = sourceBetween(
windows,
'public static void SendModifiedClick',
'private static INPUT KeyboardInput'
)
const mouseClick = sourceBetween(
windows,
'function Send-OrcaMouseClick',
'function Send-OrcaDrag'
)
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
expect(modifiedClick).not.toContain('int count')
expect(mouseClick).toMatch(
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
)
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
expect(windows).not.toContain('keybd_event')
})
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
const linux = source('native/computer-use-linux/runtime.py')
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
expect(modifiedClick).toContain('is_wayland')
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
expect(modifiedClick).toContain('finally:')
expect(modifiedClick).toContain('check=False')
expect(modifiedClick).toContain('timeout=5')
expect(modifiedClick).toContain('timeout=2')
})
})
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
const projectDir = resolve(import.meta.dirname, '../..')
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
describe('computer-use skill guidance', () => {
it('keeps web-app targeting on the computer-use surface', () => {
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
it('requires atomic modifier-click actions in the source and bundled guide', () => {
expect(bundledGuide).toBeDefined()
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
expect(skill).toContain('click --modifiers <chord>')
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
}
})
})
describe('computer-use install stub', () => {
@@ -166,9 +166,11 @@ describe('electron-builder config', () => {
)
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
)
})
+3
View File
@@ -9,7 +9,9 @@
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -68,6 +70,7 @@
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+3 -2
View File
@@ -208,8 +208,9 @@ export const electronViteConfig: UserConfig = {
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
// without paying for another Electron process.
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
+18 -25
View File
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from '../../../src/transport/host-endpoint'
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import type { HostProfile } from '../../../src/transport/types'
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
void load()
}, [load])
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
const normalizedEndpoint = useMemo(
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
[address, fallbackPort, fallbackScheme]
const endpointEdit = useMemo(
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
[address, host]
)
const nameTrimmed = name.trim()
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
const endpointChanged =
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
const endpointChanged = endpointEdit?.kind === 'changed'
const canSave =
host != null &&
endpointEdit != null &&
nameTrimmed.length > 0 &&
normalizedEndpoint.ok &&
endpointEdit.kind !== 'invalid' &&
(nameChanged || endpointChanged) &&
!saving
async function handleSave() {
if (!host || !hostId || savingRef.current) {
if (!host || !hostId || !endpointEdit || savingRef.current) {
return
}
const nextName = name.trim()
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
setSaveError('Enter a name.')
return
}
if (!normalizedEndpoint.ok) {
setSaveError(normalizedEndpoint.error)
if (endpointEdit.kind === 'invalid') {
setSaveError(endpointEdit.error)
return
}
const willRename = nextName !== host.name
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
if (!willRename && !willUpdateEndpoint) {
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
if (!willRename && nextEndpoint === undefined) {
router.back()
return
}
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
// other, and a host removed mid-edit throws instead of no-oping.
await updateHostNameAndEndpoint(host.id, {
...(willRename ? { name: nextName } : {}),
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
setSaving(false)
router.back()
if (willUpdateEndpoint) {
if (nextEndpoint !== undefined) {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
(or 6768).
</Text>
{normalizedEndpoint.ok ? (
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
<Text style={styles.preview} numberOfLines={2}>
Connects to {normalizedEndpoint.endpoint}
Connects to {endpointEdit.endpoint}
</Text>
) : address.trim().length > 0 ? (
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
<Text style={styles.previewError}>{endpointEdit.error}</Text>
) : null}
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
+3 -1
View File
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
import {
createMockTerminals,
FAKE_SCROLLBACK,
@@ -124,7 +125,8 @@ export function handleRequest(
handleMockGitRequest(request, respond, success) ||
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws)
handleMockNativeChatRequest(request, respond, success, error, ws) ||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
) {
return
}
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
// must stay stable for the process and change on restart like a real publisher —
// hence a uuid, not a clock read two restarts could land on.
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
const GROUP_ID = 'group-1'
const PARENT_TAB_ID = 'tab-1'
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
* pane-attribution bugs aren't shape-incomplete. */
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
return {
worktree: worktreeId,
publicationEpoch: PUBLICATION_EPOCH,
snapshotVersion: 1,
activeGroupId: GROUP_ID,
activeTabId: SURFACE_TAB_ID,
activeTabType: 'terminal',
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
tabGroups: [
{
id: GROUP_ID,
activeTabId: PARENT_TAB_ID,
tabOrder: [PARENT_TAB_ID],
recentTabIds: [PARENT_TAB_ID]
}
],
tabs: [
{
type: 'terminal',
id: SURFACE_TAB_ID,
title: 'zsh',
parentTabId: PARENT_TAB_ID,
leafId: LEAF_ID,
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
}
}
/** Default session-tabs backend: without it the session screen hangs on
* 'Loading tabs'. Returns false for methods it does not own. */
export function handleMockSessionTabsRequest(
request: RpcRequest,
respond: (response: RpcResponse) => void,
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
// Shared with `terminal.list` so both surfaces agree on which worktree an
// absent or `id:`-prefixed selector means.
resolveWorktreeId: (selector: unknown) => string | undefined
): boolean {
if (request.method !== 'session.tabs.list') {
return false
}
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
respond(success(request.id, createMockSessionTabs(worktreeId)))
return true
}
+5
View File
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
})
it('rename-only save updates only the name and does not reconnect', async () => {
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
setFieldValue(renderer, 'Name', 'Home Desk')
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { WebSocket } from 'ws'
import {
handleRequest,
type RpcRequest,
type RpcResponse
} from '../scripts/mock-server-rpc-handlers'
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
let response: RpcResponse | undefined
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
handleRequest(
request,
(nextResponse) => {
response = nextResponse
},
{} as WebSocket
)
expect(response).toBeDefined()
return response!
}
function listSessionTabs(worktree: string): RpcResponse {
return callRpc('session.tabs.list', { worktree })
}
describe('mock server session tabs fixture', () => {
it('returns a contract-complete terminal surface for the requested worktree', () => {
const response = listSessionTabs('id:repo-1::worktree-1')
expect(response.result).toEqual({
worktree: 'repo-1::worktree-1',
publicationEpoch: expect.stringMatching(/^mock-server:/),
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1'],
recentTabIds: ['tab-1']
}
],
tabs: [
{
type: 'terminal',
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
title: 'zsh',
parentTabId: 'tab-1',
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
})
})
it('passes a bare worktree selector through unprefixed', () => {
const response = listSessionTabs('repo-1::worktree-1')
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
})
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
const terminals = callRpc('terminal.list').result as {
terminals: { worktreeId: string }[]
}
const expected = terminals.terminals[0]?.worktreeId
expect(expected).toBeTruthy()
const tabs = callRpc('session.tabs.list').result as { worktree: string }
expect(tabs.worktree).toBe(expected)
})
})
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { displayHostEndpoint } from './host-endpoint'
import { resolveHostEndpointEdit } from './host-endpoint-edit'
function persistedAfterEdit(stored: string, input = displayHostEndpoint(stored)) {
const edit = resolveHostEndpointEdit(stored, input)
return edit.kind === 'changed' ? edit.endpoint : stored
}
describe('resolveHostEndpointEdit', () => {
it.each([
['implicit secure DNS proxy', 'wss://desk.example.com'],
['explicit secure default', 'wss://desk.example.com:443'],
['explicit secure custom', 'wss://desk.example.com:8443'],
['implicit secure IPv6 proxy', 'wss://[2001:db8::1]'],
['explicit secure IPv6 default', 'wss://[2001:db8::1]:443'],
['implicit LAN websocket', 'ws://desk.local'],
['explicit websocket default', 'ws://desk.local:80'],
['explicit websocket custom', 'ws://desk.local:6768'],
['secure path-routed proxy', 'wss://desk.example.com/orca'],
['secure query-routed proxy', 'wss://desk.example.com/orca?route=runtime'],
['legacy bare hostname', 'desk.local'],
['legacy bare host and port', 'desk.local:7777'],
['legacy unparsable endpoint', 'not-a-url']
])('preserves an untouched %s endpoint', (_label, stored) => {
const edit = resolveHostEndpointEdit(stored, displayHostEndpoint(stored))
expect(edit).toEqual({ kind: 'unchanged', endpoint: stored })
expect(persistedAfterEdit(stored)).toBe(stored)
})
it.each([
['wss://old.example.com', 'new.example.com', 'wss://new.example.com:443'],
['wss://old.example.com:8443', 'new.example.com', 'wss://new.example.com:8443'],
['ws://old.local', 'new.local', 'ws://new.local:6768'],
['ws://old.local:80', 'new.local', 'ws://new.local:80'],
['wss://[2001:db8::1]', '[2001:db8::2]', 'wss://[2001:db8::2]:443']
])('uses the current endpoint semantics for an address edit', (stored, input, expected) => {
const edit = resolveHostEndpointEdit(stored, input)
expect(edit).toEqual({ kind: 'changed', endpoint: expected })
})
it.each([
['surrounding whitespace', ' desk.example.com '],
['hostname case', 'DESK.EXAMPLE.COM'],
['explicit default port', 'desk.example.com:443'],
['percent-encoded hostname', 'wss://%64esk.example.com:443']
])('preserves routed endpoint bytes for equivalent %s', (_label, input) => {
const stored = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
expect(resolveHostEndpointEdit(stored, input)).toEqual({
kind: 'unchanged',
endpoint: stored
})
})
it('preserves the hidden route when the authority changes', () => {
expect(
resolveHostEndpointEdit('wss://old.example.com/%6Fruntime?route=%72ed', 'new.example.com')
).toEqual({
kind: 'changed',
endpoint: 'wss://new.example.com:443/%6Fruntime?route=%72ed'
})
})
it('discriminates invalid input without a candidate endpoint', () => {
expect(
resolveHostEndpointEdit('wss://desk.example.com/orca', 'https://desk.example.com')
).toEqual({
kind: 'invalid',
error: 'Use ws:// or wss:// (or host:port).'
})
})
it('stays stable through repeated name-only edits', () => {
const stored = 'wss://desk.example.com/orca?route=runtime'
const once = persistedAfterEdit(stored)
expect(once).toBe(stored)
expect(persistedAfterEdit(once)).toBe(stored)
})
})
@@ -0,0 +1,57 @@
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from './host-endpoint'
export type HostEndpointEditResolution =
| { kind: 'unchanged'; endpoint: string }
| { kind: 'changed'; endpoint: string }
| { kind: 'invalid'; error: string }
export function resolveHostEndpointEdit(
storedEndpoint: string,
input: string
): HostEndpointEditResolution {
const displayedEndpoint = displayHostEndpoint(storedEndpoint)
if (input.trim() === displayedEndpoint) {
return { kind: 'unchanged', endpoint: storedEndpoint }
}
const fallbackScheme = endpointScheme(storedEndpoint)
const fallbackPort =
endpointPort(storedEndpoint) ?? (fallbackScheme === 'wss' ? '443' : undefined)
const options = { fallbackPort, fallbackScheme }
const normalizedInput = normalizeHostEndpoint(input, options)
if (!normalizedInput.ok) {
return { kind: 'invalid', error: normalizedInput.error }
}
const normalizedDisplay = normalizeHostEndpoint(displayedEndpoint, options)
if (
sameEndpointAuthority(normalizedInput.endpoint, storedEndpoint) ||
(normalizedDisplay.ok &&
sameEndpointAuthority(normalizedInput.endpoint, normalizedDisplay.endpoint))
) {
return { kind: 'unchanged', endpoint: storedEndpoint }
}
return {
kind: 'changed',
endpoint: normalizedInput.endpoint + endpointRouteSuffix(storedEndpoint)
}
}
function sameEndpointAuthority(left: string, right: string): boolean {
try {
return new URL(left).origin === new URL(right).origin
} catch {
return false
}
}
function endpointRouteSuffix(endpoint: string): string {
const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]*(.*)$/.exec(endpoint)
return match?.[1] ?? ''
}
+46 -3
View File
@@ -822,19 +822,60 @@ def require_non_empty_string(value, name):
return str(value)
def click_at(x, y, button, count):
def click_at(x, y, button, count, modifiers=None):
button = (button or "left").lower()
buttons = {"left": ("b1p", "b1r"), "right": ("b3p", "b3r"), "middle": ("b2p", "b2r")}
if button not in buttons:
raise RuntimeError(f"unsupported mouse button: {button}")
parsed_count = require_positive_integer(1 if count is None else count, "click_count")
modifier_keys = click_modifier_keys(modifiers)
if modifier_keys:
modified_click_at(x, y, button, parsed_count, modifier_keys)
return
down, up = buttons[button]
for _ in range(require_positive_integer(1 if count is None else count, "click_count")):
for _ in range(parsed_count):
Atspi.generate_mouse_event(round(x), round(y), "abs")
Atspi.generate_mouse_event(round(x), round(y), down)
time.sleep(0.03)
Atspi.generate_mouse_event(round(x), round(y), up)
def click_modifier_keys(raw):
if raw is None:
return []
aliases = {
"ctrl": "ctrl", "control": "ctrl", "cmdorctrl": "ctrl", "commandorcontrol": "ctrl",
"shift": "shift", "alt": "alt", "option": "alt",
"meta": "super", "super": "super", "win": "super", "cmd": "super", "command": "super",
}
parts = [part.strip().lower() for part in str(raw).split("+")]
if not parts or any(not part or part not in aliases for part in parts):
raise RuntimeError("click modifiers require modifier keys only")
return list(dict.fromkeys(aliases[part] for part in parts))
def modified_click_at(x, y, button, count, modifier_keys):
xdotool = shutil.which("xdotool")
is_wayland = os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
if not xdotool or is_wayland:
raise RuntimeError("modified clicks require xdotool on an X11 session")
button_number = {"left": "1", "middle": "2", "right": "3"}[button]
command = [xdotool, "mousemove", "--sync", str(round(x)), str(round(y))]
for modifier in modifier_keys:
command.extend(["keydown", modifier])
command.extend(["click", "--repeat", str(count), "--delay", "35", button_number])
for modifier in reversed(modifier_keys):
command.extend(["keyup", modifier])
try:
subprocess.run(command, check=True, timeout=5)
finally:
subprocess.run(
[xdotool, *[item for modifier in reversed(modifier_keys) for item in ("keyup", modifier)]],
check=False,
timeout=2,
)
def scroll_at(x, y, direction, pages):
if direction is None or str(direction).strip() == "":
raise RuntimeError("direction is required")
@@ -1024,12 +1065,14 @@ def run_operation(operation):
if operation.get("click_count") is not None
else 1
)
handled = operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
has_modifiers = bool(str(operation.get("modifiers") or "").strip())
handled = not has_modifiers and operation.get("mouse_button", "left") == "left" and click_count <= 1 and perform_action(node, preferred)
if not handled:
click_at(
*screen_point(bounds, saved, operation.get("x"), operation.get("y"), node),
operation.get("mouse_button", "left"),
click_count,
operation.get("modifiers"),
)
action = {"path": "synthetic", "actionName": None, "fallbackReason": "actionUnsupported"}
else:
@@ -729,12 +729,13 @@ final class Provider {
let snapshot = try currentSnapshot(params: params)
let button = params["mouseButton"]?.string ?? "left"
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
let modifiers = try KeyMap.parseModifiers(params["modifiers"]?.string)
// Why: agents expect a click into a target app to make the next
// keyboard action safe, even when the click uses an AX action path.
recoverWindow(snapshot.app)
if let elementIndex = try optionalInteger(params, "elementIndex") {
let record = try element(snapshot, elementIndex)
if count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
return actionMetadata(path: "accessibility", actionName: actionName)
}
if let point = center(record.localFrame, in: snapshot.windowBounds) {
@@ -742,7 +743,8 @@ final class Provider {
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported")
}
@@ -753,7 +755,8 @@ final class Provider {
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic")
}
@@ -2282,14 +2285,23 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
}
private enum Input {
static func click(pid: pid_t, at point: CGPoint, button: MouseButton, count: Int) throws {
static func click(
pid: pid_t,
at point: CGPoint,
button: MouseButton,
count: Int,
modifiers: [KeyModifier]
) throws {
guard let source = CGEventSource(stateID: .combinedSessionState) else {
throw ProviderError.coded("accessibility_error", "failed to create event source")
}
let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in
result.insert(modifier.flag)
}
for _ in 0..<max(count, 1) {
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, pid: pid)
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
}
}
@@ -2343,16 +2355,20 @@ private enum Input {
static func pressKey(_ key: String, pid: pid_t) throws {
let parsed = try KeyMap.parse(key)
var flags = CGEventFlags()
var pressedModifiers: [KeyModifier] = []
defer {
for modifier in pressedModifiers.reversed() {
flags.remove(modifier.flag)
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
}
}
for modifier in parsed.modifiers {
flags.insert(modifier.flag)
try keyEvent(modifier.keyCode, down: true, flags: flags, pid: pid)
pressedModifiers.append(modifier)
}
try keyEvent(parsed.keyCode, down: true, flags: flags, pid: pid)
try keyEvent(parsed.keyCode, down: false, flags: flags, pid: pid)
for modifier in parsed.modifiers.reversed() {
try keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
flags.remove(modifier.flag)
}
}
static func pasteText(_ text: String, pid: pid_t) throws {
@@ -2377,10 +2393,18 @@ private enum Input {
try pressKey("cmd+v", pid: pid)
}
private static func mouse(_ type: CGEventType, source: CGEventSource, point: CGPoint, button: CGMouseButton, pid: pid_t) throws {
private static func mouse(
_ type: CGEventType,
source: CGEventSource,
point: CGPoint,
button: CGMouseButton,
flags: CGEventFlags = [],
pid: pid_t
) throws {
guard let event = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: point, mouseButton: button) else {
throw ProviderError.coded("accessibility_error", "failed to create mouse event")
}
event.flags = flags
event.postToPid(pid)
}
@@ -2497,16 +2521,9 @@ private enum KeyMap {
var modifiers: [KeyModifier] = []
var keyName: String?
for part in parts {
switch part {
case "cmd", "command", "meta", "super", "cmdorctrl", "commandorcontrol":
modifiers.append(KeyModifier(keyCode: 55, flag: .maskCommand))
case "ctrl", "control":
modifiers.append(KeyModifier(keyCode: 59, flag: .maskControl))
case "alt", "option":
modifiers.append(KeyModifier(keyCode: 58, flag: .maskAlternate))
case "shift":
modifiers.append(KeyModifier(keyCode: 56, flag: .maskShift))
default:
if let modifier = modifier(part) {
modifiers.append(modifier)
} else {
keyName = part
}
}
@@ -2516,6 +2533,38 @@ private enum KeyMap {
return ParsedKey(keyCode: keyCode, modifiers: modifiers)
}
static func parseModifiers(_ spec: String?) throws -> [KeyModifier] {
guard let spec else {
return []
}
let parts = spec.split(separator: "+", omittingEmptySubsequences: false)
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
guard !parts.isEmpty, !parts.contains(where: \.isEmpty) else {
throw ProviderError.coded("invalid_argument", "click modifiers require modifier keys only")
}
return try parts.map { part in
guard let modifier = modifier(part) else {
throw ProviderError.coded("invalid_argument", "unsupported click modifier '\(part)'")
}
return modifier
}
}
private static func modifier(_ part: String) -> KeyModifier? {
switch part {
case "cmd", "command", "meta", "super", "win", "cmdorctrl", "commandorcontrol":
return KeyModifier(keyCode: 55, flag: .maskCommand)
case "ctrl", "control":
return KeyModifier(keyCode: 59, flag: .maskControl)
case "alt", "option":
return KeyModifier(keyCode: 58, flag: .maskAlternate)
case "shift":
return KeyModifier(keyCode: 56, flag: .maskShift)
default:
return nil
}
}
private static let codes: [String: CGKeyCode] = [
"a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
"b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
@@ -2530,12 +2579,9 @@ private enum KeyMap {
}
private final class AgentRuntime: NSObject, NSApplicationDelegate {
private static let unclaimedSessionDeadline: TimeInterval = 30
private let socketPath: String
private let token: String?
private var listener: SocketListener?
private var unclaimedSessionTimeout: DispatchWorkItem?
init(socketPath: String, token: String?) {
self.socketPath = socketPath
@@ -2544,31 +2590,9 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
do {
let timeout = DispatchWorkItem {
fputs("computer-use agent received no authenticated session before its deadline\n", stderr)
NSApp.terminate(nil)
}
unclaimedSessionTimeout = timeout
let listener = try SocketListener(
socketPath: socketPath,
token: token,
onSessionClaimed: {
DispatchQueue.main.async {
timeout.cancel()
}
},
onSessionClosed: {
DispatchQueue.main.async {
NSApp.terminate(nil)
}
}
)
let listener = try SocketListener(socketPath: socketPath, token: token)
self.listener = listener
listener.start()
DispatchQueue.main.asyncAfter(
deadline: .now() + Self.unclaimedSessionDeadline,
execute: timeout
)
} catch {
fputs("failed to start computer-use socket: \(error)\n", stderr)
NSApp.terminate(nil)
@@ -2576,8 +2600,6 @@ private final class AgentRuntime: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
unclaimedSessionTimeout?.cancel()
unclaimedSessionTimeout = nil
listener?.stop()
}
}
@@ -3483,25 +3505,14 @@ private final class ButtonTarget: NSObject {
private final class SocketListener: @unchecked Sendable {
private let socketPath: String
private let token: String?
private let onSessionClaimed: () -> Void
private let onSessionClosed: () -> Void
private let provider = Provider()
private let providerLock = NSLock()
private let sessionLock = NSLock()
private var sessionOwnership = AgentSessionOwnership()
private var socketFd: Int32 = -1
private var isStopped = false
init(
socketPath: String,
token: String?,
onSessionClaimed: @escaping () -> Void,
onSessionClosed: @escaping () -> Void
) throws {
init(socketPath: String, token: String?) throws {
self.socketPath = socketPath
self.token = token
self.onSessionClaimed = onSessionClaimed
self.onSessionClosed = onSessionClosed
try bindSocket()
}
@@ -3583,39 +3594,15 @@ private final class SocketListener: @unchecked Sendable {
}
private func handleConnection(_ fd: Int32) {
defer {
sessionLock.lock()
let shouldTerminate = sessionOwnership.disconnect(fd)
sessionLock.unlock()
close(fd)
if shouldTerminate {
onSessionClosed()
}
}
defer { close(fd) }
let authorizedPeer = peerProcessId(fd).map(isAuthorizedAgentPeer) == true
let decoder = JSONDecoder()
var registrationComplete = false
while let line = readLine(from: fd) {
guard let data = line.data(using: .utf8),
let request = try? decoder.decode(Request.self, from: data)
else {
continue
}
if !registrationComplete {
sessionLock.lock()
let registration = sessionOwnership.registerConnection(
fd,
authenticated: isAuthenticated(
request: request,
authorizedPeer: authorizedPeer
)
)
sessionLock.unlock()
registrationComplete = registration != .rejected
if registration == .claimed {
onSessionClaimed()
}
}
let response = handleRequest(
provider: provider,
lock: providerLock,
@@ -3626,12 +3613,6 @@ private final class SocketListener: @unchecked Sendable {
writeJSON(response, to: fd)
}
}
private func isAuthenticated(request: Request, authorizedPeer: Bool) -> Bool {
guard authorizedPeer else { return false }
guard let token else { return true }
return request.token == token
}
}
private func existingPathMode(_ path: String) -> mode_t? {
@@ -3764,7 +3745,7 @@ private func handleRequest(
if let expectedToken, request.token != expectedToken {
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "invalid computer-use agent token"]]
}
if !authorizedPeer {
if expectedToken != nil && !authorizedPeer {
return ["id": request.id, "ok": false, "error": ["code": "permission_denied", "message": "computer-use agent peer is not authorized"]]
}
if request.method == "terminate" {
@@ -1,35 +0,0 @@
public enum AgentSessionRegistration: Sendable, Equatable {
case rejected
case sessionReleased
case joined
case claimed
}
public struct AgentSessionOwnership: Sendable {
private var authenticatedConnections: Set<Int32> = []
private var wasClaimed = false
private var wasReleased = false
public init() {}
public mutating func registerConnection(
_ connection: Int32,
authenticated: Bool
) -> AgentSessionRegistration {
guard authenticated else { return .rejected }
guard !wasReleased else { return .sessionReleased }
let inserted = authenticatedConnections.insert(connection).inserted
guard inserted, !wasClaimed else { return .joined }
wasClaimed = true
return .claimed
}
public mutating func disconnect(_ connection: Int32) -> Bool {
guard authenticatedConnections.remove(connection) != nil else { return false }
let released = wasClaimed && authenticatedConnections.isEmpty
if released {
wasReleased = true
}
return released
}
}
@@ -18,4 +18,32 @@ final class AgentEntrypointSourceSafetyTests: XCTestCase {
XCTAssertFalse(source.contains("unlink(tokenPath)"))
XCTAssertFalse(source.contains("unlink(socketPath)"))
}
func testSyntheticModifiersHaveGuaranteedReleaseAndModifiedClicksUseFlags() throws {
let source = try agentEntrypointSource()
XCTAssertTrue(source.contains("var pressedModifiers: [KeyModifier] = []"))
XCTAssertTrue(source.contains(
"""
defer {
for modifier in pressedModifiers.reversed() {
flags.remove(modifier.flag)
try? keyEvent(modifier.keyCode, down: false, flags: flags, pid: pid)
"""
))
XCTAssertTrue(source.contains("event.flags = flags\n event.postToPid(pid)"))
}
private func agentEntrypointSource() throws -> String {
let testFile = URL(fileURLWithPath: #filePath)
let packageRoot = testFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let mainPath = packageRoot
.appendingPathComponent("Sources")
.appendingPathComponent("OrcaComputerUseMacOS")
.appendingPathComponent("main.swift")
return try String(contentsOf: mainPath, encoding: .utf8)
}
}
@@ -1,50 +0,0 @@
import OrcaComputerUseMacOSCore
import XCTest
final class AgentSessionOwnershipTests: XCTestCase {
func testUnclaimedDisconnectDoesNotTerminateAgent() {
var ownership = AgentSessionOwnership()
XCTAssertFalse(ownership.disconnect(12))
}
func testUnauthenticatedConnectionCannotClaimOrRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: false), .rejected)
XCTAssertFalse(ownership.disconnect(12))
}
func testLastAuthenticatedDisconnectTerminatesAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(12))
}
func testAgentWaitsForEveryAuthenticatedConnectionToClose() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .joined)
XCTAssertFalse(ownership.disconnect(12))
XCTAssertTrue(ownership.disconnect(13))
}
func testDuplicateRegistrationDoesNotRetainAgent() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .joined)
XCTAssertTrue(ownership.disconnect(12))
}
func testReleasedSessionCannotBeReclaimed() {
var ownership = AgentSessionOwnership()
XCTAssertEqual(ownership.registerConnection(12, authenticated: true), .claimed)
XCTAssertTrue(ownership.disconnect(12))
XCTAssertEqual(ownership.registerConnection(13, authenticated: true), .sessionReleased)
XCTAssertFalse(ownership.disconnect(13))
}
}
+113 -7
View File
@@ -16,6 +16,7 @@ Add-Type -AssemblyName System.Windows.Forms
Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
public static class OrcaDesktopWin32 {
@@ -33,6 +34,39 @@ public static class OrcaDesktopWin32 {
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
public struct INPUT {
public uint type;
public INPUTUNION data;
}
[StructLayout(LayoutKind.Explicit)]
public struct INPUTUNION {
[FieldOffset(0)]
public MOUSEINPUT mouse;
[FieldOffset(0)]
public KEYBDINPUT keyboard;
}
[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT {
public int dx;
public int dy;
public uint mouseData;
public uint flags;
public uint time;
public UIntPtr extraInfo;
}
[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT {
public ushort virtualKey;
public ushort scanCode;
public uint flags;
public uint time;
public UIntPtr extraInfo;
}
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr hwnd, out RECT rect);
@@ -56,6 +90,54 @@ public static class OrcaDesktopWin32 {
[DllImport("user32.dll")]
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, int dwData, UIntPtr dwExtraInfo);
[DllImport("user32.dll")]
public static extern uint SendInput(uint count, INPUT[] inputs, int size);
public static void SendModifiedClick(byte[] modifiers, uint mouseDown, uint mouseUp) {
const uint keyboardInput = 1;
const uint mouseInput = 0;
const uint keyUp = 0x0002;
var inputs = new List<INPUT>();
foreach (var modifier in modifiers) {
inputs.Add(KeyboardInput(keyboardInput, modifier, 0));
}
inputs.Add(MouseInput(mouseInput, mouseDown));
inputs.Add(MouseInput(mouseInput, mouseUp));
for (var index = modifiers.Length - 1; index >= 0; index--) {
inputs.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
}
var values = inputs.ToArray();
var sent = SendInput((uint)values.Length, values, Marshal.SizeOf(typeof(INPUT)));
if (sent != (uint)values.Length) {
var releases = new List<INPUT>();
releases.Add(MouseInput(mouseInput, mouseUp));
for (var index = modifiers.Length - 1; index >= 0; index--) {
releases.Add(KeyboardInput(keyboardInput, modifiers[index], keyUp));
}
var releaseValues = releases.ToArray();
SendInput((uint)releaseValues.Length, releaseValues, Marshal.SizeOf(typeof(INPUT)));
throw new InvalidOperationException("SendInput did not complete the modified click");
}
}
private static INPUT KeyboardInput(uint type, byte virtualKey, uint flags) {
return new INPUT {
type = type,
data = new INPUTUNION {
keyboard = new KEYBDINPUT { virtualKey = virtualKey, flags = flags }
}
};
}
private static INPUT MouseInput(uint type, uint flags) {
return new INPUT {
type = type,
data = new INPUTUNION {
mouse = new MOUSEINPUT { flags = flags }
}
};
}
}
"@
@@ -912,7 +994,7 @@ function Get-OrcaElementScreenPoint($Element) {
$null
}
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count) {
function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY, [string]$Button, [int]$Count, [string]$Modifiers) {
[void][OrcaDesktopWin32]::SetForegroundWindow($WindowHandle)
[void][OrcaDesktopWin32]::SetCursorPos($ScreenX, $ScreenY)
$buttonName = if ([string]::IsNullOrWhiteSpace($Button)) { "left" } else { $Button.ToLowerInvariant() }
@@ -923,10 +1005,23 @@ function Send-OrcaMouseClick([IntPtr]$WindowHandle, [int]$ScreenX, [int]$ScreenY
default { throw "unsupported mouse button: $Button" }
}
for ($i = 0; $i -lt (Get-OrcaPositiveInteger $Count "click_count"); $i++) {
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 35
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
$modifierKeys = @(Get-OrcaClickModifierVirtualKeys $Modifiers)
$clickCount = Get-OrcaPositiveInteger $Count "click_count"
if ($modifierKeys.Count -eq 0) {
for ($i = 0; $i -lt $clickCount; $i++) {
[OrcaDesktopWin32]::mouse_event($down, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 35
[OrcaDesktopWin32]::mouse_event($up, 0, 0, 0, [UIntPtr]::Zero)
}
return
}
for ($i = 0; $i -lt $clickCount; $i++) {
[OrcaDesktopWin32]::SendModifiedClick(
[byte[]]$modifierKeys,
[uint32]$down,
[uint32]$up
)
if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }
}
}
@@ -990,6 +1085,16 @@ function Get-OrcaModifierVirtualKey([string]$Modifier) {
}
}
function Get-OrcaClickModifierVirtualKeys([string]$Modifiers) {
if ([string]::IsNullOrWhiteSpace($Modifiers)) { return @() }
$parts = @($Modifiers.Split("+") | ForEach-Object { $_.Trim() })
$emptyParts = @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0 -or $emptyParts.Count -gt 0) {
throw "Click modifiers require modifier keys only"
}
@($parts | ForEach-Object { Get-OrcaModifierVirtualKey $_ })
}
function Send-OrcaHotkey([IntPtr]$WindowHandle, [string]$KeySpec) {
$parts = @($KeySpec.Split("+") | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) { throw "Unsupported key: $KeySpec" }
@@ -1106,13 +1211,14 @@ function Invoke-OrcaOperation($Operation) {
Restore-OrcaWindow $process
$handledByPattern = $false
$clickCount = Get-OrcaPositiveInteger $Operation.click_count "click_count"
if ($null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
$hasModifiers = -not [string]::IsNullOrWhiteSpace([string]$Operation.modifiers)
if (-not $hasModifiers -and $null -ne $element -and $Operation.mouse_button -ne "right" -and $Operation.mouse_button -ne "middle" -and $clickCount -le 1) {
$handledByPattern = Invoke-OrcaPrimaryAction $element
}
if (-not $handledByPattern) {
$point = Get-OrcaElementScreenPoint $element
if ($null -eq $point) { $point = Get-OrcaScreenPoint $Operation $windowFrame }
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount
Send-OrcaMouseClick $handle $point.x $point.y $Operation.mouse_button $clickCount $Operation.modifiers
$action = [pscustomobject]@{ path = "synthetic"; actionName = $null; fallbackReason = "actionUnsupported" }
} else {
$action = [pscustomobject]@{ path = "accessibility"; actionName = "primaryAction"; fallbackReason = $null }
+1
View File
@@ -106,6 +106,7 @@
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
"bench:worktree-deletion": "node tools/benchmarks/worktree-deletion-dev-bench.mjs",
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
File diff suppressed because it is too large Load Diff
+2
View File
@@ -69,6 +69,7 @@ ORCA computer get-app-state --app <app> --json
ORCA computer get-app-state --app <app> --restore-window --json
ORCA computer click --app <app> --element-index <index> --json
ORCA computer click --app <app> --x 100 --y 100 --json
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
ORCA computer type-text --app <app> --text "text" --json
@@ -95,6 +96,7 @@ printf '%s' "$TEXT" | ORCA computer set-value --app <app> --element-index <index
- After any UI-changing action, use the returned state or rerun `get-app-state` before choosing the next element index.
- Use `type-text` only after focusing a field and confirming the app has a focused text receiver; synthetic keyboard delivery is reported as unverified, so inspect the returned state before assuming text landed.
- Use `press-key` for single/navigation keys such as Return, Escape, Tab, and arrows. Use `hotkey` only for one modifier chord plus one key, such as `CmdOrCtrl+A` or `CmdOrCtrl+Shift+P`; prefer `CmdOrCtrl+...` for cross-platform combos.
- Use `click --modifiers <chord>` for modifier-clicks. Never synthesize separate modifier-down and modifier-up commands around a click; interruption can leave a modifier logically held.
- Some actions work in background apps, but this is app-dependent. If success does not change the UI, refresh state and choose a more semantic action or restore/focus the window.
- Prefer `set-value` for text fields that expose values; it can report verified value writes when the provider can read the refreshed value.
- Coordinates are window-local; use coordinates from the latest screenshot/state for the same target window.
File diff suppressed because one or more lines are too long
+15 -5
View File
@@ -12,7 +12,7 @@ import {
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/types'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
@@ -75,7 +75,10 @@ function readEnabledFromDisk(): boolean {
return state.settings?.agentStatusHooksEnabled !== false
}
function updateEnabledOnDisk(enabled: boolean): string {
function updateEnabledOnDisk(enabled: boolean): {
settingsPath: string
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
const state = readPersistedState(dataPath)
state.settings = {
@@ -84,7 +87,13 @@ function updateEnabledOnDisk(enabled: boolean): string {
agentStatusHooksEnabled: enabled
}
writePersistedState(dataPath, state)
return dataPath
return {
settingsPath: dataPath,
settings: {
agentCmdOverrides: state.settings.agentCmdOverrides ?? {},
disabledTuiAgents: state.settings.disabledTuiAgents ?? []
}
}
}
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
@@ -134,10 +143,11 @@ async function setAgentHooksEnabled(
enabled: boolean
): Promise<AgentHookCommandResult> {
const updatedRuntime = await updateRunningRuntime(client, enabled)
const settingsPath = updatedRuntime ? getDataPath() : updateEnabledOnDisk(enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
const statuses = updatedRuntime
? getManagedAgentHookStatuses()
: applyAgentStatusHooksEnabled(enabled)
: await applyAgentStatusHooksEnabled(enabled, offlineUpdate?.settings)
return {
enabled,
settingsPath,
+12 -1
View File
@@ -1,4 +1,5 @@
import {
computerUseClickModifiersValidationMessage,
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
@@ -60,16 +61,26 @@ export function getComputerClickActionFlags(flags: Map<string, string | boolean>
y?: number
clickCount?: number
mouseButton?: string
modifiers?: string
} {
const rawModifiers = flags.get('modifiers')
const modifiers = typeof rawModifiers === 'string' ? rawModifiers : undefined
const result = {
elementIndex: getOptionalNonNegativeIntegerFlag(flags, 'element-index'),
x: getOptionalNumberFlag(flags, 'x'),
y: getOptionalNumberFlag(flags, 'y'),
clickCount: getOptionalPositiveIntegerFlag(flags, 'click-count'),
mouseButton: getOptionalStringFlag(flags, 'mouse-button')
mouseButton: getOptionalStringFlag(flags, 'mouse-button'),
modifiers
}
validateElementOrCoordinates('Click', result.elementIndex, result.x, result.y)
validateMouseButton(result.mouseButton)
if (modifiers !== undefined) {
const message = computerUseClickModifiersValidationMessage(modifiers)
if (message) {
throw new RuntimeClientError('invalid_argument', message)
}
}
return result
}
@@ -59,6 +59,8 @@ describe('orca computer action CLI routing', () => {
'Finder',
'--element-index',
'3',
'--modifiers',
'CmdOrCtrl+Shift',
'--json'
],
'/tmp/repo/src'
@@ -73,6 +75,7 @@ describe('orca computer action CLI routing', () => {
y: undefined,
clickCount: undefined,
mouseButton: undefined,
modifiers: 'CmdOrCtrl+Shift',
noScreenshot: undefined
})
})
@@ -289,6 +289,43 @@ describe('orca computer action CLI validation', () => {
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
[
'computer',
'click',
'--app',
'Finder',
'--element-index',
'1',
'--modifiers',
'CmdOrCtrl+A'
],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Click modifiers accept modifier keys only'
)
expect(process.exitCode).toBe(1)
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
['computer', 'click', '--app', 'Finder', '--element-index', '1', '--modifiers', ''],
'/tmp/repo/src'
)
expect(callMock).not.toHaveBeenCalled()
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
'Click modifiers accept modifier keys only'
)
expect(process.exitCode).toBe(1)
vi.mocked(console.error).mockClear()
process.exitCode = undefined
await main(
['computer', 'scroll', '--app', 'Finder', '--element-index', '1', '--direction', 'diagonal'],
'/tmp/repo/src'
+8
View File
@@ -72,6 +72,14 @@ describe('orca computer observation CLI handlers', () => {
expect(pressKeyOutput).toContain('Single key, e.g. Return, Escape, Tab, Left, or PageUp')
})
it('documents atomic modified clicks', async () => {
await main(['computer', 'click', '--help'], '/tmp/repo')
const output = vi.mocked(console.log).mock.calls[0][0]
expect(output).toContain('--modifiers <chord>')
expect(output).toContain('Modifier keys held only for this click')
})
it('passes list-apps through without resolving a worktree', async () => {
queueFixtures(callMock, okFixture('req_apps', { apps: [] }))
+1
View File
@@ -523,6 +523,7 @@ export function formatFlagHelp(flag: string): string {
limit: '--limit <n> Maximum number of rows to return',
mode: '--mode <mode> Mode such as edit, diff, or both',
'mouse-button': '--mouse-button <btn> Mouse button: left, right, or middle',
modifiers: '--modifiers <chord> Modifier keys held only for this click',
name: '--name <name> Name for the new worktree or automation',
'no-parent': '--no-parent Force no parent lineage for unrelated work',
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
+4 -3
View File
@@ -44,16 +44,17 @@ export const COMPUTER_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['computer', 'click'],
summary: 'Click an app element or window coordinate',
summary: 'Click an app element or window coordinate, optionally with modifiers',
usage:
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--restore-window] [--no-screenshot] [--json]',
'orca computer click --app <app> (--element-index <n> | --x <x> --y <y>) [--window-id <id> | --window-index <n>] [--click-count <n>] [--mouse-button <left|right|middle>] [--modifiers <modifier-chord>] [--restore-window] [--no-screenshot] [--json]',
allowedFlags: [
...COMPUTER_ACTION_FLAGS,
'element-index',
'x',
'y',
'click-count',
'mouse-button'
'mouse-button',
'modifiers'
]
},
{
+15 -131
View File
@@ -1,155 +1,39 @@
// Pins the contract between `runManagedHookInstallers` and the
// `agent_hook_install_failed` telemetry event: each catch must fire `track`
// with the correct agent label and a truncated error_message, and one
// installer's failure must not stop the others (fail-open semantics).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { trackMock } = vi.hoisted(() => ({ trackMock: vi.fn() }))
vi.mock('../telemetry/client', () => ({ track: trackMock }))
import { runManagedHookInstallers } from './install-telemetry'
import { recordManagedHookInstallFailure } from './install-telemetry'
describe('runManagedHookInstallers', () => {
describe('recordManagedHookInstallFailure', () => {
beforeEach(() => {
trackMock.mockReset()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('runs every installer when none throw and never calls track', () => {
const claude = vi.fn()
const codex = vi.fn()
runManagedHookInstallers([
['claude', claude],
['codex', codex]
])
expect(claude).toHaveBeenCalledTimes(1)
expect(codex).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
})
it('fires agent_hook_install_failed with the correct agent label when an installer throws', () => {
runManagedHookInstallers([
[
'codex',
() => {
throw new Error('codex config malformed')
}
]
])
it('records the agent and truncated error message', () => {
recordManagedHookInstallFailure('codex', new Error('x'.repeat(500)))
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'codex',
error_message: 'codex config malformed'
})
})
it('continues running later installers after an earlier one throws (fail-open)', () => {
const codex = vi.fn()
const gemini = vi.fn()
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex],
['gemini', gemini]
])
expect(codex).toHaveBeenCalledTimes(1)
expect(gemini).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith(
'agent_hook_install_failed',
expect.objectContaining({ agent: 'claude' })
)
})
it('truncates error_message to 200 chars', () => {
const longMessage = 'x'.repeat(500)
runManagedHookInstallers([
[
'gemini',
() => {
throw new Error(longMessage)
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
const [, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
expect(props.error_message.length).toBe(200)
})
it('handles non-Error throws', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw 'cursor string failure'
}
]
])
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: 'cursor string failure'
})
})
it('serializes thrown objects through JSON.stringify', () => {
runManagedHookInstallers([
[
'cursor',
() => {
throw { code: 'EACCES', path: '/tmp' }
}
]
])
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('agent_hook_install_failed', {
agent: 'cursor',
error_message: '{"code":"EACCES","path":"/tmp"}'
})
})
it('does not throw when an installer throws undefined (regression for JSON.stringify undefined return)', () => {
expect(() =>
runManagedHookInstallers([
[
'cursor',
() => {
throw undefined
}
]
])
).not.toThrow()
expect(trackMock).toHaveBeenCalledTimes(1)
const [eventName, props] = trackMock.mock.calls[0] as [string, { error_message: string }]
const [eventName, props] = trackMock.mock.calls[0] as [
string,
{ agent: string; error_message: string }
]
expect(eventName).toBe('agent_hook_install_failed')
expect(typeof props.error_message).toBe('string')
expect(props.agent).toBe('codex')
expect(props.error_message).toHaveLength(200)
})
it('continues running later installers when track itself throws (telemetry must not break fail-open)', () => {
const codex = vi.fn()
it('handles non-Error values and telemetry failures', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('telemetry blew up')
throw new Error('telemetry failed')
})
expect(() =>
runManagedHookInstallers([
[
'claude',
() => {
throw new Error('claude failed')
}
],
['codex', codex]
])
).not.toThrow()
expect(codex).toHaveBeenCalledTimes(1)
expect(() => recordManagedHookInstallFailure('cursor', { code: 'EACCES' })).not.toThrow()
})
})
+8 -31
View File
@@ -1,21 +1,8 @@
// Per-agent managed-hook installer with fail-open semantics and PostHog
// attribution. Lifted out of `src/main/index.ts` so the loop is unit-testable
// without standing up the full Electron startup graph — the catch site needs
// the agent label to fire `agent_hook_install_failed`, and the previous
// closure-style loop lost it.
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
// Why: install errors are about config-file shape (malformed JSON, ACL
// denial), not user content — but messages can include paths or stack
// fragments. The 200-char cap matches `agentHookInstallFailedSchema.error_message`
// in `src/shared/telemetry-events.ts`; the validator drops overlength values,
// so truncation must happen here at the call site.
const ERROR_MESSAGE_MAX_LEN = 200
export type ManagedHookInstaller = readonly [HookInstallAgent, () => void]
function describeError(error: unknown): string {
if (error instanceof Error) {
return error.message
@@ -31,23 +18,13 @@ function describeError(error: unknown): string {
}
}
export function runManagedHookInstallers(installers: readonly ManagedHookInstaller[]): void {
for (const [agent, install] of installers) {
try {
install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
// Why: telemetry must not break fail-open. A throw inside `track` (e.g.
// a corrupted settings store the resolveConsent path reads from) would
// otherwise abort the for-loop and skip later agents' installers.
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
export function recordManagedHookInstallFailure(agent: HookInstallAgent, error: unknown): void {
try {
track('agent_hook_install_failed', {
agent,
error_message: describeError(error).slice(0, ERROR_MESSAGE_MAX_LEN)
})
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
}
@@ -0,0 +1,212 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
const codexTarget: ManagedAgentHookTarget = {
agent: 'codex',
tuiAgent: 'codex',
executableCandidates: ['codex']
}
const claudeTarget: ManagedAgentHookTarget = {
agent: 'claude',
tuiAgent: 'claude',
executableCandidates: ['claude']
}
describe('detectLocalManagedAgentCliPresence', () => {
let tmpDir: string | null = null
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true })
tmpDir = null
}
})
it('scans a deduped PATH once for all agent candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath.endsWith('/bin/codex'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{
pathEnv: ['/bin', '/bin', '/usr/bin'].join(':'),
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'/bin/codex',
'/bin/claude',
'/usr/bin/claude'
])
})
it('uses executable override paths as positive evidence', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/custom/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '/custom/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/custom/bin/codex')
})
it('preserves Windows override separators', async () => {
const overridePath = 'C:\\My Tools\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: `"${overridePath}" --flag` } },
{
pathEnv: '',
pathDelimiter: ';',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands Windows home-relative override paths with Windows separators', async () => {
const overridePath = 'C:\\Users\\orca\\bin\\claude.cmd'
const probe = vi.fn(async (filePath: string) => filePath === overridePath)
const result = await detectLocalManagedAgentCliPresence(
[claudeTarget],
{ agentCmdOverrides: { claude: '~\\bin\\claude.cmd --flag' } },
{
pathEnv: '',
fileProbe: { isExecutableFile: probe },
platform: 'win32',
homeDir: 'C:\\Users\\orca'
}
)
expect(result.claude?.state).toBe('found')
expect(probe).toHaveBeenCalledWith(overridePath)
})
it('expands home-relative override paths', async () => {
const probe = vi.fn(async (filePath: string) => filePath === '/home/orca/bin/codex')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: '~/bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux',
homeDir: '/home/orca'
}
)
expect(result.codex?.state).toBe('found')
expect(probe).toHaveBeenCalledWith('/home/orca/bin/codex')
})
it('reports relative override paths as unknown', async () => {
const probe = vi.fn(async () => true)
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: { codex: 'bin/codex --profile work' } },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: probe },
platform: 'linux'
}
)
expect(result.codex).toEqual({ state: 'unknown' })
expect(probe).not.toHaveBeenCalled()
})
it('honors PATHEXT for Windows PATH candidates', async () => {
const probe = vi.fn(async (filePath: string) => filePath === 'C:\\Tools\\codex.CMD')
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: 'C:\\Other;C:\\Tools',
pathExt: '.EXE;.CMD',
fileProbe: { isExecutableFile: probe },
platform: 'win32'
}
)
expect(result.codex?.state).toBe('found')
expect(probe.mock.calls.map(([filePath]) => filePath)).toEqual([
'C:\\Other\\codex.EXE',
'C:\\Other\\codex.CMD',
'C:\\Tools\\codex.EXE',
'C:\\Tools\\codex.CMD'
])
})
it('warns and uses the inherited PATH when shell hydration throws', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const result = await detectLocalManagedAgentCliPresence(
[codexTarget],
{ agentCmdOverrides: {} },
{
pathEnv: '',
pathDelimiter: ':',
fileProbe: { isExecutableFile: vi.fn(async () => false) },
platform: 'linux',
shouldHydrateShellPath: true,
hydratePath: vi.fn(async () => {
throw new Error('shell unavailable')
})
}
)
expect(result.codex?.state).toBe('missing')
expect(warning).toHaveBeenCalledWith(
'[agent-hooks] Shell PATH hydration failed; using inherited PATH:',
expect.objectContaining({ message: 'shell unavailable' })
)
} finally {
warning.mockRestore()
}
})
it.runIf(process.platform !== 'win32')(
'accepts executable symlinks and rejects broken symlinks',
async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cli-presence-'))
const binDir = join(tmpDir, 'bin')
mkdirSync(binDir)
const targetPath = join(tmpDir, 'codex-real')
writeFileSync(targetPath, '#!/bin/sh\n')
chmodSync(targetPath, 0o755)
symlinkSync(targetPath, join(binDir, 'codex'))
symlinkSync(join(tmpDir, 'missing'), join(binDir, 'claude'))
const result = await detectLocalManagedAgentCliPresence(
[codexTarget, claudeTarget],
{ agentCmdOverrides: {} },
{ pathEnv: binDir, pathDelimiter: ':', platform: process.platform }
)
expect(result.codex?.state).toBe('found')
expect(result.claude?.state).toBe('missing')
}
)
})
@@ -0,0 +1,200 @@
import { constants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import path from 'node:path'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
hasPathSeparatorToken,
isSafeExecutableBasename
} from '../../shared/managed-agent-command-token'
import type { ManagedAgentHookTarget } from '../../shared/managed-agent-hook-targets'
import type { GlobalSettings } from '../../shared/types'
import { hydrateShellPath, mergePathSegments } from '../startup/hydrate-shell-path'
export type LocalCliPresenceState = 'found' | 'missing' | 'unknown'
export type LocalCliPresenceByAgent = Partial<
Record<AgentHookTarget, { state: LocalCliPresenceState }>
>
type FileProbe = {
isExecutableFile: (filePath: string) => Promise<boolean>
}
type HydrationResult =
| { ok: true; segments: string[] }
| { ok: false; segments: []; failureReason?: string }
type DetectOptions = {
pathEnv?: string
platform?: NodeJS.Platform
pathDelimiter?: string
pathExt?: string
fileProbe?: FileProbe
hydratePath?: () => Promise<HydrationResult>
shouldHydrateShellPath?: boolean
homeDir?: string
}
type CommandOverrideSettings = Partial<Pick<GlobalSettings, 'agentCmdOverrides'>> | null | undefined
const DEFAULT_WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD']
function pathApiForPlatform(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path.posix
}
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
try {
const fileStat = await stat(filePath)
if (!fileStat.isFile()) {
return false
}
if (platform === 'win32') {
return true
}
await access(filePath, constants.X_OK)
return true
} catch {
return false
}
}
function pathEntries(pathEnv: string, delimiter: string): string[] {
return [...new Set(pathEnv.split(delimiter).filter(Boolean))]
}
function windowsPathExts(value: string | undefined): string[] {
const source = value?.length ? value : DEFAULT_WINDOWS_EXTENSIONS.join(';')
return [
...new Set(
source
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => (part.startsWith('.') ? part : `.${part}`))
.map((part) => part.toUpperCase())
)
]
}
function candidateFileNames(
candidate: string,
platform: NodeJS.Platform,
pathExt?: string
): string[] {
if (platform !== 'win32' || pathApiForPlatform(platform).extname(candidate)) {
return [candidate]
}
return windowsPathExts(pathExt).map((suffix) => `${candidate}${suffix}`)
}
function overrideTokenForAgent(
settings: CommandOverrideSettings,
target: ManagedAgentHookTarget,
platform: NodeJS.Platform
): string | null {
return extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], { platform })
}
function expandHomePathToken(token: string, platform: NodeJS.Platform, homeDir: string): string {
if (token === '~') {
return homeDir
}
if (token.startsWith('~/') || (platform === 'win32' && token.startsWith('~\\'))) {
return pathApiForPlatform(platform).join(homeDir, token.slice(2))
}
return token
}
async function probePathCandidate(
candidate: string,
dirs: readonly string[],
platform: NodeJS.Platform,
fileProbe: FileProbe,
pathExt?: string
): Promise<boolean> {
if (!isSafeExecutableBasename(candidate)) {
return false
}
for (const dir of dirs) {
for (const fileName of candidateFileNames(candidate, platform, pathExt)) {
if (await fileProbe.isExecutableFile(pathApiForPlatform(platform).join(dir, fileName))) {
return true
}
}
}
return false
}
function isPlatformAbsolutePath(candidate: string, platform: NodeJS.Platform): boolean {
return platform === 'win32' ? path.win32.isAbsolute(candidate) : path.posix.isAbsolute(candidate)
}
async function maybeHydrateShellPath(options: DetectOptions): Promise<void> {
if (!options.shouldHydrateShellPath) {
return
}
try {
const result = await (options.hydratePath ?? hydrateShellPath)()
if (result.ok) {
mergePathSegments(result.segments)
}
} catch (error) {
// Detection failure must never permit config mutation.
console.warn('[agent-hooks] Shell PATH hydration failed; using inherited PATH:', error)
}
}
export async function detectLocalManagedAgentCliPresence(
targets: readonly ManagedAgentHookTarget[],
settings: CommandOverrideSettings,
options: DetectOptions = {}
): Promise<LocalCliPresenceByAgent> {
await maybeHydrateShellPath(options)
const platform = options.platform ?? process.platform
const delimiter = options.pathDelimiter ?? pathApiForPlatform(platform).delimiter
const dirs = pathEntries(options.pathEnv ?? process.env.PATH ?? '', delimiter)
const homeDir = options.homeDir ?? homedir()
const fileProbe = options.fileProbe ?? {
isExecutableFile: (filePath: string) => isExecutableFile(filePath, platform)
}
const candidates = new Set<string>()
for (const target of targets) {
for (const candidate of target.executableCandidates) {
if (!hasPathSeparatorToken(candidate)) {
candidates.add(candidate)
}
}
const override = overrideTokenForAgent(settings, target, platform)
if (override && !hasPathSeparatorToken(override)) {
candidates.add(override)
}
}
const found = new Set<string>()
for (const candidate of candidates) {
if (await probePathCandidate(candidate, dirs, platform, fileProbe, options.pathExt)) {
found.add(candidate)
}
}
const result: LocalCliPresenceByAgent = {}
for (const target of targets) {
const override = overrideTokenForAgent(settings, target, platform)
if (override && hasPathSeparatorToken(override)) {
const expanded = expandHomePathToken(override, platform, homeDir)
if (!isPlatformAbsolutePath(expanded, platform)) {
result[target.agent] = { state: 'unknown' }
continue
}
result[target.agent] = (await fileProbe.isExecutableFile(expanded))
? { state: 'found' }
: { state: 'missing' }
continue
}
const targetCandidates = [...target.executableCandidates, ...(override ? [override] : [])]
result[target.agent] = targetCandidates.some((candidate) => found.has(candidate))
? { state: 'found' }
: { state: 'missing' }
}
return result
}
@@ -0,0 +1,151 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
detect: vi.fn(),
installClaude: vi.fn(),
installCodex: vi.fn(),
removeClaude: vi.fn(),
removeCodex: vi.fn(),
statusClaude: vi.fn(),
statusCodex: vi.fn()
}))
vi.mock('./local-agent-cli-presence', () => ({
detectLocalManagedAgentCliPresence: mocks.detect
}))
vi.mock('./managed-agent-hook-registry', () => ({
MANAGED_AGENT_HOOK_INSTALLERS: [
['claude', mocks.installClaude],
['codex', mocks.installCodex]
],
MANAGED_AGENT_HOOK_REMOVERS: [
['claude', mocks.removeClaude],
['codex', mocks.removeCodex]
],
MANAGED_AGENT_HOOK_STATUS_READERS: [
['claude', mocks.statusClaude],
['codex', mocks.statusCodex]
]
}))
import {
applyAgentStatusHooksEnabled,
installManagedAgentHooks
} from './managed-agent-hook-controls'
function status(agent: 'claude' | 'codex', state: 'installed' | 'not_installed') {
return {
agent,
state,
configPath: `/${agent}`,
managedHooksPresent: state === 'installed',
detail: null
} as const
}
describe('managed agent hook controls', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.installClaude.mockReturnValue(status('claude', 'installed'))
mocks.installCodex.mockReturnValue(status('codex', 'installed'))
mocks.removeClaude.mockReturnValue(status('claude', 'not_installed'))
mocks.removeCodex.mockReturnValue(status('codex', 'not_installed'))
})
it('installs only agents with positively detected CLIs', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'missing' },
codex: { state: 'found' }
})
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_not_found'
}),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('fails closed when CLI detection rejects', async () => {
mocks.detect.mockRejectedValue(new Error('detection unavailable'))
const results = await installManagedAgentHooks({ agentCmdOverrides: {} })
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).not.toHaveBeenCalled()
expect(results).toEqual([
expect.objectContaining({
agent: 'claude',
state: 'skipped',
skipReason: 'cli_presence_unknown'
}),
expect.objectContaining({
agent: 'codex',
state: 'skipped',
skipReason: 'cli_presence_unknown'
})
])
})
it('removes disabled agents without probing or reinstalling them', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
const results = await applyAgentStatusHooksEnabled(true, {
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
})
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
expect(results).toEqual([
expect.objectContaining({ agent: 'claude', state: 'not_installed' }),
expect.objectContaining({ agent: 'codex', state: 'installed' })
])
})
it('does not install an agent disabled while detection was running', async () => {
mocks.detect.mockResolvedValue({
claude: { state: 'found' },
codex: { state: 'found' }
})
await installManagedAgentHooks(
{ agentCmdOverrides: {} },
{ shouldContinue: (agent) => agent !== 'claude' }
)
expect(mocks.installClaude).not.toHaveBeenCalled()
expect(mocks.installCodex).toHaveBeenCalledTimes(1)
})
it('does not remove an agent enabled by a newer settings update', async () => {
mocks.detect.mockResolvedValue({ codex: { state: 'found' } })
await applyAgentStatusHooksEnabled(
true,
{
agentCmdOverrides: {},
disabledTuiAgents: ['claude']
},
{ shouldContinue: () => true }
)
expect(mocks.removeClaude).not.toHaveBeenCalled()
})
it('removes every managed hook when the global setting is off', async () => {
await applyAgentStatusHooksEnabled(false)
expect(mocks.detect).not.toHaveBeenCalled()
expect(mocks.removeClaude).toHaveBeenCalledTimes(1)
expect(mocks.removeCodex).toHaveBeenCalledTimes(1)
})
})
@@ -1,75 +1,34 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import {
getManagedAgentHookTarget,
isManagedAgentHookTarget
} from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { droidHookService } from '../droid/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { devinHookService } from '../devin/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence'
import {
MANAGED_AGENT_HOOK_INSTALLERS,
MANAGED_AGENT_HOOK_REMOVERS,
MANAGED_AGENT_HOOK_STATUS_READERS,
type ManagedAgentHookInstaller
} from './managed-agent-hook-registry'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void]
type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
type ManagedHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
type ManagedHookSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
type InstallOptions = {
shouldHydrateShellPath?: boolean
onInstallError?: (agent: AgentHookTarget, error: unknown) => void
shouldContinue?: (agent: AgentHookTarget) => boolean
agents?: readonly AgentHookTarget[]
}
const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
type RemoveOptions = {
agents?: readonly AgentHookTarget[]
}
export function isAgentStatusHooksEnabled(
settings: Pick<GlobalSettings, 'agentStatusHooksEnabled'> | null | undefined
@@ -77,17 +36,7 @@ export function isAgentStatusHooksEnabled(
return settings?.agentStatusHooksEnabled !== false
}
export function installManagedAgentHooks(): void {
for (const [agent, install] of MANAGED_AGENT_HOOK_INSTALLERS) {
try {
install()
} catch (error) {
console.warn(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
}
}
}
function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallStatus {
function errorStatus(agent: AgentHookTarget, error: unknown): AgentHookInstallStatus {
return {
agent,
state: 'error',
@@ -97,8 +46,110 @@ function errorStatus(agent: HookInstallAgent, error: unknown): AgentHookInstallS
}
}
export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_REMOVERS.map(([agent, remove]) => {
function skippedStatus(
agent: AgentHookTarget,
skipReason: NonNullable<AgentHookInstallStatus['skipReason']>,
detail: string
): AgentHookInstallStatus {
return {
agent,
state: 'skipped',
configPath: '',
managedHooksPresent: false,
detail,
skipReason
}
}
function selectedInstallers(options: InstallOptions): readonly ManagedAgentHookInstaller[] {
if (!options.agents) {
return MANAGED_AGENT_HOOK_INSTALLERS
}
const allowed = new Set(options.agents)
return MANAGED_AGENT_HOOK_INSTALLERS.filter(([agent]) => allowed.has(agent))
}
function runInstaller(
entry: ManagedAgentHookInstaller,
onInstallError: InstallOptions['onInstallError']
): AgentHookInstallStatus {
const [agent, install] = entry
try {
return install()
} catch (error) {
console.error(`[agent-hooks] Failed to install ${agent} managed hooks:`, error)
try {
onInstallError?.(agent, error)
} catch (telemetryError) {
console.error('[agent-hooks] Failed to record install-failure telemetry:', telemetryError)
}
return errorStatus(agent, error)
}
}
export async function installManagedAgentHooks(
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
const installers = selectedInstallers(options)
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent))
const targets = enabledInstallers.flatMap(([agent]) => {
const target = getManagedAgentHookTarget(agent)
return target ? [target] : []
})
let presenceByAgent
try {
presenceByAgent = await detectLocalManagedAgentCliPresence(targets, settings, {
shouldHydrateShellPath: options.shouldHydrateShellPath
})
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return installers.map(([agent]) =>
disabled.has(agent)
? skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.')
: skippedStatus(agent, 'cli_presence_unknown', detail)
)
}
const results: AgentHookInstallStatus[] = []
for (const entry of installers) {
const [agent] = entry
if (disabled.has(agent)) {
results.push(skippedStatus(agent, 'agent_disabled', 'Agent is disabled in Settings.'))
continue
}
if (options.shouldContinue && !options.shouldContinue(agent)) {
results.push(
skippedStatus(
agent,
'hooks_disabled',
'Agent status hooks were disabled before install completed.'
)
)
continue
}
const presence = presenceByAgent[agent]
if (presence?.state !== 'found') {
results.push(
skippedStatus(
agent,
presence?.state === 'unknown' ? 'cli_presence_unknown' : 'cli_not_found',
'CLI not found; managed hook install skipped.'
)
)
continue
}
results.push(runInstaller(entry, options.onInstallError))
}
return results
}
export function removeManagedAgentHooks(options: RemoveOptions = {}): AgentHookInstallStatus[] {
const allowed = options.agents ? new Set(options.agents) : null
return MANAGED_AGENT_HOOK_REMOVERS.filter(
([agent]) => allowed === null || allowed.has(agent)
).map(([agent, remove]) => {
try {
return remove()
} catch (error) {
@@ -108,7 +159,7 @@ export function removeManagedAgentHooks(): AgentHookInstallStatus[] {
}
export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
return LOCAL_MANAGED_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
return MANAGED_AGENT_HOOK_STATUS_READERS.map(([agent, getStatus]) => {
try {
return getStatus()
} catch (error) {
@@ -117,10 +168,26 @@ export function getManagedAgentHookStatuses(): AgentHookInstallStatus[] {
})
}
export function applyAgentStatusHooksEnabled(enabled: boolean): AgentHookInstallStatus[] {
if (enabled) {
installManagedAgentHooks()
return getManagedAgentHookStatuses()
export async function applyAgentStatusHooksEnabled(
enabled: boolean,
settings: ManagedHookSettings = null,
options: InstallOptions = {}
): Promise<AgentHookInstallStatus[]> {
if (!enabled) {
return removeManagedAgentHooks()
}
return removeManagedAgentHooks()
const disabled = normalizeDisabledTuiAgents(settings?.disabledTuiAgents).filter(
isManagedAgentHookTarget
)
const installed = await installManagedAgentHooks(settings, options)
const disabledToRemove = options.shouldContinue
? disabled.filter((agent) => !options.shouldContinue?.(agent))
: disabled
if (disabledToRemove.length === 0) {
return installed
}
const removed = new Map(
removeManagedAgentHooks({ agents: disabledToRemove }).map((status) => [status.agent, status])
)
return installed.map((status) => removed.get(status.agent) ?? status)
}
@@ -0,0 +1,71 @@
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { HookInstallAgent } from '../../shared/telemetry-events'
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { devinHookService } from '../devin/hook-service'
import { droidHookService } from '../droid/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] = [
['claude', () => claudeHookService.install()],
['openclaude', () => openClaudeHookService.install()],
['codex', () => codexHookService.install()],
['gemini', () => geminiHookService.install()],
['antigravity', () => antigravityHookService.install()],
['amp', () => ampHookService.install()],
['cursor', () => cursorHookService.install()],
['droid', () => droidHookService.install()],
['command-code', () => commandCodeHookService.install()],
['grok', () => grokHookService.install()],
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['claude', () => claudeHookService.remove()],
['openclaude', () => openClaudeHookService.remove()],
['codex', () => codexHookService.remove()],
['gemini', () => geminiHookService.remove()],
['antigravity', () => antigravityHookService.remove()],
['amp', () => ampHookService.remove()],
['cursor', () => cursorHookService.remove()],
['droid', () => droidHookService.remove()],
['command-code', () => commandCodeHookService.remove()],
['grok', () => grokHookService.remove()],
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()]
]
export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
['claude', () => claudeHookService.getStatus()],
['openclaude', () => openClaudeHookService.getStatus()],
['codex', () => codexHookService.getStatus()],
['gemini', () => geminiHookService.getStatus()],
['antigravity', () => antigravityHookService.getStatus()],
['amp', () => ampHookService.getStatus()],
['cursor', () => cursorHookService.getStatus()],
['droid', () => droidHookService.getStatus()],
['grok', () => grokHookService.getStatus()],
['command-code', () => commandCodeHookService.getStatus()],
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()]
]
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents
} from './managed-hook-detection-commands'
describe('managed hook detection commands', () => {
it('omits disabled agents and includes safe command overrides', () => {
const commands = buildManagedHookDetectionCommands(
{
disabledTuiAgents: ['claude'],
agentCmdOverrides: { codex: '/opt/codex custom' }
},
'linux'
)
expect(commands.some((command) => command.id === 'claude')).toBe(false)
expect(commands).toContainEqual({ id: 'codex', cmd: '/opt/codex' })
})
it('maps detected TUI ids back to managed hook targets', () => {
expect(detectedManagedHookAgents(['codex', 'opencode', 'droid'])).toEqual(['codex', 'droid'])
})
})
@@ -0,0 +1,42 @@
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import {
extractExecutableToken,
isSafeOverrideExecutableToken
} from '../../shared/managed-agent-command-token'
import { MANAGED_AGENT_HOOK_TARGETS } from '../../shared/managed-agent-hook-targets'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/types'
import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands'
export type ManagedHookDetectionSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
> | null
export function buildManagedHookDetectionCommands(
settings: ManagedHookDetectionSettings,
platform: NodeJS.Platform
): TuiAgentDetectionCommand[] {
const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => !disabled.has(target.tuiAgent)).flatMap(
(target) => {
const commands = new Set(target.executableCandidates)
const override = extractExecutableToken(settings?.agentCmdOverrides?.[target.tuiAgent], {
platform
})
if (override && isSafeOverrideExecutableToken(override)) {
commands.add(override)
}
return [...commands].map((cmd) => ({ id: target.tuiAgent, cmd }))
}
)
}
export function detectedManagedHookAgents(values: unknown): AgentHookTarget[] {
if (!Array.isArray(values)) {
return []
}
const detected = new Set(values.filter((value): value is string => typeof value === 'string'))
return MANAGED_AGENT_HOOK_TARGETS.filter((target) => detected.has(target.tuiAgent)).map(
(target) => target.agent
)
}
@@ -6,9 +6,11 @@ import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
const tempHomes: string[] = []
const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'
async function createTempHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'orca-managed-hooks-'))
// Why: mkdir-p probes ancestors; macOS's per-user temp directory can contain hundreds of thousands of entries.
const home = await mkdtemp(join(tempRoot, 'orca-managed-hooks-'))
tempHomes.push(home)
return home
}
+4 -1
View File
@@ -3,6 +3,7 @@ import { basename } from 'node:path'
import { homedir, userInfo } from 'node:os'
import { promisify } from 'node:util'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import type { AgentHookTarget } from '../../shared/agent-hook-types'
import { createManagedHookLocalFilesystem } from './managed-hook-local-filesystem'
import { withManagedHookInstallLock } from './managed-hook-install-lock'
import {
@@ -75,6 +76,7 @@ export async function resolveRelayGrokHome(home: string, signal?: AbortSignal):
export async function installManagedHooks(options?: {
signal?: AbortSignal
hostKeyFingerprint?: string
agents?: readonly AgentHookTarget[]
}): Promise<ManagedHookInstallSummary> {
options?.signal?.throwIfAborted()
const home = homedir()
@@ -93,7 +95,8 @@ export async function installManagedHooks(options?: {
home,
{
grokHomeDir,
signal: options?.signal
signal: options?.signal,
agents: options?.agents
}
)
return {
@@ -713,6 +713,20 @@ describe('remote hook service installers', () => {
expect(byAgent.get('copilot')).toBe('installed')
})
it('installs only positively detected remote agents', async () => {
const { sftp, fs } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
agents: ['codex']
})
expect(results.map((result) => result.agent)).toEqual(['codex'])
const paths = [...fs.files.keys(), ...fs.dirs]
for (const unusedHome of ['.factory', '.gemini', '.grok', '.hermes', '.commandcode']) {
expect(paths.some((path) => path.includes(`/home/dev/${unusedHome}`))).toBe(false)
}
})
it('stops before the next installer when its relay request is cancelled', async () => {
const controller = new AbortController()
const claudeInstall = vi
@@ -1,5 +1,5 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import type { AgentHookInstallStatus, AgentHookTarget } from '../../shared/agent-hook-types'
import { ampHookService } from '../amp/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
@@ -26,6 +26,8 @@ export type RemoteManagedHookInstallOptions = {
/** Stops before starting the next installer when the owning relay request
* is cancelled. Individual filesystem mutations remain atomic. */
signal?: AbortSignal
/** Positively detected and enabled agents allowed to mutate config. */
agents?: readonly AgentHookTarget[]
}
type RemoteManagedHookInstaller = readonly [
@@ -80,7 +82,11 @@ export async function installRemoteManagedAgentHooks(
options?: RemoteManagedHookInstallOptions
): Promise<AgentHookInstallStatus[]> {
const results: AgentHookInstallStatus[] = []
const allowedAgents = options?.agents ? new Set(options.agents) : null
for (const [agent, install] of REMOTE_MANAGED_HOOK_INSTALLERS) {
if (allowedAgents && !allowedAgents.has(agent)) {
continue
}
// Why: relay requests can disappear during reconnect; do not start more
// user-config mutations after their client has gone away.
options?.signal?.throwIfAborted()
+26 -2
View File
@@ -6,6 +6,11 @@
import type { SFTPWrapper } from 'ssh2'
import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import {
buildManagedHookDetectionCommands,
detectedManagedHookAgents,
type ManagedHookDetectionSettings
} from './managed-hook-detection-commands'
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
import { wslCodexRuntimeHomeForGuestHome } from '../pty/codex-home-wsl-env'
import { WSL_HOOK_FS_METHODS, type WslFsResult } from '../../shared/wsl-hook-relay-contract'
@@ -18,11 +23,30 @@ export async function installWslGuestHooks(options: {
guestHome: string
distro: string
installHooks: typeof installRemoteManagedAgentHooks
settings: ManagedHookDetectionSettings
warn: (message: string) => void
}): Promise<void> {
const { mux, guestHome, distro, installHooks, warn } = options
const { mux, guestHome, distro, installHooks, settings, warn } = options
let agents
try {
const detected = (await mux.request('preflight.detectAgents', {
commands: buildManagedHookDetectionCommands(settings, 'linux')
})) as { agents?: unknown }
agents = detectedManagedHookAgents(detected?.agents)
} catch (error) {
warn(
`[agent-hooks] WSL agent detection for '${distro}' failed: ${
error instanceof Error ? error.message : String(error)
}`
)
return
}
if (agents.length === 0) {
return
}
const results = await installHooks(createWslHookSftpAdapter(mux), guestHome, {
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome)
codexHomeDir: wslCodexRuntimeHomeForGuestHome(guestHome),
agents
})
const failed = results.filter((r) => r.state === 'error').length
if (failed > 0) {
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { agentHookServer } from './server'
import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands'
import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers'
import { getOpenCodePluginSource } from '../opencode/hook-service'
import type { PluginSources } from '../../relay/plugin-overlay'
@@ -59,6 +60,7 @@ export type WslHookRelayManagerDeps = {
waitForSentinel: typeof waitForWslRelaySentinel
ingest: (envelope: Record<string, unknown>, connectionId: string) => void
installHooks: typeof installRemoteManagedAgentHooks
managedHookSettings: () => ManagedHookDetectionSettings
/** Plugin source strings shipped to the guest relay so an Orca update needn't redeploy the relay bundle. */
pluginSources: () => PluginSources
warn: (message: string) => void
@@ -89,6 +91,7 @@ export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = {
connectionId
),
installHooks: installRemoteManagedAgentHooks,
managedHookSettings: () => null,
// Why: only OpenCode is in scope for WSL now; the payload shape stays identical to SSH so Pi/OMP are additive later.
pluginSources: () => ({ opencodePluginSource: getOpenCodePluginSource() }),
warn: (message) => console.warn(message),
@@ -7,7 +7,6 @@
// exercises minus the wsl.exe byte transport (validated separately on-rig).
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
@@ -59,7 +58,7 @@ describe.skipIf(process.platform === 'win32')(
})
it('delivers a Claude hook POST from the live relay into ingestRemote and installs guest hooks', async () => {
fakeHome = mkdtempSync(join(tmpdir(), 'wsl-live-home-'))
fakeHome = mkdtempSync(join('/tmp', 'wsl-live-home-'))
const preferredPort = await pickFreePort()
const version = readFileSync(join(BUNDLE_DIR, '.version'), 'utf8').trim()
@@ -102,6 +101,12 @@ describe.skipIf(process.platform === 'win32')(
envelope as Parameters<AgentHookServer['ingestRemote']>[0],
connectionId
),
managedHookSettings: () => ({
agentCmdOverrides: {
claude: process.execPath,
codex: process.execPath
}
}),
warn: (message) => warns.push(message),
transientRetryDelayMs: 1
})
@@ -3,7 +3,6 @@
// the per-distro relay manager state machine with fault injection.
import { EventEmitter } from 'node:events'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -34,9 +33,13 @@ function createGuestHarness(): GuestHarness {
const clientDataCallbacks: ((data: Buffer) => void)[] = []
const closeCallbacks: (() => void)[] = []
const transport: MultiplexerTransport = {
write: (data) => {
setImmediate(() => relayFeed?.(data))
write: (data, onSettled) => {
setImmediate(() => {
relayFeed?.(data)
onSettled?.({ ok: true })
})
},
supportsWriteSettlement: true,
onData: (cb) => {
clientDataCallbacks.push(cb)
},
@@ -44,21 +47,24 @@ function createGuestHarness(): GuestHarness {
closeCallbacks.push(cb)
}
}
const guestDispatcher = new RelayDispatcher((data: Buffer) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
})
})
const guestDispatcher = new RelayDispatcher(
(data: Buffer, onSettled) => {
setImmediate(() => {
for (const cb of clientDataCallbacks) {
cb(data)
}
onSettled({ ok: true })
})
},
{ supportsWriteCallback: true }
)
relayFeed = (data) => guestDispatcher.feed(data)
const mux = new SshChannelMultiplexer(transport)
return { transport, guestDispatcher, mux }
}
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only by
// design (posix.resolve). On a Windows dev host tmpdir() yields C:\ paths the
// bridge correctly refuses; Windows coverage comes from the live rig runs.
// Why skipIf: the fs bridge runs inside the Linux guest and is POSIX-only;
// Windows coverage comes from the live rig runs.
describe.skipIf(process.platform === 'win32')(
'createWslHookSftpAdapter over the guest fs bridge',
() => {
@@ -66,7 +72,7 @@ describe.skipIf(process.platform === 'win32')(
let harness: GuestHarness
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'wsl-guest-home-'))
home = mkdtempSync(join('/tmp', 'wsl-guest-home-'))
harness = createGuestHarness()
registerWslHookFsHandlers(harness.guestDispatcher, home)
})
@@ -166,13 +172,19 @@ describe('WslHookRelayManager', () => {
return child as unknown as ChildProcessWithoutNullStreams & { emitClose: () => void }
}
function guestTransport(registerInstallPlugins = true): MultiplexerTransport {
function guestTransport(
options: { registerInstallPlugins?: boolean; detectedAgents?: string[] } = {}
): MultiplexerTransport {
const { registerInstallPlugins = true, detectedAgents = ['codex'] } = options
const harness = createGuestHarness()
harnesses.push(harness)
registerWslHookFsHandlers(harness.guestDispatcher, home)
harness.guestDispatcher.onRequest(AGENT_HOOK_REQUEST_REPLAY_METHOD, async () => ({
replayed: 0
}))
harness.guestDispatcher.onRequest('preflight.detectAgents', async () => ({
agents: detectedAgents
}))
// A guest bundle predating the plugin overlay omits this handler (-32601).
if (registerInstallPlugins) {
harness.guestDispatcher.onRequest(AGENT_HOOK_INSTALL_PLUGINS_METHOD, async () => ({
@@ -212,6 +224,7 @@ describe('WslHookRelayManager', () => {
waitForSentinel: vi.fn(async () => guestTransport()),
ingest: vi.fn(),
installHooks: vi.fn(async () => []),
managedHookSettings: () => null,
pluginSources: () => ({ opencodePluginSource: '// opencode plugin source' }),
warn: vi.fn(),
transientRetryDelayMs: 1,
@@ -228,7 +241,8 @@ describe('WslHookRelayManager', () => {
expect(deps.spawnRelay).toHaveBeenCalledTimes(1)
// Codex is the one agent whose home Orca redirects for WSL sessions.
expect(deps.installHooks).toHaveBeenCalledWith(expect.anything(), home, {
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`
codexHomeDir: `${home}/.local/share/orca/codex-runtime-home/home`,
agents: ['codex']
})
expect(manager.getGuestEndpointFilePath('Ubuntu')).toBe(
@@ -261,7 +275,7 @@ describe('WslHookRelayManager', () => {
})
it('leaves the overlay dir null when the guest bundle lacks the installPlugins handler', async () => {
const waitForSentinel = vi.fn(async () => guestTransport(false))
const waitForSentinel = vi.fn(async () => guestTransport({ registerInstallPlugins: false }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
// Connect still completes (hooks install); the -32601 is swallowed silently.
@@ -272,6 +286,17 @@ describe('WslHookRelayManager', () => {
manager.disposeAll()
})
it('does not mutate managed agent homes when no WSL agents are detected', async () => {
const waitForSentinel = vi.fn(async () => guestTransport({ detectedAgents: [] }))
const { manager, deps } = createManager({ waitForSentinel })
manager.ensureForDistro('Ubuntu')
await vi.waitFor(() => expect(manager.getOpenCodeOverlayDir('Ubuntu')).toBe(opencodeOverlayDir))
expect(deps.installHooks).not.toHaveBeenCalled()
manager.disposeAll()
})
it('resolves the default distro for null and dedupes it with the explicit name', async () => {
const { manager, deps } = createManager({})
manager.ensureForDistro(null)
+10 -8
View File
@@ -18,6 +18,7 @@ import {
} from './wsl-hook-relay-deps'
import { wireWslRelayLink } from './wsl-hook-relay-link'
import { WslRelayRecovery } from './wsl-hook-relay-recovery'
import { wslHookRelayStateKey } from './wsl-hook-relay-state-key'
import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install'
import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer'
import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay'
@@ -44,10 +45,6 @@ type DistroState = {
lastInstallAt?: number
}
function distroKey(distro: string): string {
return distro.trim().toLowerCase()
}
export class WslHookRelayManager {
private deps: WslHookRelayManagerDeps
private recovery: WslRelayRecovery
@@ -62,12 +59,12 @@ export class WslHookRelayManager {
isDistroRunning: (distro) => this.deps.isDistroRunning(distro),
warn: (message) => this.deps.warn(message),
isDisposed: () => this.disposed,
isCurrent: (state) => this.states.get(distroKey(state.distro)) === state,
isCurrent: (state) => this.states.get(wslHookRelayStateKey(state.distro)) === state,
restart: (distro) => this.ensureForDistro(distro),
dropState: (state) => {
// Why: identity-guarded — a fresh ensure() may own this key by now;
// deleting by key alone would orphan its live relay child.
const key = distroKey(state.distro)
const key = wslHookRelayStateKey(state.distro)
if (this.states.get(key) === state) {
this.states.delete(key)
}
@@ -75,6 +72,10 @@ export class WslHookRelayManager {
})
}
setManagedHookSettingsResolver(resolve: WslHookRelayManagerDeps['managedHookSettings']): void {
this.deps.managedHookSettings = resolve
}
/** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */
ensureForDistro(distro: string | null): void {
if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) {
@@ -89,7 +90,7 @@ export class WslHookRelayManager {
private stateFor(distro: string | null): DistroState | undefined {
// Empty key never matches a real (non-empty) distro state.
return this.states.get(distroKey(distro ?? this.defaultDistro ?? ''))
return this.states.get(wslHookRelayStateKey(distro ?? this.defaultDistro ?? ''))
}
/** Guest endpoint file path once known; null before first connect
@@ -120,7 +121,7 @@ export class WslHookRelayManager {
if (!distro || this.disposed) {
return
}
const key = distroKey(distro)
const key = wslHookRelayStateKey(distro)
const existing = this.states.get(key)
if (existing) {
if (existing.phase === 'running') {
@@ -280,6 +281,7 @@ export class WslHookRelayManager {
guestHome,
distro: state.distro,
installHooks: this.deps.installHooks,
settings: this.deps.managedHookSettings(),
warn: this.deps.warn
})
// Why: ship OpenCode's status plugin and record the guest overlay dir the
@@ -0,0 +1,3 @@
export function wslHookRelayStateKey(distro: string): string {
return distro.trim().toLowerCase()
}
@@ -0,0 +1,192 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const processMocks = vi.hoisted(() => ({
spawn: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: processMocks.spawn
}))
vi.mock('electron', () => ({
app: { getPath: () => 'C:\\orca-review-11407' }
}))
vi.mock('../codex-cli/command', () => ({
resolveClaudeCommand: () => 'claude.exe'
}))
vi.mock('./keychain', () => ({
deleteActiveClaudeKeychainCredentialsStrict: vi.fn(),
deleteManagedClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentials: vi.fn(),
readActiveClaudeKeychainCredentialsStrict: vi.fn(),
readManagedClaudeKeychainCredentials: vi.fn(),
writeActiveClaudeKeychainCredentials: vi.fn(),
writeManagedClaudeKeychainCredentials: vi.fn()
}))
type CommandConfig = {
windowsPath: string
linuxPath: string | null
wslDistro: string | null
}
type FakeChild = EventEmitter & {
stdin: PassThrough
stdout: PassThrough
stderr: PassThrough
kill: ReturnType<typeof vi.fn>
pid: number
}
type CommandRunner = {
runClaudeCommand(
args: string[],
config: CommandConfig,
timeoutMs: number,
options?: { keepStdinOpen?: boolean; signal?: AbortSignal }
): Promise<string>
}
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
}
function createChild(): FakeChild {
const child = new EventEmitter() as FakeChild
child.stdin = new PassThrough()
child.stdout = new PassThrough()
child.stderr = new PassThrough()
child.kill = vi.fn()
child.pid = 11407
return child
}
async function createRunner(): Promise<CommandRunner> {
const { ClaudeAccountService } = await import('./service')
return new ClaudeAccountService({} as never, {} as never, {} as never) as unknown as CommandRunner
}
async function flushPromiseCallbacks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('native Windows Claude login completion oracle', () => {
beforeEach(() => {
vi.useFakeTimers()
processMocks.spawn.mockReset()
})
afterEach(() => {
vi.useRealTimers()
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform)
}
})
it('settles exact native Windows auth login on exit exactly once and cleans resources', async () => {
setPlatform('win32')
const child = createChild()
const destroyStdin = vi.spyOn(child.stdin, 'destroy')
const destroyStdout = vi.spyOn(child.stdout, 'destroy')
const destroyStderr = vi.spyOn(child.stderr, 'destroy')
const abortController = new AbortController()
const removeAbortListener = vi.spyOn(abortController.signal, 'removeEventListener')
processMocks.spawn.mockReturnValue(child)
const runner = await createRunner()
let completions = 0
const command = runner
.runClaudeCommand(
['auth', 'login', '--claudeai'],
{ windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null },
1000,
{ keepStdinOpen: true, signal: abortController.signal }
)
.then(() => {
completions += 1
})
child.emit('exit', 0)
await flushPromiseCallbacks()
try {
expect(completions).toBe(1)
expect(child.listenerCount('error')).toBe(0)
expect(child.listenerCount('exit')).toBe(0)
expect(child.listenerCount('close')).toBe(0)
expect(child.stdout.listenerCount('data')).toBe(0)
expect(child.stderr.listenerCount('data')).toBe(0)
expect(destroyStdin).toHaveBeenCalledTimes(1)
expect(destroyStdout).toHaveBeenCalledTimes(1)
expect(destroyStderr).toHaveBeenCalledTimes(1)
expect(removeAbortListener).toHaveBeenCalledTimes(1)
child.emit('close', 0)
await vi.advanceTimersByTimeAsync(1001)
expect(completions).toBe(1)
expect(child.kill).not.toHaveBeenCalled()
expect(destroyStdin).toHaveBeenCalledTimes(1)
expect(destroyStdout).toHaveBeenCalledTimes(1)
expect(destroyStderr).toHaveBeenCalledTimes(1)
expect(removeAbortListener).toHaveBeenCalledTimes(1)
} finally {
child.emit('close', 0)
await command
}
})
it.each([
{
name: 'non-Windows auth login',
platform: 'linux' as const,
args: ['auth', 'login', '--claudeai'],
config: { windowsPath: '/isolated-auth', linuxPath: null, wslDistro: null }
},
{
name: 'unrelated native Windows command containing login',
platform: 'win32' as const,
args: ['config', 'login'],
config: { windowsPath: 'C:\\isolated-auth', linuxPath: null, wslDistro: null }
},
{
name: 'WSL auth login',
platform: 'win32' as const,
args: ['auth', 'login', '--claudeai'],
config: {
windowsPath: 'C:\\isolated-auth',
linuxPath: '/home/orca/isolated-auth',
wslDistro: 'Ubuntu-Oracle'
}
}
])('preserves close completion for $name', async ({ platform, args, config }) => {
setPlatform(platform)
const child = createChild()
processMocks.spawn.mockReturnValue(child)
const runner = await createRunner()
let completions = 0
const command = runner.runClaudeCommand(args, config, 1000).then(() => {
completions += 1
})
child.emit('exit', 0)
await flushPromiseCallbacks()
expect(completions).toBe(0)
expect(child.listenerCount('close')).toBe(1)
child.emit('close', 0)
await command
expect(completions).toBe(1)
expect(child.listenerCount('close')).toBe(0)
expect(child.kill).not.toHaveBeenCalled()
})
})
+16 -3
View File
@@ -967,6 +967,13 @@ export class ClaudeAccountService {
rejectPromise(new Error('Claude command failed to open output streams.'))
return
}
const completesOnExit =
process.platform === 'win32' &&
configDir.linuxPath === null &&
configDir.wslDistro === null &&
args[0] === 'auth' &&
args[1] === 'login'
const completionEvent = completesOnExit ? 'exit' : 'close'
let settled = false
let output = ''
@@ -990,11 +997,15 @@ export class ClaudeAccountService {
stdout.off('data', appendOutput)
stderr.off('data', appendOutput)
child.off('error', onError)
child.off('close', onClose)
child.off(completionEvent, onDone)
options?.signal?.removeEventListener('abort', onAbort)
if (options?.keepStdinOpen) {
child.stdin?.destroy()
}
if (completesOnExit) {
stdout.destroy()
stderr.destroy()
}
}
const settle = (callback: () => void): void => {
if (settled) {
@@ -1062,7 +1073,7 @@ export class ClaudeAccountService {
}
settle(() => rejectPromise(error))
}
const onClose = (code: number | null): void => {
const onDone = (code: number | null): void => {
if (terminationPending) {
return
}
@@ -1085,7 +1096,9 @@ export class ClaudeAccountService {
stdout.on('data', appendOutput)
stderr.on('data', appendOutput)
child.on('error', onError)
child.on('close', onClose)
// Native Windows browsers can inherit these pipes and indefinitely delay close.
child.on(completionEvent, onDone)
if (options?.signal?.aborted) {
onAbort()
} else {
+164 -5
View File
@@ -1,17 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises 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 { ClaudeUsagePersistedState } from './types'
import type * as Scanner from './scanner'
vi.mock('electron', () => ({
app: {
getPath: vi.fn(() => '/tmp/orca-test-userdata')
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
}))
import { ClaudeUsageStore } from './store'
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
vi.mock('./scanner', async (importOriginal) => ({
...(await importOriginal<typeof Scanner>()),
scanClaudeUsageFiles: vi.fn()
}))
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { scanClaudeUsageFiles } from './scanner'
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore({
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
@@ -34,11 +80,32 @@ function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): Claude
}
describe('ClaudeUsageStore', () => {
let tempUserData: string
beforeEach(() => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initClaudeUsagePath()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanClaudeUsageFiles).mockReset()
vi.mocked(scanClaudeUsageFiles).mockResolvedValue({
processedFiles: [],
sessions: [],
dailyAggregates: []
})
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000-04:00'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(tempUserData, { recursive: true, force: true })
})
it('reports no data for Orca scope when only non-Orca usage exists', async () => {
const store = createStoreWithState({
sessions: [
@@ -549,4 +616,96 @@ describe('ClaudeUsageStore', () => {
expect(refreshMock).toHaveBeenCalledWith(false)
})
it('persists setEnabled via async durable write without leaving tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.setEnabled(true)
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persisted = JSON.parse(
readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')
)
expect(persisted.scanState.enabled).toBe(true)
// Pretty-print preserved for human inspection of the analytics cache.
expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n')
})
it('vetoes a stale concurrent async write so the newer snapshot wins', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: ClaudeUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('persists a successful refresh with one full-cache write', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
await store.refresh(true)
// Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState
).toMatchObject({
lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(),
lastScanError: null
})
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
})
})
+23 -18
View File
@@ -1,7 +1,8 @@
/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */
import { app } from 'electron'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -331,6 +332,9 @@ export class ClaudeUsageStore {
private state: ClaudeUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile)
constructor(store: Store) {
this.store = store
@@ -375,23 +379,19 @@ export class ClaudeUsageStore {
}
}
private writeToDisk(): void {
const usageFile = getClaudeUsageFile()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
// Why: scans can refresh while the app is in active use. Use the same
// atomic temp-file pattern as the main store so a crash or concurrent write
// cannot leave a truncated analytics file as the common failure mode.
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
renameSync(tmpFile, usageFile)
private writeToDisk(): Promise<void> {
// Pretty-print preserved: humans inspect this analytics cache on disk.
return this.writer.write(() => JSON.stringify(this.state, null, 2))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
this.state.scanState.enabled = enabled
this.writeToDisk()
await this.writeToDisk()
return this.getScanState()
}
@@ -441,8 +441,11 @@ export class ClaudeUsageStore {
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
// Why: assign scanPromise before any await so concurrent refresh shares one scan.
this.scanPromise = (async () => {
try {
const repos = this.store.getRepos()
@@ -458,10 +461,12 @@ export class ClaudeUsageStore {
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
this.writeToDisk()
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
+84 -12
View File
@@ -1,9 +1,9 @@
/* eslint-disable max-lines */
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises 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 * as Fs from 'node:fs'
import type {
CodexUsageDailyAggregate,
CodexUsagePersistedFile,
@@ -11,8 +11,15 @@ import type {
CodexUsageSession
} from './types'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata'),
// Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting
// every open would hide a regression back to multiple full-cache rewrites per scan.
writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 },
writeGate: {
blocked: false,
waiters: [] as (() => void)[]
}
}))
vi.mock('electron', () => ({
@@ -21,11 +28,26 @@ vi.mock('electron', () => ({
}
}))
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof Fs>('fs')
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
writeFileSync: vi.fn(actual.writeFileSync)
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] !== 'w') {
return actual.open(...args)
}
writeOpens.value += 1
writeOpens.inFlight += 1
writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight)
try {
if (writeGate.blocked) {
await new Promise<void>((resolve) => writeGate.waiters.push(resolve))
}
return await actual.open(...args)
} finally {
writeOpens.inFlight -= 1
}
}) as typeof actual.open
}
})
@@ -97,7 +119,11 @@ describe('CodexUsageStore', () => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initCodexUsagePath()
vi.mocked(writeFileSync).mockClear()
writeOpens.value = 0
writeOpens.inFlight = 0
writeOpens.maxConcurrent = 0
writeGate.blocked = false
writeGate.waiters = []
vi.mocked(scanCodexUsageFiles).mockReset()
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
vi.useFakeTimers()
@@ -109,7 +135,7 @@ describe('CodexUsageStore', () => {
rmSync(tempUserData, { recursive: true, force: true })
})
it('persists a successful refresh with one compact disk write', async () => {
it('persists a successful refresh with one compact async disk write', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
@@ -122,7 +148,9 @@ describe('CodexUsageStore', () => {
await store.refresh(true)
expect(writeFileSync).toHaveBeenCalledTimes(1)
// Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards.
expect(writeOpens.value).toBe(1)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
expect(persistedJson).not.toContain('\n')
@@ -155,13 +183,57 @@ describe('CodexUsageStore', () => {
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
lastScanError: null
})
expect(writeFileSync).not.toHaveBeenCalled()
expect(writeOpens.value).toBe(0)
pendingScan.resolve(createEmptyScanResult())
await refreshPromise
expect(store.getScanState().isScanning).toBe(false)
expect(writeFileSync).toHaveBeenCalledTimes(1)
expect(writeOpens.value).toBe(1)
})
it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => {
const store = createStoreWithState({
schemaVersion: 5,
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
const internals = store as unknown as {
writeToDisk: () => Promise<void>
state: CodexUsagePersistedState
}
writeGate.blocked = true
const first = internals.writeToDisk()
await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1))
internals.state.scanState.enabled = false
writeGate.blocked = false
const second = internals.writeToDisk()
writeGate.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(
JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState
.enabled
).toBe(false)
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
// Serialized, so the superseded write can be skipped safely rather than racing the newer one.
expect(writeOpens.maxConcurrent).toBe(1)
})
it('sweeps a usage temp file orphaned by a crash between write and rename', async () => {
const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp')
writeFileSync(orphan, '{}')
createStoreWithState({})
await vi.waitFor(() =>
expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0)
)
})
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
+21 -15
View File
@@ -1,7 +1,8 @@
/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */
import { app } from 'electron'
import { dirname, join } from 'node:path'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@@ -366,6 +367,9 @@ export class CodexUsageStore {
private state: CodexUsagePersistedState
private readonly store: Store
private scanPromise: Promise<void> | null = null
// Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes
// and vetoes superseded renames.
private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile)
constructor(store: Store) {
this.store = store
@@ -393,20 +397,19 @@ export class CodexUsageStore {
}
}
private writeToDisk(): void {
const usageFile = getCodexUsageFile()
const dir = dirname(usageFile)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
renameSync(tmpFile, usageFile)
private writeToDisk(): Promise<void> {
// Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan.
return this.writer.write(() => JSON.stringify(this.state))
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
this.state.scanState.enabled = enabled
this.writeToDisk()
await this.writeToDisk()
return this.getScanState()
}
@@ -456,7 +459,8 @@ export class CodexUsageStore {
this.state.scanState.lastScanStartedAt = Date.now()
this.state.scanState.lastScanError = null
// Why: start-only writes rewrite the full usage cache before scan results change.
// Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single
// result changed. The completion/failure write below persists the same fields.
this.scanPromise = (async () => {
try {
@@ -473,10 +477,12 @@ export class CodexUsageStore {
this.state.worktreeFingerprint = worktreeFingerprint
this.state.scanState.lastScanCompletedAt = Date.now()
this.state.scanState.lastScanError = null
this.writeToDisk()
// Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into
// a scan error and reject refresh() for every query caller; writeToDisk already logs it.
await this.writeToDisk().catch(() => {})
} catch (error) {
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
this.writeToDisk()
await this.writeToDisk().catch(() => {})
} finally {
this.scanPromise = null
}
@@ -1,4 +1,5 @@
import {
computerUseClickModifiersValidationMessage,
computerUseHotkeyValidationMessage,
computerUsePressKeyValidationMessage
} from '../../shared/computer-use-key-spec'
@@ -27,6 +28,7 @@ export async function validateComputerProviderActionParams(
validateElementOrCoordinates('Click', params)
validatePositiveInteger(params, 'clickCount')
validateMouseButton(params)
validateClickModifiers(params)
return app
case 'performSecondaryAction':
requireNonNegativeInteger(params, 'elementIndex')
@@ -159,6 +161,17 @@ function validateMouseButton(params: Record<string, unknown>): void {
}
}
function validateClickModifiers(params: Record<string, unknown>): void {
if (params.modifiers === undefined) {
return
}
const modifiers = requireNonEmptyString(params, 'modifiers')
const message = computerUseClickModifiersValidationMessage(modifiers)
if (message) {
throw new RuntimeClientError('invalid_argument', message)
}
}
function validateScrollDirection(params: Record<string, unknown>): void {
const direction = requireNonEmptyString(params, 'direction')
if (direction !== 'up' && direction !== 'down' && direction !== 'left' && direction !== 'right') {
@@ -186,10 +186,18 @@ describe('DesktopScriptProviderClient actions', () => {
ok: true,
capabilities: sampleCapabilities()
})
mockBridgeResponse({
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
})
mockBridgeResponse(
{
ok: true,
snapshot: sampleBridgeSnapshot('Text Editor', 'initial')
},
(operation) => {
expect(operation).toMatchObject({
tool: 'click',
modifiers: 'CmdOrCtrl+Shift'
})
}
)
const client = await createDesktopScriptProviderClient('linux', '/tmp/runtime.py')
@@ -197,6 +205,7 @@ describe('DesktopScriptProviderClient actions', () => {
const result = await client.action('click', {
app: 'Text Editor',
elementIndex: 0,
modifiers: 'CmdOrCtrl+Shift',
noScreenshot: true
})
@@ -112,7 +112,7 @@ export function mapBridgeError(message: string): RuntimeClientError {
return new RuntimeClientError('app_blocked', text)
}
if (
/unsupported capability|hotkey.*require|paste_text requires|GDK is required for non-character key synthesis/i.test(
/unsupported capability|hotkey.*require|paste_text requires|modified clicks require xdotool|GDK is required for non-character key synthesis/i.test(
text
)
) {
@@ -152,6 +152,7 @@ export class DesktopScriptProviderClient {
to_y: optionalNumberParam(params, 'toY'),
click_count: optionalNumberParam(params, 'clickCount'),
mouse_button: optionalStringParam(params, 'mouseButton'),
modifiers: optionalStringParam(params, 'modifiers'),
action: optionalStringParam(params, 'action'),
direction: optionalStringParam(params, 'direction'),
pages: optionalNumberParam(params, 'pages'),
@@ -134,6 +134,7 @@ export type BridgeRequest = {
to_y?: number
click_count?: number
mouse_button?: string
modifiers?: string
action?: string
direction?: string
pages?: number
@@ -278,6 +278,16 @@ describe('MacOSNativeProviderClient', () => {
code: 'invalid_argument',
message: expect.stringContaining('Click requires')
})
await expect(
client.action('click', {
app: 'TextEdit',
elementIndex: 0,
modifiers: 'CmdOrCtrl+A'
})
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Click modifiers accept modifier keys only')
})
await expect(client.action('typeText', { app: 'TextEdit', text: '' })).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('Missing text')
+15
View File
@@ -0,0 +1,15 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
export function shouldHandoffDaemonHistory(
keepHistory: boolean | undefined,
owner: DaemonPtyAdapter,
current: DaemonPtyAdapter
): boolean {
return (
keepHistory === true &&
owner !== current &&
owner.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION &&
current.protocolVersion >= HISTORY_SEED_TRANSFER_PROTOCOL_VERSION
)
}
@@ -0,0 +1,15 @@
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
export type DaemonPtyRouterDataEvent = {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}
export type DaemonPtyRouterExitEvent = {
id: string
code: number
incarnationId?: PtyIncarnationId
}
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonPtyRouter } from './daemon-pty-router'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
import type { DaemonFileLog } from './daemon-file-log'
import type { SubprocessHandle } from './session'
function createMockSubprocess(): SubprocessHandle & {
emitData: (data: string) => void
} {
let onData: ((data: string) => void) | undefined
let onExit: ((code: number) => void) | undefined
return {
pid: 999_999_999,
getForegroundProcess: () => null,
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 0)),
forceKill: vi.fn(() => setTimeout(() => onExit?.(137), 0)),
signal: vi.fn(),
onData(callback) {
onData = callback
},
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
emitData(data: string) {
onData?.(data)
}
}
}
describe('DaemonPtyRouter history handoff', () => {
let testDir: string
let historyPath: string
let legacyServer: DaemonServer
let currentServer: DaemonServer
let legacyAdapter: DaemonPtyAdapter
let currentAdapter: DaemonPtyAdapter
let router: DaemonPtyRouter
let legacySubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
testDir = mkdtempSync(join(tmpdir(), 'daemon-router-history-handoff-'))
historyPath = join(testDir, 'history')
const log: DaemonFileLog = { log: () => {}, close: () => {} }
legacyServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
log,
spawnSubprocess: () => {
legacySubprocess = createMockSubprocess()
return legacySubprocess
}
})
currentServer = new DaemonServer({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
log,
spawnSubprocess: createMockSubprocess
})
await Promise.all([legacyServer.start(), currentServer.start()])
legacyAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 29),
tokenPath: join(testDir, 'legacy.token'),
protocolVersion: 29,
historyPath
})
currentAdapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(testDir, 30),
tokenPath: join(testDir, 'current.token'),
protocolVersion: 30,
historyPath
})
})
afterEach(async () => {
if (router) {
router.dispose()
} else {
legacyAdapter?.dispose()
currentAdapter?.dispose()
}
await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()])
rmSync(testDir, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('moves a slept v29 session to v30 with its full large history', async () => {
const sessionId = 'large-legacy-session'
const marker = 'V29-HISTORY-HANDOFF-MARKER'
await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 })
legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`)
router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] })
await router.discoverLegacySessions()
const client = (
currentAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
).client
const request = vi.spyOn(client, 'request')
await router.shutdown(sessionId, { immediate: true, keepHistory: true })
const restored = await router.spawn({ sessionId, cols: 400, rows: 24 })
expect(restored.coldRestore?.scrollback).toContain(marker)
expect(request.mock.calls.map(([type]) => type)).toEqual(
expect.arrayContaining([
'startHistorySeedTransfer',
'appendHistorySeedTransfer',
'finishHistorySeedTransfer'
])
)
await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({
data: expect.stringContaining(marker)
})
expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true)
})
})
+47
View File
@@ -7,6 +7,7 @@ import {
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION
} from './types'
import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version'
type AdapterMock = DaemonPtyAdapter & {
emitData: (id: string, data: string, sequenceChars?: number) => void
@@ -490,6 +491,52 @@ describe('DaemonPtyRouter', () => {
await expect(router.probePtyLiveness('unknown-session')).resolves.toBeNull()
})
it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => {
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
const legacy = createAdapter(
'legacy',
['legacy-session'],
undefined,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1
)
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
await router.shutdown('legacy-session', { keepHistory: true })
await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
expect(legacy.shutdown).toHaveBeenCalledWith('legacy-session', { keepHistory: true })
expect(legacy.ackColdRestore).toHaveBeenCalledWith('legacy-session')
expect(current.spawn).toHaveBeenCalledWith({
sessionId: 'legacy-session',
cols: 80,
rows: 24
})
expect(legacy.spawn).not.toHaveBeenCalled()
})
it('keeps the legacy route when checkpointed shutdown fails', async () => {
const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION)
const legacy = createAdapter(
'legacy',
['legacy-session'],
undefined,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1
)
vi.mocked(legacy.shutdown).mockRejectedValueOnce(new Error('checkpoint failed'))
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
await expect(router.shutdown('legacy-session', { keepHistory: true })).rejects.toThrow(
'checkpoint failed'
)
await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 })
expect(legacy.spawn).toHaveBeenCalled()
expect(legacy.ackColdRestore).not.toHaveBeenCalled()
expect(current.spawn).not.toHaveBeenCalled()
})
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
const current = createAdapter('current', ['current-session'])
const legacy = createAdapter('legacy', ['legacy-session'])
+16 -34
View File
@@ -8,27 +8,18 @@ import type {
PtySpawnOptions,
PtySpawnResult
} from '../providers/types'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { probePtyOwners } from './daemon-pty-liveness-probe'
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
export class DaemonPtyRouter implements IPtyProvider {
private current: DaemonPtyAdapter
private legacy: DaemonPtyAdapter[]
private sessionAdapters = new Map<string, DaemonPtyAdapter>()
private unsubscribers: (() => void)[] = []
private dataListeners: ((payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void)[] = []
private exitListeners: ((payload: {
id: string
code: number
incarnationId?: PtyIncarnationId
}) => void)[] = []
private dataListeners: ((payload: DaemonPtyRouterDataEvent) => void)[] = []
private exitListeners: ((payload: DaemonPtyRouterExitEvent) => void)[] = []
constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) {
this.current = opts.current
@@ -137,15 +128,16 @@ export class DaemonPtyRouter implements IPtyProvider {
id: string,
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
): Promise<void> {
await this.adapterFor(id).shutdown(id, opts)
// Why: sleep passes keepHistory=true and re-spawns against the same
// sessionId on wake. If we delete the routing entry here, adapterFor()
// falls back to `this.current` on wake — for a session that originally
// lived on a legacy adapter (different protocolVersion), the wake-side
// createOrAttach lands on the wrong adapter and creates a fresh session,
// losing the cold-restore from the legacy adapter's history dir.
if (!opts.keepHistory) {
this.sessionAdapters.delete(id)
const adapter = this.adapterFor(id)
const migrateHistory = shouldHandoffDaemonHistory(opts.keepHistory, adapter, this.current)
await adapter.shutdown(id, opts)
if (!opts.keepHistory || migrateHistory) {
if (migrateHistory) {
adapter.ackColdRestore(id)
}
if (this.sessionAdapters.get(id) === adapter) {
this.sessionAdapters.delete(id)
}
}
}
@@ -229,15 +221,7 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.current.getProfiles()
}
onData(
callback: (payload: {
id: string
data: string
sequenceChars?: number
transformed?: boolean
seq?: number
}) => void
): () => void {
onData(callback: (payload: DaemonPtyRouterDataEvent) => void): () => void {
this.dataListeners.push(callback)
return () => {
const idx = this.dataListeners.indexOf(callback)
@@ -265,9 +249,7 @@ export class DaemonPtyRouter implements IPtyProvider {
return () => {}
}
onExit(
callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void
): () => void {
onExit(callback: (payload: DaemonPtyRouterExitEvent) => void): () => void {
this.exitListeners.push(callback)
return () => {
const idx = this.exitListeners.indexOf(callback)
+45
View File
@@ -4,6 +4,7 @@ import { join } from 'node:path'
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
@@ -11,6 +12,7 @@ import {
writeFileSync
} from 'node:fs'
import { HistoryManager } from './history-manager'
import { flushPendingSessionTreeRemovals } from './terminal-history-session-tombstone'
import type { TerminalSnapshot, TerminalModes } from './types'
import { getHistorySessionDirName } from './history-paths'
import {
@@ -58,6 +60,8 @@ describe('HistoryManager', () => {
afterEach(async () => {
await mgr.dispose()
// Detached tombstone reclaims outlive the test that queued them; settle before the fixture goes.
await flushPendingSessionTreeRemovals()
rmSync(dir, { recursive: true, force: true })
})
@@ -486,4 +490,45 @@ describe('HistoryManager', () => {
}
)
})
describe('large session cleanup responsiveness', () => {
it('tombstones a large session tree instead of walking it on the teardown path', async () => {
const sessionId = 'bulky'
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
const sessionDir = join(dir, getHistorySessionDirName(sessionId))
// Enough entries that a recursive walk would dominate removeSession's duration.
for (let i = 0; i < 3_000; i++) {
writeFileSync(join(sessionDir, `chunk-${i}.log`), `payload-${i}`)
}
// Why structural and not a turn count: worktree teardown awaits removeSession once per terminal,
// so the contract is that the awaited half only renames. The tree surviving under .pending-delete
// right after the await can only happen if the reclaim was detached.
await mgr.removeSession(sessionId)
expect(existsSync(sessionDir)).toBe(false)
const tombstones = readdirSync(join(dir, '.pending-delete'))
expect(tombstones).toHaveLength(1)
expect(readdirSync(join(dir, '.pending-delete', tombstones[0])).length).toBeGreaterThan(0)
await flushPendingSessionTreeRemovals()
expect(readdirSync(join(dir, '.pending-delete'))).toHaveLength(0)
})
it('reclaims tombstones left by a quit mid-removal on the next construction', async () => {
const sessionId = 'leftover'
await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 })
await mgr.removeSession(sessionId)
await flushPendingSessionTreeRemovals()
const orphan = join(dir, '.pending-delete', 'orphaned-tombstone')
mkdirSync(orphan, { recursive: true })
writeFileSync(join(orphan, 'output.log'), 'stranded')
new HistoryManager(dir)
await flushPendingSessionTreeRemovals()
expect(existsSync(orphan)).toBe(false)
})
})
})
+11 -11
View File
@@ -1,15 +1,18 @@
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'node:fs'
import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'
import { getHistorySessionDirName } from './history-paths'
import {
fingerprintTerminalHistorySession,
hasTerminalHistoryRecoveryProtection,
quarantineTerminalHistorySession,
removeTerminalHistoryQuarantines,
type ActiveHistoryRecoveryFreeze,
type HistoryRecoveryFreeze
} from './terminal-history-recovery-quarantine'
import {
removeTerminalHistorySessionTrees,
schedulePendingSessionTreeRemovals
} from './terminal-history-session-tombstone'
import { TerminalHistorySessionWriter } from './terminal-history-session-writer'
import {
readTerminalHistoryMetaFromDir,
@@ -43,6 +46,8 @@ export class HistoryManager {
) {
this.onWriteError = opts?.onWriteError
this.checkpointMaxBytes = opts?.checkpointMaxBytes ?? TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
// Why: a quit between tombstone and reclaim leaves the tree on disk; nothing else rescans the queue.
schedulePendingSessionTreeRemovals(this.basePath)
}
async openSession(sessionId: string, opts: OpenSessionOptions): Promise<void> {
@@ -270,16 +275,11 @@ export class HistoryManager {
async removeSession(sessionId: string): Promise<void> {
this.writers.delete(sessionId)
this.disabledSessions.delete(sessionId)
const activeFreeze = this.recoveryFreezes.get(sessionId)
if (activeFreeze) {
this.recoveryFreezes.delete(sessionId)
}
this.recoveryFreezes.delete(sessionId)
await this.mutations.wait(sessionId)
rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), {
recursive: true,
force: true
})
removeTerminalHistoryQuarantines(this.basePath, sessionId)
// Why tombstoned: writer handles are closed by here, so the trees only have to become unreachable —
// they reach hundreds of MB and every terminal a worktree delete tears down awaits this.
await removeTerminalHistorySessionTrees(this.basePath, sessionId)
}
isSessionDisabled(sessionId: string): boolean {
+5 -1
View File
@@ -18,6 +18,7 @@ import {
hasTerminalHistoryRecoveryProtection,
isTerminalHistoryQuarantineEntry
} from './terminal-history-recovery-quarantine'
import { isTerminalHistoryPendingDeleteEntry } from './terminal-history-session-tombstone'
import {
readTerminalHistoryMeta,
type SessionMeta,
@@ -195,7 +196,10 @@ export class HistoryReader {
if (!entry.isDirectory()) {
continue
}
if (isTerminalHistoryQuarantineEntry(entry.name)) {
if (
isTerminalHistoryQuarantineEntry(entry.name) ||
isTerminalHistoryPendingDeleteEntry(entry.name)
) {
continue
}
let sessionId: string
@@ -5,7 +5,6 @@ import {
mkdirSync,
readdirSync,
renameSync,
rmSync,
unlinkSync,
writeFileSync
} from 'node:fs'
@@ -90,10 +89,3 @@ export function quarantineTerminalHistorySession(
renameSync(sessionDir, quarantineDir)
return quarantineDir
}
export function removeTerminalHistoryQuarantines(basePath: string, sessionId: string): void {
rmSync(getTerminalHistoryQuarantineOwnerDir(basePath, sessionId), {
recursive: true,
force: true
})
}
@@ -0,0 +1,77 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { removeHostTreeMock } = vi.hoisted(() => ({
removeHostTreeMock: vi.fn<(dir: string) => Promise<void>>()
}))
vi.mock('../host-tree-removal', () => ({
removeHostTree: removeHostTreeMock
}))
import { getHistorySessionDirName } from './history-paths'
import {
cancelPendingSessionTreeRemovalRetries,
removeTerminalHistorySessionTrees,
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS
} from './terminal-history-session-tombstone'
/** Mirrors the worktree-level tombstone retry: a session tree whose rm fails must be re-queued
* in-process instead of waiting for the next HistoryManager construction. */
describe('tombstoned session tree removal retries', () => {
let basePath: string
beforeEach(() => {
basePath = mkdtempSync(join(tmpdir(), 'orca-session-tombstone-retry-'))
removeHostTreeMock.mockReset()
vi.useFakeTimers()
})
afterEach(() => {
cancelPendingSessionTreeRemovalRetries()
vi.useRealTimers()
rmSync(basePath, { recursive: true, force: true })
})
function seedSession(sessionId: string): void {
const dir = join(basePath, getHistorySessionDirName(sessionId))
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'segment-0.log'), 'x')
}
it('re-queues a tombstone whose removal failed, then stops after the last attempt', async () => {
seedSession('session-busy')
removeHostTreeMock.mockRejectedValue(
Object.assign(new Error('resource busy'), { code: 'EBUSY' })
)
await removeTerminalHistorySessionTrees(basePath, 'session-busy')
await vi.advanceTimersByTimeAsync(0)
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
for (const [index, retryDelayMs] of SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.entries()) {
await vi.advanceTimersByTimeAsync(retryDelayMs)
expect(removeHostTreeMock).toHaveBeenCalledTimes(index + 2)
}
// Bounded: a permanently wedged tree waits for the next construction's drain.
await vi.advanceTimersByTimeAsync(60 * 60_000)
expect(removeHostTreeMock).toHaveBeenCalledTimes(
SESSION_TREE_REMOVAL_RETRY_DELAYS_MS.length + 1
)
expect(removeHostTreeMock).toHaveBeenLastCalledWith(expect.stringContaining('.pending-delete'))
})
it('does not re-arm a retry after the removal succeeds', async () => {
seedSession('session-clean')
removeHostTreeMock.mockResolvedValue(undefined)
await removeTerminalHistorySessionTrees(basePath, 'session-clean')
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[0])
expect(removeHostTreeMock).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,141 @@
// Why: worktree teardown stops every terminal one by one, and each stop used to await a full recursive
// delete of that session's history tree (hundreds of MB). Rename the tree into a tombstone queue instead
// so the stop-and-wait path is metadata-only, and drain the queue off the critical path.
import { randomUUID } from 'node:crypto'
import { existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import { removeHostTree } from '../host-tree-removal'
import { getHistorySessionDirName } from './history-paths'
import { getTerminalHistoryQuarantineOwnerDir } from './terminal-history-recovery-quarantine'
const PENDING_DELETE_DIR_NAME = '.pending-delete'
const pendingSessionTreeRemovals = new Map<string, Promise<void>>()
// Why: a tombstone whose rm fails once (Windows EBUSY under AV) would otherwise sit on disk until the
// next HistoryManager construction. Bounded so a genuinely wedged tree stops burning timers.
export const SESSION_TREE_REMOVAL_RETRY_DELAYS_MS = [30_000, 120_000]
const sessionTreeRemovalAttempts = new Map<string, number>()
const sessionTreeRemovalRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
export function isTerminalHistoryPendingDeleteEntry(name: string): boolean {
return name === PENDING_DELETE_DIR_NAME
}
function getPendingDeleteRoot(basePath: string): string {
return join(basePath, PENDING_DELETE_DIR_NAME)
}
function tombstoneSessionTree(basePath: string, dir: string): string | null {
const pendingRoot = getPendingDeleteRoot(basePath)
try {
mkdirSync(pendingRoot, { recursive: true })
const tombstone = join(pendingRoot, randomUUID())
renameSync(dir, tombstone)
return tombstone
} catch {
return null
}
}
function scheduleSessionTreeRemovalRetry(dir: string): void {
const attempt = sessionTreeRemovalAttempts.get(dir) ?? 0
const retryDelayMs = SESSION_TREE_REMOVAL_RETRY_DELAYS_MS[attempt]
if (retryDelayMs === undefined) {
// Out of in-process attempts: the tombstone stays queued for the next construction's drain.
sessionTreeRemovalAttempts.delete(dir)
return
}
sessionTreeRemovalAttempts.set(dir, attempt + 1)
const timer = setTimeout(() => {
sessionTreeRemovalRetryTimers.delete(dir)
scheduleSessionTreeRemoval(dir)
}, retryDelayMs)
timer.unref?.()
sessionTreeRemovalRetryTimers.set(dir, timer)
}
function scheduleSessionTreeRemoval(dir: string): void {
if (pendingSessionTreeRemovals.has(dir)) {
return
}
// A rescan (startup drain) hitting the same tombstone supersedes its pending retry.
const pendingRetry = sessionTreeRemovalRetryTimers.get(dir)
if (pendingRetry) {
clearTimeout(pendingRetry)
sessionTreeRemovalRetryTimers.delete(dir)
}
const removal = removeHostTree(dir)
.then(() => {
sessionTreeRemovalAttempts.delete(dir)
})
.catch((err: unknown) => {
console.warn(
`[history] Failed to delete tombstoned session tree: ${err instanceof Error ? err.message : String(err)}`
)
scheduleSessionTreeRemovalRetry(dir)
})
.finally(() => {
if (pendingSessionTreeRemovals.get(dir) === removal) {
pendingSessionTreeRemovals.delete(dir)
}
})
pendingSessionTreeRemovals.set(dir, removal)
}
/** Remove everything a session owns on disk — its history tree and any quarantined generations —
* without holding the caller for the recursive walks. Both are unreachable once this resolves;
* only the reclaim is detached. */
export async function removeTerminalHistorySessionTrees(
basePath: string,
sessionId: string
): Promise<void> {
for (const dir of [
join(basePath, getHistorySessionDirName(sessionId)),
getTerminalHistoryQuarantineOwnerDir(basePath, sessionId)
]) {
await removeSessionOwnedTree(basePath, dir)
}
}
async function removeSessionOwnedTree(basePath: string, dir: string): Promise<void> {
if (!existsSync(dir)) {
return
}
const tombstone = tombstoneSessionTree(basePath, dir)
if (tombstone) {
scheduleSessionTreeRemoval(tombstone)
return
}
// Rename blocked (open handles under Windows AV); remove in place so the tree is still gone on return.
await removeHostTree(dir)
}
/** Queue tombstones left by a crash or quit mid-removal. Cheap no-op when the queue never formed. */
export function schedulePendingSessionTreeRemovals(basePath: string): void {
const pendingRoot = getPendingDeleteRoot(basePath)
try {
for (const entry of readdirSync(pendingRoot)) {
scheduleSessionTreeRemoval(join(pendingRoot, entry))
}
} catch {
// Missing or unreadable queue is non-fatal; the next construction rescans.
}
}
/** Drop every armed retry timer so a fixture teardown cannot resurrect a removal. Tests only. */
export function cancelPendingSessionTreeRemovalRetries(): void {
for (const timer of sessionTreeRemovalRetryTimers.values()) {
clearTimeout(timer)
}
sessionTreeRemovalRetryTimers.clear()
sessionTreeRemovalAttempts.clear()
}
/** Await the in-flight tombstone removals. Tests only — production reclaims off the critical path and
* re-queues whatever a quit interrupted on the next HistoryManager construction. */
export async function flushPendingSessionTreeRemovals(): Promise<void> {
while (pendingSessionTreeRemovals.size > 0) {
await Promise.all(pendingSessionTreeRemovals.values())
}
}
@@ -0,0 +1,248 @@
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
const { copyFileMock, handleMock, lstatMock, realpathMock, renameMock } = vi.hoisted(() => ({
copyFileMock: vi.fn(),
handleMock: vi.fn(),
lstatMock: vi.fn(),
realpathMock: vi.fn(),
renameMock: vi.fn()
}))
const handlers = new Map<string, (_event: unknown, args: unknown) => Promise<unknown>>()
vi.mock('electron', () => ({
ipcMain: { handle: handleMock }
}))
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
return {
...actual,
copyFile: copyFileMock,
lstat: lstatMock,
realpath: realpathMock,
rename: renameMock
}
})
import { renameLocalPathSerializedByDestination } from './destination-serialized-local-rename'
import { registerFilesystemMutationHandlers } from './ipc/filesystem-mutations'
import { RuntimeFileCommands } from './runtime/orca-runtime-files'
const REPO_PATH = path.resolve('/workspace/repo')
const store = {
getRepos: () => [
{ id: 'repo-1', path: REPO_PATH, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
],
getSettings: () => ({ workspaceDir: path.resolve('/workspace') })
}
function enoent(): Error {
return Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
}
function mockStats(dev: number, ino: number) {
return { dev, ino, isDirectory: () => false }
}
function createRuntimeCommands(): RuntimeFileCommands {
return new RuntimeFileCommands({
requireStore: () => store,
resolveRuntimeFileTarget: async () => ({
worktree: { id: 'wt-1', repoId: 'repo-1', path: REPO_PATH }
})
} as never)
}
function createGate(): { promise: Promise<void>; release: () => void } {
let release!: () => void
const promise = new Promise<void>((resolve) => {
release = resolve
})
return { promise, release }
}
async function reachNextMacrotask(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
describe('renameLocalPathSerializedByDestination', () => {
beforeEach(() => {
handlers.clear()
copyFileMock.mockReset()
handleMock.mockReset()
lstatMock.mockReset()
realpathMock.mockReset()
renameMock.mockReset()
handleMock.mockImplementation((channel: string, handler: never) => {
handlers.set(channel, handler)
})
lstatMock.mockRejectedValue(enoent())
realpathMock.mockImplementation(async (filePath: string) => filePath)
renameMock.mockResolvedValue(undefined)
registerFilesystemMutationHandlers(store as never)
})
it('serializes exact Unicode aliases across IPC and runtime entry points', async () => {
const ipcSource = path.join(REPO_PATH, 'ipc-source.md')
const runtimeSource = path.join(REPO_PATH, 'runtime-source.md')
const sharpSDestination = path.join(REPO_PATH, 'Straße.md')
const expandedDestination = path.join(REPO_PATH, 'STRASSE.md')
const firstRenameGate = createGate()
let destinationExists = false
lstatMock.mockImplementation(async (filePath: string) => {
if (
destinationExists &&
(filePath === sharpSDestination || filePath === expandedDestination)
) {
return mockStats(1, 30)
}
if (filePath === ipcSource) {
return mockStats(1, 10)
}
if (filePath === runtimeSource) {
return mockStats(1, 20)
}
throw enoent()
})
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
destinationExists = true
})
const ipcRename = handlers.get('fs:rename')!(null, {
oldPath: ipcSource,
newPath: sharpSDestination
})
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const runtimeRename = createRuntimeCommands().renameFileExplorerPath(
'id:wt-1',
'runtime-source.md',
'STRASSE.md',
undefined,
undefined,
'local'
)
await reachNextMacrotask()
const callsWhileFirstBlocked = renameMock.mock.calls.length
firstRenameGate.release()
const results = await Promise.allSettled([ipcRename, runtimeRename])
expect(callsWhileFirstBlocked).toBe(1)
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
expect(renameMock).toHaveBeenCalledTimes(1)
})
it('releases an alias queue after the leading rename errors', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
throw new Error('EACCES')
}
})
const destination = path.join(REPO_PATH, 'destination.md')
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first.md'),
destination
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second.md'),
destination
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(firstRename).rejects.toThrow('EACCES')
await expect(secondRename).resolves.toBeUndefined()
expect(renameMock).toHaveBeenCalledTimes(2)
})
it('does not serialize destinations in unrelated parent directories', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first', 'source.md'),
path.join(REPO_PATH, 'first', 'alpha.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second', 'source.md'),
path.join(REPO_PATH, 'second', 'beta.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(2))
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
})
it('serializes symlink aliases of one destination parent', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstParent = path.join(REPO_PATH, 'parent-link-a')
const secondParent = path.join(REPO_PATH, 'parent-link-b')
const canonicalParent = path.join(REPO_PATH, 'canonical-parent')
realpathMock.mockImplementation(async (filePath: string) => {
if (filePath === firstParent || filePath === secondParent) {
return canonicalParent
}
return filePath
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(firstParent, 'source.md'),
path.join(firstParent, 'alpha.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(secondParent, 'source.md'),
path.join(secondParent, 'beta.md')
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
expect(renameMock).toHaveBeenCalledTimes(2)
})
it('scopes conservative serialization to one destination parent', async () => {
const firstRenameGate = createGate()
renameMock.mockImplementation(async () => {
if (renameMock.mock.calls.length === 1) {
await firstRenameGate.promise
}
})
const firstRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'first.md'),
path.join(REPO_PATH, 'dotless-ı.md')
)
await vi.waitFor(() => expect(renameMock).toHaveBeenCalledTimes(1))
const secondRename = renameLocalPathSerializedByDestination(
path.join(REPO_PATH, 'second.md'),
path.join(REPO_PATH, 'dotless-I.md')
)
await reachNextMacrotask()
expect(renameMock).toHaveBeenCalledTimes(1)
firstRenameGate.release()
await expect(Promise.all([firstRename, secondRename])).resolves.toEqual([undefined, undefined])
expect(renameMock).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,50 @@
import { realpath, rename } from 'node:fs/promises'
import { dirname, normalize } from 'node:path'
import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision'
// Why: parent scope covers native Unicode aliases without guessing each filesystem's collation.
const pendingRenamesByParent = new Map<string, Promise<void>>()
function isENOENT(error: unknown): boolean {
return (
error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT'
)
}
async function destinationParentKey(filePath: string): Promise<string> {
const parentPath = dirname(filePath)
try {
return normalize(await realpath(parentPath))
} catch (error) {
// A missing parent will fail the rename; retain its path so that failure
// does not prevent unrelated destinations from progressing.
if (isENOENT(error)) {
return normalize(parentPath)
}
throw error
}
}
export async function renameLocalPathSerializedByDestination(
oldPath: string,
newPath: string
): Promise<void> {
const key = await destinationParentKey(newPath)
const previous = pendingRenamesByParent.get(key) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
pendingRenamesByParent.set(key, current)
await previous
try {
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
await rename(oldPath, newPath)
} finally {
release()
if (pendingRenamesByParent.get(key) === current) {
pendingRenamesByParent.delete(key)
}
}
}
+65 -9
View File
@@ -4,8 +4,8 @@
// hour's loss; fsync stops it from happening.
import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs'
import { open, rename } from 'node:fs/promises'
import { dirname } from 'node:path'
import { open, readdir, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
/**
* fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a
@@ -57,16 +57,72 @@ export async function writeFileDurable(
finalPath: string,
payload: string
): Promise<void> {
const handle = await open(tmpPath, 'w')
await writeFileDurableIfCurrent(tmpPath, finalPath, payload, () => true)
}
/**
* `writeFileDurable` with a commit veto: `isCurrent` is consulted after the fsync and before the
* rename so a writer that was superseded mid-write doesn't publish a stale snapshot. Returns whether
* the rename happened; the temp file is removed on every path that doesn't commit, so a multi-MB
* payload can't orphan itself.
*/
export async function writeFileDurableIfCurrent(
tmpPath: string,
finalPath: string,
payload: string,
isCurrent: () => boolean
): Promise<boolean> {
let renamed = false
try {
await handle.writeFile(payload, 'utf-8')
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await handle.sync()
const handle = await open(tmpPath, 'w')
try {
await handle.writeFile(payload, 'utf-8')
// Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file.
await handle.sync()
} finally {
await handle.close()
}
if (!isCurrent()) {
return false
}
await rename(tmpPath, finalPath)
renamed = true
await syncDirectory(dirname(finalPath))
return true
} finally {
await handle.close()
if (!renamed) {
await rm(tmpPath, { force: true }).catch(() => {})
}
}
}
/** Temp path for a durable write. Shared shape so `removeStaleDurableWriteTempFiles` can reclaim orphans. */
export function durableWriteTempPath(finalPath: string): string {
return `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
}
/**
* Sweep temp files orphaned by a death between write and rename — for multi-MB payloads they would
* otherwise accumulate forever. Racing another instance's in-flight save at worst loses that save,
* the trade already accepted for rename-based atomicity. This process's own temps are skipped: a
* `<file>.<our pid>.*.tmp` seen during a sweep is a live write, and deleting it fails its rename.
*/
export async function removeStaleDurableWriteTempFiles(finalPath: string): Promise<void> {
const directory = dirname(finalPath)
const prefix = `${basename(finalPath)}.`
const ownPrefix = `${prefix}${process.pid}.`
try {
const names = await readdir(directory)
await Promise.all(
names
.filter(
(name) => name.startsWith(prefix) && name.endsWith('.tmp') && !name.startsWith(ownPrefix)
)
.map((name) => rm(join(directory, name), { force: true }).catch(() => {}))
)
} catch {
// Directory missing or unreadable — nothing to sweep.
}
await rename(tmpPath, finalPath)
await syncDirectory(dirname(finalPath))
}
/** Synchronous counterpart for quit and crash paths that cannot await. */
+50 -4
View File
@@ -30,6 +30,51 @@ export function normalizeGitUsername(value: string): string {
return localPart.replace(/^\d+\+/, '')
}
/**
* Hosted account logins used as branch-prefix segments must be single-path
* tokens. Reject multi-line / JSON error bodies from a failed `gh api user`
* (rate-limit 403 still prints JSON on stdout) so they never become branch names.
*/
export function isPlausibleHostedLogin(value: string): boolean {
// GitHub usernames: 1–39 chars, alphanumerics and single hyphens, no leading/trailing hyphen.
return (
/^[A-Za-z0-9]$/.test(value) ||
(/^[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9]$/.test(value) && !value.includes('--'))
)
}
// Not a check-ref-format rule: a login becomes one slash-free branch component,
// which a loose ref stores as a single filename (255-byte cap on ext4/APFS/NTFS).
// The ASCII-only charset below makes character count equal byte count.
const MAX_BRANCH_SAFE_LOGIN_LENGTH = 255
/**
* Provider-agnostic branch-safe token: GitLab/Bitbucket/self-hosted logins may
* carry `_`/`.` and run longer than GitHub's 39-char limit, so the strict
* GitHub rule must not gate explicitly configured usernames.
*/
export function isBranchSafeHostedLogin(value: string): boolean {
if (value.length > MAX_BRANCH_SAFE_LOGIN_LENGTH) {
return false
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
return false
}
// Dot placements git check-ref-format rejects; `.lock` is case-sensitive there too.
return !value.includes('..') && !value.endsWith('.') && !value.endsWith('.lock')
}
function normalizeHostedLogin(value: string): string {
const normalized = normalizeGitUsername(value)
return normalized && isPlausibleHostedLogin(normalized) ? normalized : ''
}
/** Explicit `github.user`/`user.username` config is provider-agnostic; only reject non-tokens. */
function normalizeConfiguredLogin(value: string): string {
const normalized = normalizeGitUsername(value)
return normalized && isBranchSafeHostedLogin(normalized) ? normalized : ''
}
/**
* A resolved username plus whether every probe on the way to it completed.
* Non-authoritative '' (a probe timed out) must not overwrite a previously
@@ -46,7 +91,7 @@ export async function getSshGitUsername(
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
try {
const { stdout } = await provider.exec(['config', '--get', key], repoPath)
const username = normalizeGitUsername(stdout)
const username = normalizeConfiguredLogin(stdout)
if (username) {
return username
}
@@ -130,7 +175,7 @@ function parseGhAuthStatusLogin(output: string): string {
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
const apiLogin = normalizeGitUsername(api.stdout.trim())
const apiLogin = normalizeHostedLogin(api.stdout)
if (apiLogin) {
return { login: apiLogin, timedOut: false }
}
@@ -144,7 +189,7 @@ async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
return { login: '', timedOut: true }
}
const output = `${status.stdout}\n${status.stderr}`
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
return { login: normalizeHostedLogin(parseGhAuthStatusLogin(output)), timedOut: false }
}
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
@@ -270,7 +315,8 @@ export async function resolveLocalGitUsernameDetailed(
cwd: repoPath,
timeout: LOCAL_GIT_READ_TIMEOUT_MS
})
const username = normalizeGitUsername(stdout)
// Why: config can hold free-form strings; only branch-safe logins become prefixes.
const username = normalizeConfiguredLogin(stdout)
if (username) {
return { username, authoritative: true }
}
+56 -1
View File
@@ -14,6 +14,7 @@ vi.mock('./runner', async () => {
})
import {
isBranchSafeHostedLogin,
resolveLocalGitUsername,
resolveLocalGitUsernameDetailed,
resetGhLoginCacheForTests
@@ -21,11 +22,43 @@ import {
function makeExecError(
message: string,
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
extra: {
code?: string
killed?: boolean
signal?: string
stdout?: string
stderr?: string
} = {}
): Error {
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
}
describe('isBranchSafeHostedLogin', () => {
it.each(['demo', 'demo-user', 'demo_user', 'demo.user', 'a', 'FOO.LOCK'])(
'accepts %s',
(login) => {
expect(isBranchSafeHostedLogin(login)).toBe(true)
}
)
it.each(['foo.', 'foo..bar', 'foo.lock', 'a.b.lock', '.foo', '-foo', 'foo bar'])(
'rejects the git check-ref-format-invalid %s',
(login) => {
expect(isBranchSafeHostedLogin(login)).toBe(false)
}
)
// Length is Orca's defensive bound, not a check-ref-format rule: a login is one
// branch component, so a loose ref stores it as a single 255-byte-max filename.
it('accepts long provider-agnostic logins up to the loose-ref filename cap', () => {
expect(isBranchSafeHostedLogin('a'.repeat(255))).toBe(true)
})
it('rejects logins past the loose-ref filename cap', () => {
expect(isBranchSafeHostedLogin('a'.repeat(256))).toBe(false)
})
})
describe('resolveLocalGitUsername', () => {
let gitConfig: Record<string, string>
let originRemoteUrl: string | undefined
@@ -98,6 +131,15 @@ describe('resolveLocalGitUsername', () => {
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('falls through config values git rejects as branch components', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['github.user'] = 'foo.lock'
gitConfig['user.username'] = 'foo..bar'
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
})
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['user.email'] = 'demo@example.com'
@@ -160,6 +202,19 @@ describe('resolveLocalGitUsername', () => {
}
})
it('ignores rate-limit JSON bodies from gh api user so they never become branch prefixes', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
const rateLimitJson = JSON.stringify({
message: 'API rate limit exceeded for user ID 6427696',
status: '403'
})
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh api failed', { stdout: rateLimitJson }))
.mockRejectedValueOnce(makeExecError('gh auth status failed'))
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
})
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock.mockRejectedValueOnce(
+14
View File
@@ -7,6 +7,7 @@ import {
refreshBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { withSpan } from '../observability/tracer'
import type {
GitWorktreeInfo,
LocalBaseRefRefreshResult,
@@ -1157,6 +1158,19 @@ async function performRemoveWorktree(
return {}
}
// Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as
// `git worktree remove` being slow unless it is timed separately.
return withSpan('worktree.remove.branch_delete', () =>
deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options)
)
}
async function deleteBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
branchHead: string,
options: RemoveWorktreeOptions
): Promise<RemoveWorktreeResult> {
try {
// Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves
// unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback.
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
const TIMEOUT_MS = 45_000
const CHECK_INTERVAL_MS = 5_000
@@ -8,7 +8,7 @@ function loopWithClock(startAt = 0) {
let now = startAt
const onHangDetected = vi.fn()
const onHangResolved = vi.fn()
const loop = createHangWatchdogDetectionLoop({
const loop = createHangWatchdogChildLoop({
timeoutMs: TIMEOUT_MS,
checkIntervalMs: CHECK_INTERVAL_MS,
now: () => now,
@@ -18,7 +18,7 @@ function loopWithClock(startAt = 0) {
return { loop, onHangDetected, onHangResolved, advance: (ms: number) => (now += ms) }
}
describe('createHangWatchdogDetectionLoop', () => {
describe('createHangWatchdogChildLoop', () => {
it('does not fire while heartbeats keep arriving', () => {
const { loop, onHangDetected, advance } = loopWithClock()
for (let i = 0; i < 100; i++) {
@@ -1,4 +1,4 @@
export type HangWatchdogDetectionLoopConfig = {
export type HangWatchdogChildLoopConfig = {
timeoutMs: number
checkIntervalMs: number
now: () => number
@@ -7,14 +7,14 @@ export type HangWatchdogDetectionLoopConfig = {
onHangResolved: (unresponsiveMs: number) => void
}
export type HangWatchdogDetectionLoop = {
export type HangWatchdogChildLoop = {
recordHeartbeat: () => void
tick: () => void
}
export function createHangWatchdogDetectionLoop(
config: HangWatchdogDetectionLoopConfig
): HangWatchdogDetectionLoop {
export function createHangWatchdogChildLoop(
config: HangWatchdogChildLoopConfig
): HangWatchdogChildLoop {
let lastHeartbeatAt = config.now()
let lastTickAt = config.now()
let detected = false
@@ -0,0 +1,17 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
export function resolveHangWatchdogEntryPath(
appPath: string,
isPackaged: boolean,
pathExists: (candidate: string) => boolean = existsSync
): string {
// Why: ELECTRON_RUN_AS_NODE bypasses asar integration, so the packaged entry must be forked from app.asar.unpacked.
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
const adjacentBuildEntry = join(basePath, 'main-thread-hang-watchdog-entry.js')
// Why: electron-vite's unpackaged appPath is already out/main; appending out/main again would silently disable the watchdog in dev and E2E builds.
if (!isPackaged && pathExists(adjacentBuildEntry)) {
return adjacentBuildEntry
}
return join(basePath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
}
@@ -1,26 +0,0 @@
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
describe('resolveHangWatchdogWorkerPath', () => {
it('resolves packaged workers inside app.asar', () => {
const appPath = join('/apps', 'orca', 'app.asar')
expect(resolveHangWatchdogWorkerPath(appPath, true)).toBe(
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
})
it('uses an adjacent entry when the dev app path is out/main', () => {
const appPath = join('/repo', 'out', 'main')
const adjacent = join(appPath, 'main-thread-hang-watchdog-entry.js')
const pathExists = vi.fn((candidate: string) => candidate === adjacent)
expect(resolveHangWatchdogWorkerPath(appPath, false, pathExists)).toBe(adjacent)
})
it('resolves through out/main from a dev project root', () => {
const appPath = join('/repo', 'orca')
expect(resolveHangWatchdogWorkerPath(appPath, false, () => false)).toBe(
join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
})
})
@@ -1,14 +0,0 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
export function resolveHangWatchdogWorkerPath(
appPath: string,
isPackaged: boolean,
pathExists: (candidate: string) => boolean = existsSync
): string {
const adjacentBuildEntry = join(appPath, 'main-thread-hang-watchdog-entry.js')
if (!isPackaged && pathExists(adjacentBuildEntry)) {
return adjacentBuildEntry
}
return join(appPath, 'out', 'main', 'main-thread-hang-watchdog-entry.js')
}
@@ -1,12 +0,0 @@
export const HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS = 2_000
export const HANG_WATCHDOG_TIMEOUT_MS = 45_000
export const HANG_WATCHDOG_CHECK_INTERVAL_MS = 5_000
export type HangWatchdogWorkerData = {
parentPid: number
markerPath: string
timeoutMs: number
checkIntervalMs: number
}
export type MainToHangWatchdogWorkerMessage = { type: 'heartbeat' } | { type: 'shutdown' }
@@ -67,6 +67,9 @@ describe('recordHangObservation', () => {
})
})
// Why: this is the safety contract of the whole PR. Observing a hang must never kill, relaunch,
// or exit — a false positive would SIGKILL a live main thread mid-write. If a future change
// reintroduces recovery, this test must fail loudly rather than ship silently.
it('never spawns, signals, or exits', () => {
recordHangObservation({
parentPid: 4242,
@@ -1,12 +1,20 @@
import { isMainThread, parentPort, workerData } from 'node:worker_threads'
import { createHangWatchdogDetectionLoop } from './hang-watchdog-detection-loop'
// Forked with ELECTRON_RUN_AS_NODE from the main process. Watches heartbeats from the main
// thread; if they stop (e.g. the macOS 26 AppKit scene-update deadlock) it records a marker so
// the next launch can report the stall, and rewrites it if the heartbeats come back.
//
// Why no kill: a deadlocked main thread has already lost whatever sat in the persistence debounce
// window, so killing it recovers nothing the user could not recover by force-quitting. A false
// positive, though, would SIGKILL a live main thread that was merely blocked — most plausibly on
// I/O, i.e. exactly when writes are in flight. All of the downside sits in the misfire, so this
// measures first: `selfRecovered` counts the stalls a killer would have gotten wrong.
//
// Must never import electron.
import { createHangWatchdogChildLoop } from './hang-watchdog-child-loop'
import { writeHangDetectionMarker } from './hang-detection-marker'
import type {
HangWatchdogWorkerData,
MainToHangWatchdogWorkerMessage
} from './hang-watchdog-worker-protocol'
// Observation only: a false positive must never kill a live main thread mid-write.
const DEFAULT_TIMEOUT_MS = 45_000
const DEFAULT_CHECK_INTERVAL_MS = 5_000
export function recordHangObservation(options: {
parentPid: number
markerPath: string
@@ -28,63 +36,37 @@ export function recordHangObservation(options: {
}
}
function runWatchdog(config: HangWatchdogWorkerData): void {
const port = parentPort
if (!port) {
return
}
const loop = createHangWatchdogDetectionLoop({
timeoutMs: config.timeoutMs,
checkIntervalMs: config.checkIntervalMs,
function runWatchdog(parentPid: number): void {
const markerPath = process.env.ORCA_HANG_WATCHDOG_MARKER_PATH ?? ''
const timeoutMs = Number(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS
const checkIntervalMs =
Number(process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS) || DEFAULT_CHECK_INTERVAL_MS
const loop = createHangWatchdogChildLoop({
timeoutMs,
checkIntervalMs,
now: () => Date.now(),
onHangDetected: (unresponsiveMs) =>
recordHangObservation({
parentPid: config.parentPid,
markerPath: config.markerPath,
unresponsiveMs,
selfRecovered: false
}),
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: false }),
// Why: rewriting the marker keeps one observation per stall rather than two rows to reconcile.
onHangResolved: (unresponsiveMs) =>
recordHangObservation({
parentPid: config.parentPid,
markerPath: config.markerPath,
unresponsiveMs,
selfRecovered: true
})
recordHangObservation({ parentPid, markerPath, unresponsiveMs, selfRecovered: true })
})
let checkTimer: ReturnType<typeof setInterval> | null = setInterval(
() => loop.tick(),
config.checkIntervalMs
)
port.on('message', (message: MainToHangWatchdogWorkerMessage) => {
if (message.type === 'heartbeat') {
process.on('message', (message) => {
const type = (message as { type?: string } | null)?.type
if (type === 'heartbeat') {
loop.recordHeartbeat()
} else if (message.type === 'shutdown') {
if (checkTimer) {
clearInterval(checkTimer)
checkTimer = null
}
port.close()
} else if (type === 'shutdown') {
process.exit(0)
}
})
// Why: a normal parent exit closes the IPC channel; the watchdog must not outlive it and misfire.
process.on('disconnect', () => process.exit(0))
setInterval(() => loop.tick(), checkIntervalMs)
}
function isHangWatchdogWorkerData(value: unknown): value is HangWatchdogWorkerData {
const data = value as Partial<HangWatchdogWorkerData> | null
return (
!!data &&
Number.isInteger(data.parentPid) &&
(data.parentPid ?? 0) > 0 &&
typeof data.markerPath === 'string' &&
Number.isFinite(data.timeoutMs) &&
(data.timeoutMs ?? 0) > 0 &&
Number.isFinite(data.checkIntervalMs) &&
(data.checkIntervalMs ?? 0) > 0
)
}
if (!isMainThread && isHangWatchdogWorkerData(workerData)) {
runWatchdog(workerData)
const configuredParentPid = Number(process.env.ORCA_HANG_WATCHDOG_PARENT_PID)
if (Number.isInteger(configuredParentPid) && configuredParentPid > 0) {
runWatchdog(configuredParentPid)
}
@@ -1,12 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
const { workerState, appMock } = vi.hoisted(() => ({
workerState: {
calls: [] as unknown[][],
instance: null as object | null,
error: null as Error | null
},
const { forkMock, appMock } = vi.hoisted(() => ({
forkMock: vi.fn(),
appMock: {
isPackaged: true,
getAppPath: vi.fn(() => '/apps/orca/app.asar'),
@@ -14,16 +9,8 @@ const { workerState, appMock } = vi.hoisted(() => ({
}
}))
vi.mock('node:worker_threads', () => ({
Worker: class WorkerMock {
constructor(...args: unknown[]) {
workerState.calls.push(args)
if (workerState.error) {
throw workerState.error
}
return workerState.instance as WorkerMock
}
}
vi.mock('node:child_process', () => ({
fork: forkMock
}))
vi.mock('electron', () => ({
@@ -42,33 +29,28 @@ function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
}
}
function fakeWorker() {
function fakeChild() {
return {
postMessage: vi.fn(),
unref: vi.fn(),
connected: true,
stderr: { on: vi.fn() },
on: vi.fn(),
once: vi.fn()
send: vi.fn(),
disconnect: vi.fn(),
kill: vi.fn()
}
}
describe('installMainThreadHangWatchdog', () => {
beforeEach(() => {
vi.useFakeTimers()
workerState.calls = []
workerState.instance = null
workerState.error = null
forkMock.mockReset()
appMock.on.mockReset()
appMock.isPackaged = true
delete process.env.ORCA_HANG_WATCHDOG_FORCE
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
})
afterEach(() => {
vi.useRealTimers()
delete process.env.ORCA_HANG_WATCHDOG_FORCE
delete process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS
delete process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS
})
it('is a no-op off macOS', () => {
@@ -78,7 +60,7 @@ describe('installMainThreadHangWatchdog', () => {
expect(
withPlatform('linux', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
expect(workerState.calls).toHaveLength(0)
expect(forkMock).not.toHaveBeenCalled()
})
it('is a no-op in unpackaged builds unless forced', () => {
@@ -87,98 +69,62 @@ describe('installMainThreadHangWatchdog', () => {
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
process.env.ORCA_HANG_WATCHDOG_FORCE = '1'
const worker = fakeWorker()
workerState.instance = worker
const child = fakeChild()
forkMock.mockReturnValue(child)
expect(
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).not.toBeNull()
delete process.env.ORCA_HANG_WATCHDOG_FORCE
})
it('starts a worker with pid, marker, and timing config', () => {
const worker = fakeWorker()
workerState.instance = worker
it('forks the watchdog as plain Node with pid, bundle, and marker config', () => {
const child = fakeChild()
forkMock.mockReturnValue(child)
const handle = withPlatform('darwin', () =>
installMainThreadHangWatchdog({ userDataPath: '/ud' })
)
expect(handle).not.toBeNull()
const [workerPath, rawOptions] = workerState.calls[0]
const options = rawOptions as {
name: string
workerData: {
parentPid: number
markerPath: string
timeoutMs: number
checkIntervalMs: number
}
}
expect(workerPath).toBe(
join('/apps/orca/app.asar', 'out', 'main', 'main-thread-hang-watchdog-entry.js')
)
expect(options.name).toBe('orca-main-thread-hang-watchdog')
expect(options.workerData).toMatchObject({
parentPid: process.pid,
markerPath: join('/ud', 'main-thread-hang.json'),
timeoutMs: 45_000,
checkIntervalMs: 5_000
})
expect(worker.unref).toHaveBeenCalled()
const [, , options] = forkMock.mock.calls[0]
expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(options.env.ORCA_HANG_WATCHDOG_PARENT_PID).toBe(String(process.pid))
expect(options.env.ORCA_HANG_WATCHDOG_MARKER_PATH).toContain('/ud')
})
it('sends heartbeats on an interval and shutdown on stop', () => {
const worker = fakeWorker()
workerState.instance = worker
it('sends heartbeats on an interval and shutdown+disconnect on stop', () => {
const child = fakeChild()
forkMock.mockReturnValue(child)
const handle = withPlatform('darwin', () =>
installMainThreadHangWatchdog({ userDataPath: '/ud' })
)
vi.advanceTimersByTime(6_000)
const heartbeats = worker.postMessage.mock.calls.filter(([m]) => m.type === 'heartbeat')
const heartbeats = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
expect(heartbeats.length).toBe(3)
handle?.stop()
expect(worker.postMessage.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
expect(child.send.mock.calls.some(([m]) => m.type === 'shutdown')).toBe(true)
expect(child.disconnect).toHaveBeenCalled()
// Why: quit fires will-quit twice; a second stop must not resend or throw.
handle?.stop()
const shutdowns = worker.postMessage.mock.calls.filter(([m]) => m.type === 'shutdown')
const shutdowns = child.send.mock.calls.filter(([m]) => m.type === 'shutdown')
expect(shutdowns.length).toBe(1)
vi.advanceTimersByTime(10_000)
const heartbeatsAfterStop = worker.postMessage.mock.calls.filter(
([m]) => m.type === 'heartbeat'
)
const heartbeatsAfterStop = child.send.mock.calls.filter(([m]) => m.type === 'heartbeat')
expect(heartbeatsAfterStop.length).toBe(3)
})
it('passes test timing overrides to the worker', () => {
process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS = '900'
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS = '100'
workerState.instance = fakeWorker()
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
const options = workerState.calls[0][1] as {
workerData: { timeoutMs: number; checkIntervalMs: number }
}
expect(options.workerData).toMatchObject({ timeoutMs: 900, checkIntervalMs: 100 })
})
it('registers stop on will-quit', () => {
workerState.instance = fakeWorker()
const child = fakeChild()
forkMock.mockReturnValue(child)
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
expect(appMock.on).toHaveBeenCalledWith('will-quit', expect.any(Function))
})
it('stops heartbeat work when the worker exits', () => {
const worker = fakeWorker()
workerState.instance = worker
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
const exitListener = worker.once.mock.calls.find(([event]) => event === 'exit')?.[1]
expect(exitListener).toEqual(expect.any(Function))
exitListener()
vi.advanceTimersByTime(6_000)
expect(worker.postMessage).not.toHaveBeenCalled()
})
it('returns null and stays inert when worker construction fails', () => {
workerState.error = new Error('worker failure')
it('returns null and stays inert when the fork itself fails', () => {
forkMock.mockImplementation(() => {
throw new Error('spawn failure')
})
expect(
withPlatform('darwin', () => installMainThreadHangWatchdog({ userDataPath: '/ud' }))
).toBeNull()
@@ -1,25 +1,19 @@
import { Worker } from 'node:worker_threads'
import { fork, type ChildProcess } from 'node:child_process'
import { app } from 'electron'
import { resolveHangWatchdogEntryPath } from './hang-watchdog-entry-path'
import { hangDetectionMarkerPath } from './hang-detection-marker'
import { resolveHangWatchdogWorkerPath } from './hang-watchdog-worker-path'
import {
HANG_WATCHDOG_CHECK_INTERVAL_MS,
HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS,
HANG_WATCHDOG_TIMEOUT_MS,
type HangWatchdogWorkerData,
type MainToHangWatchdogWorkerMessage
} from './hang-watchdog-worker-protocol'
const HEARTBEAT_INTERVAL_MS = 2_000
export type MainThreadHangWatchdogHandle = {
stop: () => void
worker: Worker
}
function positiveTiming(value: string | undefined, fallback: number): number {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
child: ChildProcess
}
// Why: macOS 26 scene-backed AppKit windows can deadlock the main thread inside
// FrontBoardServices with no crash and no event loop left to self-report. A plain-Node sibling
// process watches heartbeats and records the stall so the next launch can report it. It observes
// only — see main-thread-hang-watchdog-entry.ts for why it does not kill the parent.
export function installMainThreadHangWatchdog(options: {
userDataPath: string
}): MainThreadHangWatchdogHandle | null {
@@ -30,57 +24,58 @@ export function installMainThreadHangWatchdog(options: {
if (!app.isPackaged && process.env.ORCA_HANG_WATCHDOG_FORCE !== '1') {
return null
}
const workerPath = resolveHangWatchdogWorkerPath(app.getAppPath(), app.isPackaged)
const workerData: HangWatchdogWorkerData = {
parentPid: process.pid,
markerPath: hangDetectionMarkerPath(options.userDataPath),
timeoutMs: positiveTiming(process.env.ORCA_HANG_WATCHDOG_TIMEOUT_MS, HANG_WATCHDOG_TIMEOUT_MS),
checkIntervalMs: positiveTiming(
process.env.ORCA_HANG_WATCHDOG_CHECK_INTERVAL_MS,
HANG_WATCHDOG_CHECK_INTERVAL_MS
)
}
let worker: Worker
const entryPath = resolveHangWatchdogEntryPath(app.getAppPath(), app.isPackaged)
let child: ChildProcess
try {
// Why: the worker survives an AppKit main-thread deadlock without another Electron process.
worker = new Worker(workerPath, {
name: 'orca-main-thread-hang-watchdog',
workerData
child = fork(entryPath, [], {
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
ORCA_HANG_WATCHDOG_PARENT_PID: String(process.pid),
ORCA_HANG_WATCHDOG_MARKER_PATH: hangDetectionMarkerPath(options.userDataPath)
}
})
} catch (error) {
console.error('[hang-watchdog] failed to start watchdog worker:', error)
console.error('[hang-watchdog] failed to fork watchdog process:', error)
return null
}
worker.on('error', (error) => {
console.error('[hang-watchdog] watchdog worker failed:', error)
child.stderr?.on('data', (chunk: Buffer) => {
console.error('[hang-watchdog]', String(chunk).trimEnd())
})
let stopped = false
const postMessage = (message: MainToHangWatchdogWorkerMessage): void => {
if (stopped && message.type === 'heartbeat') {
return
}
try {
worker.postMessage(message)
} catch {
// The worker already exited.
}
}
// Why: the watchdog is a safety net — if it dies, run without it; a restart loop here must never affect the app.
child.on('error', () => {})
const heartbeatTimer = setInterval(() => {
postMessage({ type: 'heartbeat' })
}, HANG_WATCHDOG_HEARTBEAT_INTERVAL_MS)
if (child.connected) {
try {
child.send({ type: 'heartbeat' }, () => {})
} catch {
// Channel raced closed between the check and the send.
}
}
}, HEARTBEAT_INTERVAL_MS)
let stopped = false
const stop = (): void => {
if (stopped) {
return
}
stopped = true
clearInterval(heartbeatTimer)
postMessage({ type: 'shutdown' })
if (child.connected) {
try {
child.send({ type: 'shutdown' }, () => {})
} catch {
// Already disconnecting.
}
}
// Why: disconnect guarantees the child observes parent shutdown and exits instead of misreading quit as a hang.
try {
child.disconnect()
} catch {
// Channel already closed.
}
}
worker.once('exit', () => {
stopped = true
clearInterval(heartbeatTimer)
})
worker.unref()
// Why: will-quit fires twice during quit; stop is idempotent.
app.on('will-quit', stop)
return { stop, worker }
return { stop, child }
}
+67
View File
@@ -0,0 +1,67 @@
// Why: every recursive host delete Orca performs (worktrees, terminal history, quarantined recovery
// generations) hits the same Windows stickiness — AV/indexers/late handle releases surface transient
// EBUSY/ENOTEMPTY/EPERM on a tree Node just emptied. One helper so no call site forgets the retries.
import type { RmOptions } from 'node:fs'
import { rm } from 'node:fs/promises'
import { win32 } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000]
const WINDOWS_RM_MAX_RETRIES = 8
const WINDOWS_RM_RETRY_DELAY_MS = 150
export function toHostRemovalPath(targetPath: string): string {
// Why: Git for Windows can fail long recursive deletes even after Orca has
// proven the worktree target; Node's host deletion should use Win32 long paths.
return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath
}
function getHostRemovalOptions(): RmOptions {
const base = { recursive: true, force: true }
if (process.platform !== 'win32') {
return base
}
return {
...base,
// Why: large Windows trees commonly surface transient ENOTEMPTY/EPERM while
// Node walks and removes nested directories.
maxRetries: WINDOWS_RM_MAX_RETRIES,
retryDelay: WINDOWS_RM_RETRY_DELAY_MS
}
}
function isTransientWindowsRemovalError(error: unknown): boolean {
if (process.platform !== 'win32' || typeof error !== 'object' || error === null) {
return false
}
const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined
if (code && ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(code)) {
return true
}
const message = 'message' in error && typeof error.message === 'string' ? error.message : ''
return /directory not empty|resource busy|operation not permitted/i.test(message)
}
/** Recursively remove a host directory tree, retrying the transient Windows failures. */
export async function removeHostTree(targetPath: string): Promise<void> {
const removalPath = toHostRemovalPath(targetPath)
const retryDelays = process.platform === 'win32' ? WINDOWS_REMOVE_RETRY_DELAYS_MS : []
const rmOptions = getHostRemovalOptions()
let attempt = 0
while (true) {
try {
await rm(removalPath, rmOptions)
return
} catch (error) {
if (attempt >= retryDelays.length || !isTransientWindowsRemovalError(error)) {
throw error
}
// Why: Git/Node recursive deletes on Windows can observe a just-emptied
// directory before antivirus/indexers/handles release it.
await delay(retryDelays[attempt])
attempt += 1
}
}
}
+27 -4
View File
@@ -43,7 +43,7 @@ import { initObservability, shutdownObservability } from './observability'
import { registerMobileHandlers } from './ipc/mobile'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry'
import {
indexPersistedPaneKeyPtyIds,
isLocalExecutionHost,
@@ -51,8 +51,8 @@ import {
sweepRestoredSubagentsWithoutLiveAgent
} from './agent-hooks/restored-subagent-liveness-sweep'
import {
applyAgentStatusHooksEnabled,
isAgentStatusHooksEnabled,
MANAGED_AGENT_HOOK_INSTALLERS,
removeManagedAgentHooks
} from './agent-hooks/managed-agent-hook-controls'
import { initCohortClassifier } from './telemetry/cohort-classifier'
@@ -80,6 +80,7 @@ import { DesktopRelayService } from './runtime/relay/desktop-relay-service'
import type { RelayBrokerStatus } from './runtime/relay/relay-session-broker'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { scheduleAllPendingHistoryTreeRemovals } from './terminal-history-deletion'
import { ensureMainI18n, setMainPluginLanguagePacks, setMainUiLanguage } from './i18n/main-i18n'
import {
getNextDefaultOnAppearanceSettingValue,
@@ -1996,6 +1997,7 @@ void app.whenReady().then(async () => {
const activeOrcaProfile = ensureActiveOrcaProfile()
store = new Store({ dataFile: activeOrcaProfile.dataFile })
wslHookRelayManager.setManagedHookSettingsResolver(() => store?.getSettings() ?? null)
logStartupMilestone('store-loaded')
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
@@ -2556,7 +2558,17 @@ void app.whenReady().then(async () => {
if (shouldInstallManagedHooks(is.dev)) {
// Why: check the persisted off switch before any auto-install so removed hooks don't silently reappear on launch.
if (isAgentStatusHooksEnabled(store.getSettings())) {
runManagedHookInstallers(MANAGED_AGENT_HOOK_INSTALLERS)
const managedHookStore = store
void applyAgentStatusHooksEnabled(true, managedHookStore.getSettings(), {
shouldHydrateShellPath: app.isPackaged && process.platform !== 'win32',
onInstallError: recordManagedHookInstallFailure,
shouldContinue: (agent) => {
const settings = managedHookStore.getSettings()
return isAgentStatusHooksEnabled(settings) && !settings.disabledTuiAgents.includes(agent)
}
}).catch((error) => {
console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error)
})
} else {
removeManagedAgentHooks()
}
@@ -2791,6 +2803,9 @@ void app.whenReady().then(async () => {
}
// Why: headless serve never opens a renderer, so arm scheduled automation dispatch here.
automations.start()
// Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is
// armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch.
scheduleAllPendingHistoryTreeRemovals()
await printServeReady(serveOptions)
return
}
@@ -2917,6 +2932,13 @@ app.on('will-quit', (e) => {
killAllPty()
const watcherShutdown = shutdownWatchersOnce()
store?.flush()
// Why: usage-cache writes are queued off the main thread, so a quit right after setEnabled or a
// scan completion would drop the final snapshot. Captured before any await; joins the barrier below.
const usageCacheFlush = Promise.all([
claudeUsage?.flush(),
codexUsage?.flush(),
openCodeUsage?.flush()
]).then(() => {})
// Why: preventDefault to await disconnectDaemon's async checkpoint writes (else data lost); guard prevents an infinite quit loop on the re-fired will-quit.
if (!daemonDisconnectDone) {
@@ -2950,7 +2972,8 @@ app.on('will-quit', (e) => {
{ name: 'runtime-rpc', promise: rpcStopAndClear },
{ name: 'watchers', promise: watcherShutdown },
{ name: 'emulator', promise: emulatorShutdown },
{ name: 'plugin-hosts', promise: pluginHostShutdown }
{ name: 'plugin-hosts', promise: pluginHostShutdown },
{ name: 'usage-cache', promise: usageCacheFlush }
])
.then((pendingTeardowns) => {
if (pendingTeardowns.length > 0) {
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
MAX_FEEDBACK_IMAGE_BYTES,
MAX_FEEDBACK_IMAGE_COUNT,
appendFeedbackImagesToFormData,
feedbackImageFilename,
isSupportedFeedbackImageContentType,
validateFeedbackImages
} from './feedback-image-attachments'
function image(contentType: string, bytes = 4): { contentType: string; data: Uint8Array } {
return { contentType, data: new Uint8Array(bytes).fill(1) }
}
describe('isSupportedFeedbackImageContentType', () => {
it.each(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])('accepts %s', (contentType) => {
expect(isSupportedFeedbackImageContentType(contentType)).toBe(true)
})
it.each(['image/bmp', 'image/svg+xml', 'application/pdf', ''])('rejects %s', (contentType) => {
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
})
// Why: the allow-list is an object literal, so a prototype member must not
// pass as a content type and get spliced into the upload filename.
it.each(['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty'])(
'rejects the inherited member %s',
(contentType) => {
expect(isSupportedFeedbackImageContentType(contentType)).toBe(false)
expect(validateFeedbackImages([image(contentType)])).toBe('Unsupported image type.')
}
)
})
describe('feedbackImageFilename', () => {
it.each([
['image/png', 'feedback-image-1.png'],
['image/jpeg', 'feedback-image-1.jpg'],
['image/webp', 'feedback-image-1.webp'],
['image/gif', 'feedback-image-1.gif']
])('names a %s attachment %s', (contentType, expected) => {
expect(feedbackImageFilename(0, contentType)).toBe(expected)
})
it('numbers attachments from one', () => {
expect(feedbackImageFilename(3, 'image/png')).toBe('feedback-image-4.png')
})
})
describe('appendFeedbackImagesToFormData', () => {
it('frames only the exact typed-array view', async () => {
const backing = new Uint8Array([0, 1, 2, 3])
const view = backing.subarray(1, 3)
const formData = new FormData()
appendFeedbackImagesToFormData(formData, [{ contentType: 'image/png', data: view }])
const blob = formData.get('feedbackImage')
expect(blob).toBeInstanceOf(Blob)
expect(new Uint8Array(await (blob as Blob).arrayBuffer())).toEqual(new Uint8Array([1, 2]))
})
})
describe('validateFeedbackImages', () => {
it('accepts a full batch of supported images', () => {
expect(
validateFeedbackImages(
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT }, () => image('image/png'))
)
).toBeNull()
})
it('rejects more than the supported count', () => {
expect(
validateFeedbackImages(
Array.from({ length: MAX_FEEDBACK_IMAGE_COUNT + 1 }, () => image('image/png'))
)
).toBe(`Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`)
})
it('rejects malformed attachment collections and byte payloads', () => {
expect(validateFeedbackImages('image/png')).toBe('Image attachments must be a list.')
expect(validateFeedbackImages([null])).toBe('Invalid image attachment.')
expect(validateFeedbackImages([{ contentType: 'image/png', data: '8388608' }])).toBe(
'Invalid image attachment bytes.'
)
})
it('rejects an empty attachment', () => {
expect(validateFeedbackImages([image('image/png', 0)])).toBe('Image attachment is empty.')
})
it('rejects an attachment over the byte cap', () => {
expect(validateFeedbackImages([image('image/png', MAX_FEEDBACK_IMAGE_BYTES + 1)])).toBe(
`Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
)
})
})
@@ -0,0 +1,96 @@
import { readFetchResponseJsonWithinLimit } from '../../shared/fetch-response-body'
// Why: mirrors the server allow-list. Slack picks a renderer from the filename
// extension, so every accepted type needs one.
const FEEDBACK_IMAGE_EXTENSIONS: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif'
}
export const MAX_FEEDBACK_IMAGE_COUNT = 4
export const MAX_FEEDBACK_IMAGE_BYTES = 8 * 1024 * 1024
export const MAX_FEEDBACK_IMAGE_RESPONSE_BYTES = 64 * 1024
export const FEEDBACK_IMAGE_FORM_FIELD = 'feedbackImage'
export type FeedbackImageAttachment = {
contentType: string
data: Uint8Array
}
export function isSupportedFeedbackImageContentType(contentType: string): boolean {
// Why: `in` walks the prototype chain, so "constructor" and "__proto__" would
// clear the allow-list and name the upload after an inherited member.
return Object.hasOwn(FEEDBACK_IMAGE_EXTENSIONS, contentType)
}
export function getSupportedFeedbackImageContentTypes(): string[] {
return Object.keys(FEEDBACK_IMAGE_EXTENSIONS)
}
export function feedbackImageFilename(index: number, contentType: string): string {
return `feedback-image-${index + 1}.${FEEDBACK_IMAGE_EXTENSIONS[contentType]}`
}
/** Defence in depth: the renderer validates first, but this channel is reachable directly. */
export function validateFeedbackImages(images: unknown): string | null {
if (!Array.isArray(images)) {
return 'Image attachments must be a list.'
}
if (images.length > MAX_FEEDBACK_IMAGE_COUNT) {
return `Attach ${MAX_FEEDBACK_IMAGE_COUNT} images or fewer.`
}
for (const image of images) {
if (!image || typeof image !== 'object') {
return 'Invalid image attachment.'
}
if (typeof image.contentType !== 'string') {
return 'Invalid image attachment content type.'
}
if (!isSupportedFeedbackImageContentType(image.contentType)) {
return 'Unsupported image type.'
}
if (!(image.data instanceof Uint8Array)) {
return 'Invalid image attachment bytes.'
}
if (image.data.byteLength === 0) {
return 'Image attachment is empty.'
}
if (image.data.byteLength > MAX_FEEDBACK_IMAGE_BYTES) {
return `Each image must be ${MAX_FEEDBACK_IMAGE_BYTES} bytes or fewer.`
}
}
return null
}
export function appendFeedbackImagesToFormData(
formData: FormData,
images: FeedbackImageAttachment[]
): void {
for (const [index, image] of images.entries()) {
formData.append(
FEEDBACK_IMAGE_FORM_FIELD,
new Blob([image.data as BlobPart], { type: image.contentType }),
feedbackImageFilename(index, image.contentType)
)
}
}
/** Atomic servers omit the image field after both the text and images land. */
export async function readFeedbackImagesDelivered(response: Response): Promise<boolean> {
try {
const parsed: unknown = await readFetchResponseJsonWithinLimit(
response,
MAX_FEEDBACK_IMAGE_RESPONSE_BYTES
)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return false
}
if ('imagesDelivered' in parsed) {
return (parsed as { imagesDelivered?: unknown }).imagesDelivered === true
}
return (parsed as { ok?: unknown }).ok === true
} catch {}
return false
}
+216
View File
@@ -16,6 +16,7 @@ vi.mock('electron', () => ({
net: { fetch: (...args: unknown[]) => fetchMock(...args) }
}))
import { MAX_FEEDBACK_IMAGE_RESPONSE_BYTES } from './feedback-image-attachments'
import { registerFeedbackHandlers, submitFeedback } from './feedback'
function okResponse(): Response {
@@ -392,4 +393,219 @@ describe('submitFeedback', () => {
githubEmail: null
})
})
describe('image attachments', () => {
function pngImage(bytes = 8): { contentType: string; data: Uint8Array } {
return { contentType: 'image/png', data: new Uint8Array(bytes).fill(1) }
}
function imageSubmitArgs(
images: { contentType: string; data: Uint8Array }[]
): Parameters<typeof submitFeedback>[0] {
return {
feedback: 'images attached',
submissionType: 'feedback',
githubLogin: 'someone',
githubEmail: null,
images
}
}
function jsonResponse(body: unknown): Response {
return Response.json(body, { status: 202 })
}
it('sends attached images as multipart form parts', async () => {
await submitFeedback(imageSubmitArgs([pngImage(), pngImage()]))
const body = requestInit().body as FormData
expect(body).toBeInstanceOf(FormData)
expect(body.getAll('feedbackImage')).toHaveLength(2)
expect(body.get('feedback')).toBe('images attached')
// Why: multipart must not lose the enrichment fields the JSON lane sends.
expect(body.get('submissionType')).toBe('feedback')
expect(body.get('appVersion')).toBe('1.2.3-test')
})
it('keeps the JSON lane when nothing is attached', async () => {
await submitFeedback(imageSubmitArgs([]))
expect(requestInit().body).not.toBeInstanceOf(FormData)
expect(postedBody().feedback).toBe('images attached')
})
it('reports partial delivery when the server could not attach the images', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true, imagesDelivered: false }))
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
ok: true,
imagesDelivered: false
})
})
it('accepts the production atomic-success response when it omits the image result', async () => {
fetchMock.mockResolvedValue(jsonResponse({ ok: true }))
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
ok: true,
imagesDelivered: true
})
})
it('reports unconfirmed delivery for a settled non-JSON 2xx', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 202,
json: async () => {
throw new SyntaxError('Unexpected token')
}
} as unknown as Response)
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
ok: true,
imagesDelivered: false
})
})
it('reports unconfirmed delivery when the response body aborts before the deadline', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 202,
json: async () => {
throw new TypeError('terminated')
}
} as unknown as Response)
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
ok: true,
imagesDelivered: false
})
expect(requestInit().signal).toMatchObject({ aborted: false })
})
it('bounds the image-delivery response body', async () => {
fetchMock.mockResolvedValue(
new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 })
)
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
ok: true,
imagesDelivered: false
})
})
it('fails a stalled delivery response body at the attachment timeout', async () => {
vi.useFakeTimers()
fetchMock.mockImplementation((_url: string, init?: RequestInit) =>
Promise.resolve({
ok: true,
status: 202,
json: () =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('body aborted')))
})
} as unknown as Response)
)
const result = submitFeedback(imageSubmitArgs([pngImage()]))
await vi.advanceTimersByTimeAsync(60_000)
await expect(result).resolves.toEqual({
ok: false,
status: null,
error: 'request timed out after 60 seconds'
})
expect(requestInit().signal).toMatchObject({ aborted: true })
})
it('rejects unsupported image types before any request is made', async () => {
const result = await submitFeedback(
imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }])
)
expect(result.ok).toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
// Why: the renderer screens types first, so this lane only matters for a
// renderer invoking the channel directly — the case the handler guards.
it('rejects a prototype member posing as a content type over IPC', async () => {
registerFeedbackHandlers()
const result = (await handlers.get('feedback:submit')?.(null, {
feedback: 'images attached',
githubLogin: null,
githubEmail: null,
images: [{ contentType: 'constructor', data: new Uint8Array(4).fill(1) }]
})) as { ok: boolean; error?: string }
expect(result).toMatchObject({ ok: false, error: 'Unsupported image type.' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects malformed IPC bytes before typed-array normalization', async () => {
registerFeedbackHandlers()
const result = (await handlers.get('feedback:submit')?.(null, {
feedback: 'images attached',
githubLogin: null,
githubEmail: null,
images: [{ contentType: 'image/png', data: '8388608' }]
})) as { ok: boolean; error?: string }
expect(result).toMatchObject({ ok: false, error: 'Invalid image attachment bytes.' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects oversized IPC batches before normalizing their entries', async () => {
registerFeedbackHandlers()
const result = (await handlers.get('feedback:submit')?.(null, {
feedback: 'images attached',
githubLogin: null,
githubEmail: null,
images: Array.from({ length: 5 }, () => ({
contentType: 'image/png',
data: '8388608'
}))
})) as { ok: boolean; error?: string }
expect(result).toMatchObject({ ok: false, error: 'Attach 4 images or fewer.' })
expect(fetchMock).not.toHaveBeenCalled()
})
it('rejects more images than the supported count', async () => {
const result = await submitFeedback(
imageSubmitArgs(Array.from({ length: 5 }, () => pngImage()))
)
expect(result.ok).toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
it('does not fail a crash report over images it was never going to send', async () => {
// Why: the crash lane discards images, so validating them there would
// abort a crash report the user needs delivered.
await submitFeedback({
...diagnosticSubmitArgs(),
images: Array.from({ length: 9 }, () => ({
contentType: 'application/pdf',
data: new Uint8Array(0)
}))
} as Parameters<typeof submitFeedback>[0])
expect(fetchMock).toHaveBeenCalledTimes(1)
const body = requestInit().body as FormData
expect(body.getAll('feedbackImage')).toHaveLength(0)
expect(body.get('submissionType')).toBe('crash')
})
it('drops images from crash submissions', async () => {
await submitFeedback({
...diagnosticSubmitArgs(),
images: [pngImage()]
} as Parameters<typeof submitFeedback>[0])
const body = requestInit().body as FormData
expect(body.getAll('feedbackImage')).toHaveLength(0)
expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop')
})
})
})
+98 -28
View File
@@ -1,5 +1,13 @@
import os from 'node:os'
import { app, ipcMain, net } from 'electron'
import {
appendFeedbackImagesToFormData,
readFeedbackImagesDelivered,
validateFeedbackImages,
type FeedbackImageAttachment
} from './feedback-image-attachments'
export type { FeedbackImageAttachment }
// Why: the production Mac build loads the renderer from a file:// origin, so a
// cross-origin POST from fetch() triggers a CORS preflight that the feedback
@@ -21,6 +29,7 @@ export type FeedbackSubmitArgs = {
submitAnonymously?: boolean
githubLogin: string | null
githubEmail: string | null
images?: FeedbackImageAttachment[]
}
export type FeedbackDiagnosticBundleAttachment = {
@@ -40,6 +49,7 @@ type FeedbackSubmitBody = {
osRelease: string
arch: string
diagnosticBundle?: FeedbackDiagnosticBundleAttachment
images?: FeedbackImageAttachment[]
}
export type FeedbackRequestFailure = {
@@ -48,7 +58,12 @@ export type FeedbackRequestFailure = {
}
export type FeedbackSubmitResult =
| { ok: true; diagnosticBundleFailure?: FeedbackRequestFailure }
| {
ok: true
diagnosticBundleFailure?: FeedbackRequestFailure
/** Absent when nothing was attached; false when the text landed but the images did not. */
imagesDelivered?: boolean
}
| ({ ok: false } & FeedbackRequestFailure & {
diagnosticBundleFailure?: FeedbackRequestFailure
})
@@ -81,18 +96,21 @@ function buildSubmitBody(args: InternalFeedbackSubmitArgs): FeedbackSubmitBody {
arch: process.arch,
...(args.submissionType === 'crash' && args.diagnosticBundle
? { diagnosticBundle: args.diagnosticBundle }
: {})
: {}),
// Why: images are a feedback-only affordance; crash reports already carry
// diagnostic bundles and the server rejects images on that lane.
...(args.submissionType !== 'crash' && args.images?.length ? { images: args.images } : {})
}
}
async function postFeedback(
url: string,
body: FeedbackSubmitBody,
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS
timeoutMs = FEEDBACK_REQUEST_TIMEOUT_MS,
readResponse?: (response: Response) => Promise<void>
): Promise<Response> {
const controller = new AbortController()
// Why: a silent feedback endpoint should not leave IPC or crash-report
// submission flows pending forever.
// Why: a silent endpoint must not leave feedback IPC pending forever.
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const init: RequestInit = {
@@ -100,10 +118,18 @@ async function postFeedback(
...feedbackRequestBodyInit(body),
signal: controller.signal
}
return await net.fetch(url, init)
const response = await net.fetch(url, init)
if (readResponse) {
await readResponse(response)
}
// Why: a response parser may tolerate malformed legacy bodies, but it must
// not turn the deadline's aborted body into a confirmed delivery.
if (controller.signal.aborted) {
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
}
return response
} catch (error) {
// Why: Electron and Node use different AbortError messages. Normalize our
// client deadline so support logs explain which request budget expired.
// Why: Electron and Node report AbortError differently; keep deadline logs stable.
if (controller.signal.aborted) {
throw new Error(`request timed out after ${timeoutMs / 1000} seconds`)
}
@@ -114,7 +140,7 @@ async function postFeedback(
}
function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'body' | 'headers'> {
if (!body.diagnosticBundle) {
if (!body.diagnosticBundle && !body.images?.length) {
return {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -130,22 +156,25 @@ function feedbackRequestBodyInit(body: FeedbackSubmitBody): Pick<RequestInit, 'b
appendFeedbackFormField(formData, 'platform', body.platform)
appendFeedbackFormField(formData, 'osRelease', body.osRelease)
appendFeedbackFormField(formData, 'arch', body.arch)
appendFeedbackFormField(
formData,
'diagnosticBundleSubmissionId',
body.diagnosticBundle.bundleSubmissionId
)
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
appendFeedbackFormField(
formData,
'diagnosticBundleSpanCount',
String(body.diagnosticBundle.spanCount)
)
formData.append(
'diagnosticBundleFile',
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
)
if (body.diagnosticBundle) {
appendFeedbackFormField(
formData,
'diagnosticBundleSubmissionId',
body.diagnosticBundle.bundleSubmissionId
)
appendFeedbackFormField(formData, 'diagnosticBundleBytes', String(body.diagnosticBundle.bytes))
appendFeedbackFormField(
formData,
'diagnosticBundleSpanCount',
String(body.diagnosticBundle.spanCount)
)
formData.append(
'diagnosticBundleFile',
new Blob([body.diagnosticBundle.content], { type: DIAGNOSTIC_BUNDLE_CONTENT_TYPE }),
`orca-diagnostics-${body.diagnosticBundle.bundleSubmissionId}.ndjson`
)
}
appendFeedbackImagesToFormData(formData, body.images ?? [])
// Why: multipart avoids JSON-escaping a near-cap NDJSON bundle over the
// backend request limit while still submitting one feedback request.
@@ -253,7 +282,37 @@ async function submitFeedbackWithDiagnosticBundle(
export async function submitFeedback(
args: InternalFeedbackSubmitArgs
): Promise<FeedbackSubmitResult> {
// Why: buildSubmitBody drops images on the crash lane, so validating them
// there would abort a crash report over attachments it never meant to send.
if (args.submissionType !== 'crash' && args.images !== undefined) {
const imageError = validateFeedbackImages(args.images)
if (imageError) {
return { ok: false, status: null, error: imageError }
}
}
const body = buildSubmitBody(args)
if (body.images?.length) {
try {
let imagesDelivered = true
const response = await postFeedback(
FEEDBACK_API_URL,
body,
FEEDBACK_ATTACHMENT_REQUEST_TIMEOUT_MS,
async (nextResponse) => {
imagesDelivered = nextResponse.ok ? await readFeedbackImagesDelivered(nextResponse) : true
}
)
if (response.ok) {
return { ok: true, imagesDelivered }
}
// Why: the text lane retries 5xx, this one does not. Replaying up to
// 32 MiB of attachments on a flaky link costs more than it saves, and the
// dialog keeps the draft and thumbnails so the user can resend.
return { ok: false, ...responseFailure(response) }
} catch (error) {
return { ok: false, ...errorFailure(error) }
}
}
if (body.diagnosticBundle) {
const bodyWithoutDiagnosticBundle =
args.feedbackWithoutDiagnosticBundle !== undefined
@@ -283,9 +342,20 @@ export async function submitFeedback(
export function registerFeedbackHandlers(): void {
ipcMain.removeHandler('feedback:submit')
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) =>
ipcMain.handle('feedback:submit', (_event, args: FeedbackSubmitArgs) => {
// Why: validate the raw clone before normalization so a tiny hostile value
// cannot become a large main-process typed-array allocation.
if (args.images !== undefined) {
const imageError = validateFeedbackImages(args.images)
if (imageError) {
return { ok: false, status: null, error: imageError }
}
}
// Why: crash submissions are main-only. A compromised renderer can invoke
// this channel directly, so force the public feedback lane at the boundary.
submitFeedback({ ...args, submissionType: 'feedback' })
)
return submitFeedback({
...args,
submissionType: 'feedback'
})
})
}
+67
View File
@@ -171,6 +171,36 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('serializes concurrent local renames targeting the same destination', async () => {
const firstPath = path.resolve('/workspace/repo/first.ts')
const secondPath = path.resolve('/workspace/repo/second.ts')
const destinationPath = path.resolve('/workspace/repo/destination.ts')
let destinationExists = false
lstatMock.mockImplementation(async (filePath: string) => {
if (filePath === destinationPath && destinationExists) {
return mockStats(1, 30)
}
if (filePath === firstPath) {
return mockStats(1, 10)
}
if (filePath === secondPath) {
return mockStats(1, 20)
}
throw enoent()
})
renameMock.mockImplementation(async () => {
destinationExists = true
})
const results = await Promise.allSettled([
handlers.get('fs:rename')!(null, { oldPath: firstPath, newPath: destinationPath }),
handlers.get('fs:rename')!(null, { oldPath: secondPath, newPath: destinationPath })
])
expect(results.map(({ status }) => status).sort()).toEqual(['fulfilled', 'rejected'])
expect(renameMock).toHaveBeenCalledTimes(1)
})
it('rejects rename when destination already exists as a true collision', async () => {
const oldPath = path.resolve('/workspace/repo/old.ts')
const resolvedNewPath = path.resolve('/workspace/repo/new.ts')
@@ -197,12 +227,30 @@ describe('registerFilesystemMutationHandlers', () => {
it('allows case-only rename when destination is the same entry in the same parent', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
const canonicalPath = path.resolve('/workspace/repo/README.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 20)
}
throw enoent()
})
mockRealpath({ [oldPath]: canonicalPath, [newPath]: canonicalPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
expect(renameMock).toHaveBeenCalledWith(oldPath, newPath)
})
it('allows a native Unicode alias rename for the same directory entry', async () => {
const oldPath = path.resolve('/workspace/repo/Straße.md')
const newPath = path.resolve('/workspace/repo/STRASSE.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(2, 21)
}
throw enoent()
})
mockRealpath({ [oldPath]: oldPath, [newPath]: oldPath })
await handlers.get('fs:rename')!(null, { oldPath, newPath })
@@ -226,6 +274,25 @@ describe('registerFilesystemMutationHandlers', () => {
expect(renameMock).not.toHaveBeenCalled()
})
it('fails closed when same-entry realpath encounters a permission error', async () => {
const oldPath = path.resolve('/workspace/repo/README.md')
const newPath = path.resolve('/workspace/repo/readme.md')
lstatMock.mockImplementation(async (p: string) => {
if (p === oldPath || p === newPath) {
return mockStats(3, 30)
}
throw enoent()
})
realpathMock.mockRejectedValue(
Object.assign(new Error('permission denied'), { code: 'EACCES' })
)
await expect(handlers.get('fs:rename')!(null, { oldPath, newPath })).rejects.toMatchObject({
code: 'EACCES'
})
expect(renameMock).not.toHaveBeenCalled()
})
it('rejects cross-parent case-only rename collisions even when dev and ino match', async () => {
const oldPath = path.resolve('/workspace/repo/src/README.md')
const newPath = path.resolve('/workspace/repo/docs/readme.md')
+2 -4
View File
@@ -9,7 +9,6 @@ import {
open,
readdir,
realpath,
rename,
rm,
unlink,
writeFile
@@ -21,9 +20,9 @@ import { authorizeExternalPath, resolveAuthorizedPath, isENOENT } from './filesy
import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { resolveLocalDroppedPathsForAgent } from './dropped-path-resolution'
import { importExternalPathsSsh } from './filesystem-import-ssh'
import { assertNoClobberRenameDestinationAvailable } from '../../shared/filesystem-rename-collision'
import type { SshMutationExpectation } from '../../shared/ssh-types'
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename'
/**
* Re-throw filesystem errors with user-friendly messages.
@@ -146,8 +145,7 @@ export function registerFilesystemMutationHandlers(store: Store): void {
// accidentally write into a symlinked destination name.
const oldPath = await resolveAuthorizedPath(args.oldPath, store, { preserveSymlink: true })
const newPath = await resolveAuthorizedPath(args.newPath, store, { preserveSymlink: true })
await assertNoClobberRenameDestinationAvailable(oldPath, newPath)
await rename(oldPath, newPath)
await renameLocalPathSerializedByDestination(oldPath, newPath)
}
)

Some files were not shown because too many files have changed in this diff Show More