diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9f63560d6d2..2640357d6a2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,25 +2,13 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/main/agent-hooks/server.ts -inline src/main/browser/agent-browser-bridge.ts -inline src/main/browser/browser-cookie-import.ts -inline src/main/browser/browser-manager.ts -inline src/main/codex-accounts/runtime-home-service.ts -inline src/main/index.ts -inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/rate-limits/service.ts -inline src/main/runtime/orca-runtime-browser.ts -inline src/main/runtime/orca-runtime-files.ts inline src/main/runtime/orca-runtime.test.ts inline src/main/runtime/orca-runtime.ts -inline src/main/runtime/rpc/methods/orchestration.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts inline src/main/ssh/ssh-relay-session.ts -inline src/main/updater.ts inline src/relay/pty-handler.ts inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts mobile-config app/h/*/files/*.tsx diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 1abcf84ef64..4213d360a41 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -57,8 +57,11 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', // both ends of the contract are only readable statically. Without this leg the rewrite could // emit a name nothing reads and every behavioural assertion above would still pass. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = mainSource.indexOf('function getServeOptions(') + const mainSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const start = mainSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) const end = mainSource.indexOf('\n}', start) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 11c1088315f..f03484f14f9 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -1,3526 +1,30 @@ -/* eslint-disable max-lines -- Why: this file owns the loopback HTTP adapter, the on-disk last-status persistence layer (hydrate, sanitize, TTL, atomic write, drop), and the relay ingest path in one place so the cache lifecycle (set → schedule → drain) lives next to the surfaces that mutate it. Splitting would force mutual `private` accessor scaffolding for a single class. */ -// Why: this main-process adapter keeps listener internals in shared/ (`src/shared/agent-hook-listener.ts`) so the relay can host the same pipeline without Electron; parsing that drifts back into this file stops applying to SSH panes. -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' -import { createHash, randomBytes, randomUUID } from 'node:crypto' -import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { track } from '../telemetry/client' -import { getCohortAtEmit } from '../telemetry/cohort-classifier' -import { AGENT_KIND_VALUES, type AgentKind } from '../../shared/telemetry-events' -import { - ORCA_HOOK_PROTOCOL_VERSION, - ORCA_HOOK_RAW_JSON_TRANSPORT -} from '../../shared/agent-hook-types' -import { - clearAllListenerCaches, - clearPaneCacheState, - paneHasStateClaims, - createHookListenerState, - movePaneCacheState, - type HookListenerState -} from '../../shared/agent-hook-listener/listener-state' -import { - clearClaudeAnsweredQuestionWait, - markClaudeLeadTurnInterrupted, - reapRestoredClaudeSubagentsForDeadPane, - seedClaudeLeadTurnFromPersistedStatus, - seedClaudeSubagentRosterFromSnapshots -} from '../../shared/agent-hook-listener/providers/claude-roster-state' -import { - getEndpointFileName, - writeEndpointFile -} from '../../shared/agent-hook-listener/endpoint-publication' -import { - hasCodexTranscriptSubagents, - markCodexLeadTurnInterrupted, - reconcileRemoteCodexState, - seedCodexStateFromSnapshot -} from '../../shared/agent-hook-listener/providers/codex-state' -import { - hasPendingAgentResultText, - preparePendingGrokResultDiscovery -} from '../../shared/agent-hook-listener/grok-result-discovery' -import { - HOOK_REQUEST_SLOWLORIS_MS, - MAX_PANE_KEY_LEN, - normalizeClaudePromptId, - warnOnHookEnvOrVersionMismatch -} from '../../shared/agent-hook-listener/listener-limits' -import { isNewTurnEvent } from '../../shared/agent-hook-listener/provider-event-routing' +// This main-process adapter keeps listener internals in shared/ so the relay can host the same pipeline without Electron. +import { clearAllListenerCaches } from '../../shared/agent-hook-listener/listener-state' import { normalizeHookPayload } from '../../shared/agent-hook-listener' -import { mergeAgentHookRequestHeaders } from '../../shared/agent-hook-listener/hook-envelope' -import { - parseFormEncodedBody, - readRequestBody -} from '../../shared/agent-hook-listener/request-body' -import { resolveHookSource } from '../../shared/agent-hook-listener/source-routing' +import { parseFormEncodedBody } from '../../shared/agent-hook-listener/request-body' import type { AgentHookEventPayload } from '../../shared/agent-hook-listener/listener-event' -import { - canAcceptClaudeCompactCompletion, - isClaudeCompactCompletionConsumed, - markClaudeCompactCompletionConsumed, - resolveLegacyCompactTrigger -} from '../../shared/claude-compact-completion' -import { - createHookTransportInterferenceTracker, - describeHookTransportInterference, - isHookRequestTruncatedError, - type HookTransportInterferenceReport -} from '../../shared/agent-hook-transport-interference' -import { - claudeTeammateIdMatchesName, - claudeRosterHasRestoredSnapshotSubagent, - claudeRosterHasWorkingSubagent, - claudeRosterToSnapshots -} from '../../shared/claude-subagent-roster' -import { - isAgentHookSource, - restoreShedStatusFields, - type AgentHookSource -} from '../../shared/agent-hook-relay' -import { - CLAUDE_STATUSLINE_PATHNAME, - parseClaudeStatusLineBody, - type ClaudeStatusLineRateLimits -} from '../../shared/claude-statusline-rate-limits' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusClearIpcPayload, - type AgentStatusIpcPayload, - type AgentType, - type AgentStatusState, - type ParsedAgentStatusPayload, - normalizeAgentStatusPayload -} from '../../shared/agent-status-types' -import { terminalStatusPayloadMatchesHook } from '../../shared/agent-terminal-status-equivalence' -import { - AgentStatusObservationSequencer, - createAgentStatusAuthorityId, - type AgentStatusObservation, - type AgentStatusObservationOrigin -} from '../../shared/agent-status-observation' -import { - resolveAgentStatusIdentity, - shouldSuppressInheritedTerminalStatus -} from '../../shared/agent-status-identity' -import { - isAgentInterruptInputIntent, - type AgentInterruptInferenceRequest -} from '../../shared/agent-interrupt-intent' -import { - isAskUserQuestionTool, - type AgentQuestionAnsweredInferenceRequest -} from '../../shared/agent-question-answered-intent' -import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../shared/pane-key-alias' -import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import type { LegacyPaneKeyAliasEntry } from '../../shared/persisted-state-types' -import { - getAgentResumeArgv, - normalizeAgentProviderSession, - type AgentProviderSessionMetadata -} from '../../shared/agent-session-resume' -import { isCommandCodeNewTurnWhileWorking } from '../../shared/command-code-turn-boundary' -import { - buildSpoolHookBody, - drainAgentHookSpool, - launchTokenHash, - type SpoolRecord -} from '../../shared/agent-hook-spool' -import { CodexSubagentPollScheduler } from '../../shared/codex-subagent-poll-scheduler' +import type { AgentHookSource } from '../../shared/agent-hook-relay' +import { AgentHookServerLifecycle } from './server/server-lifecycle' +import { isValidPaneKey } from './server/server-status-identity' +export type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload +} from './server/server-types' export type { AgentHookSource } - -// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). -type EnrichedAgentHookEventPayload = AgentHookEventPayload & { - receivedAt: number - stateStartedAt: number - /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ - observation?: AgentStatusObservation - /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ - restoredUnconfirmed?: true - /** User-hidden resume identity retained solely for destructive liveness checks. */ - retainedForLiveness?: true - /** Persisted proof that a lead boundary was held working only by child agents. */ - claudeLeadBoundaryChildOnly?: true -} - -type NormalizedLocalHook = { - event: AgentHookEventPayload | null - onAccepted?: () => void -} - -type PersistedAgentHookEventPayload = Omit< - EnrichedAgentHookEventPayload, - | 'claudeRunningNonAgentTask' - | 'launchToken' - | 'promptInteractionKey' - | 'restoredUnconfirmed' - // Why: revision counters are in-memory and the authority id is regenerated per process, so - // a stored observation could only rehydrate as a stale ordering claim from a dead authority. - | 'observation' -> & { - launchTokenHash?: string -} - -type PersistedAgentHookAuthorityCommitment = { - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -} - -export type AgentHookStatusChangeEntry = { - state: AgentStatusState - receivedAt: number - observedInCurrentRuntime: boolean -} - -export type AgentHookProviderSessionIdentity = { - paneKey: string - sessionId: string - transcriptPath?: string - worktreeId?: string -} - -export type AgentHookAuthorityEvidence = Readonly<{ - paneKey: string - launchTokenHash: string - connectionId: string | null - tabId?: string - worktreeId?: string - observedAt: number -}> - -export type AgentHookAuthorityAttestation = Readonly<{ - paneKey: string - source: 'current_hook' | 'hydrated_commitment' -}> - -type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void -type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void -type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void -type StatusDropListener = (paneKey: string) => void -type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void -type PaneKeyAliasEntry = { - stablePaneKey: string - ptyId: string | null - updatedAt: number - authorityVerified: boolean -} -type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } -/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ -type RetiredPaneFence = { - paneKeys: readonly string[] - aliases: readonly RetiredPaneAlias[] -} - -// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. -const LAST_STATUS_FILE_NAME = 'last-status.json' -const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 -const ASSISTANT_MESSAGE_RETRY_MS = 50 -const CODEX_SUBAGENT_POLL_MS = 1_000 -const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 - -type CodexSubagentPoll = { - source: AgentHookSource - body: unknown - original: EnrichedAgentHookEventPayload -} - -// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). -const LAST_STATUS_FILE_VERSION = 2 - -// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. -const STATUS_PERSIST_DEBOUNCE_MS = 250 -const TOOL_PROGRESS_HOOK_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) -const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) - -// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. -const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 - -// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. -export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 -export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 -export const PANE_KEY_ALIASES_MAX = 1024 -export const RETIRED_PANE_FENCES_MAX = 1024 - -type LastStatusFile = { - version: number - entries: Record - authorityCommitments?: Record -} - -type AgentPromptSentDedupeEntry = { - agentKind: AgentKind - promptHash: string - promptInteractionKey?: string -} - -function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { - const normalized = agentType?.trim().toLowerCase() - if (!normalized || normalized === 'unknown') { - return 'other' - } - if (normalized === 'claude') { - return 'claude-code' - } - return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) - ? (normalized as AgentKind) - : 'other' -} - -function equivalentInterruptAgentType( - actual: AgentType | undefined, - baseline: AgentType | undefined -): boolean { - const normalizedActual = actual === 'unknown' ? undefined : actual - const normalizedBaseline = baseline === 'unknown' ? undefined : baseline - return normalizedActual === normalizedBaseline -} - -// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. -export function isValidPaneKey(value: unknown): value is string { - return ( - typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null - ) -} - -function dropHydratedIdleClaudeSubagents( - payload: ParsedAgentStatusPayload -): ParsedAgentStatusPayload { - if ( - payload.agentType !== 'claude' || - !payload.subagents?.some((subagent) => subagent.state === 'idle') - ) { - return payload - } - const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. - return { - ...payload, - subagents: activeSubagents.length > 0 ? activeSubagents : undefined - } -} - -// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. -function isValidPiProviderSessionOnly( - providerSession: AgentProviderSessionMetadata | undefined, - agentType: AgentType | undefined -): boolean { - return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) -} - -function sanitizeHydratedEntry( - paneKey: string, - rawEntry: unknown -): EnrichedAgentHookEventPayload | null { - const parsedPaneKey = parsePaneKey(paneKey) - if (!parsedPaneKey) { - return null - } - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - if (record.paneKey !== paneKey) { - return null - } - const tabId = record.tabId - if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { - return null - } - // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. - if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { - return null - } - const worktreeId = record.worktreeId - if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { - return null - } - const receivedAt = record.receivedAt - if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { - return null - } - const stateStartedAt = record.stateStartedAt - if ( - typeof stateStartedAt !== 'number' || - !Number.isFinite(stateStartedAt) || - stateStartedAt <= 0 - ) { - return null - } - // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. - const connectionIdRaw = record.connectionId - let connectionId: string | null - if (connectionIdRaw === null || connectionIdRaw === undefined) { - connectionId = null - } else if (typeof connectionIdRaw === 'string') { - connectionId = connectionIdRaw - } else { - return null - } - const payload = normalizeAgentStatusPayload(record.payload) - if (!payload) { - return null - } - const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined - const providerSessionOnly = record.providerSessionOnly === true - const retainedForLiveness = record.retainedForLiveness === true - const validRetainedIdentity = Boolean( - retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' - ) - if ( - providerSessionOnly && - !isValidPiProviderSessionOnly(providerSession, payload.agentType) && - !validRetainedIdentity - ) { - return null - } - const source = isAgentHookSource(record.source) ? record.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') - ? record.compactTrigger - : undefined - return { - paneKey, - source, - tabId: typeof tabId === 'string' ? tabId : undefined, - worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, - connectionId, - hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, - hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, - providerPromptId, - compactTrigger, - toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, - toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, - teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, - toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, - claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, - providerSession, - providerSessionOnly: providerSessionOnly ? true : undefined, - retainedForLiveness: retainedForLiveness ? true : undefined, - payload, - receivedAt, - stateStartedAt - } -} - -function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { - if (typeof rawEntry !== 'object' || rawEntry === null) { - return null - } - const record = rawEntry as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { - return launchTokenHash - } - const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' - return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null -} - -function sanitizePersistedAuthorityCommitment( - paneKey: string, - value: unknown -): AgentHookAuthorityEvidence | null { - if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { - return null - } - const record = value as Record - const launchTokenHash = - typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' - const connectionId = record.connectionId - const observedAt = record.observedAt - if ( - !/^[a-f0-9]{64}$/.test(launchTokenHash) || - (connectionId !== null && typeof connectionId !== 'string') || - typeof observedAt !== 'number' || - !Number.isFinite(observedAt) - ) { - return null - } - return Object.freeze({ - paneKey, - launchTokenHash, - connectionId, - ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), - ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), - observedAt - }) -} - -function authorityCommitmentsMatch( - left: AgentHookAuthorityEvidence, - right: AgentHookAuthorityEvidence -): boolean { - return ( - left.paneKey === right.paneKey && - left.launchTokenHash === right.launchTokenHash && - left.connectionId === right.connectionId && - left.tabId === right.tabId && - left.worktreeId === right.worktreeId - ) -} - -function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentStatusIpcPayload { - return { - paneKey: entry.paneKey, - ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), - tabId: entry.tabId, - worktreeId: entry.worktreeId, - connectionId: entry.connectionId, - receivedAt: entry.receivedAt, - stateStartedAt: entry.stateStartedAt, - ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), - ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), - ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), - ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(entry.observation ? { observation: entry.observation } : {}), - ...entry.payload - } -} - -function trackEmptyPaneKeyHook(body: unknown): void { - if (typeof body !== 'object' || body === null) { - return - } - const paneKey = (body as Record).paneKey - if (typeof paneKey === 'string' && paneKey.trim().length > 0) { - return - } - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) -} - -function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { - if (next.payload.state !== 'working') { - return false - } - if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { - return false - } - // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. - return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) -} - -function paneCacheKeyTabId(key: string): string | null { - const paneKey = key.split('\0', 1)[0] ?? key - return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null -} - -function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { - return paneCacheKeyTabId(key) === tabId -} - -function attachClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { - const establishesBoundary = - next.payload.agentType === 'claude' && - (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && - !next.toolAgentId && - next.payload.state === 'working' && - next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && - next.claudeRunningNonAgentTask === false - const carriesBoundary = - previous?.claudeLeadBoundaryChildOnly === true && - next.payload.agentType === 'claude' && - next.claudeRunningNonAgentTask === false && - (next.toolAgentId !== undefined || - next.hookEventName === 'SubagentStart' || - next.hookEventName === 'SubagentStop' || - next.hookEventName === 'TeammateIdle') - return establishesBoundary || carriesBoundary - ? { ...next, claudeLeadBoundaryChildOnly: true } - : next -} - -function invalidateClaudeChildOnlyBoundary( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): EnrichedAgentHookEventPayload | undefined { - if ( - previous?.claudeLeadBoundaryChildOnly !== true || - attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true - ) { - return previous - } - const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous - return withoutBoundary -} - -function shouldKeepClaudePermissionVisible( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if (previous?.restoredUnconfirmed) { - return false - } - if ( - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'waiting' || - previous.hookEventName !== 'PermissionRequest' || - next.payload.agentType !== 'claude' || - next.payload.state !== 'working' - ) { - return false - } - if (next.hasExplicitPrompt === true) { - return false - } - if (isClaudePermissionOwningChildEnding(previous, next)) { - return false - } - if (isClaudePermissionResumingApprovedTool(previous, next)) { - return false - } - // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. - if (isAskUserQuestionTool(previous.payload.toolName)) { - return false - } - return true -} - -function isClaudePermissionOwningChildEnding( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const ownerId = previous.toolAgentId?.trim() - if (!ownerId) { - return false - } - if (next.hookEventName === 'SubagentStop') { - return ownerId === next.toolAgentId?.trim() - } - return ( - next.hookEventName === 'TeammateIdle' && - next.teammateName !== undefined && - claudeTeammateIdMatchesName(ownerId, next.teammateName) - ) -} - -function isClaudePermissionResumingApprovedTool( - previous: EnrichedAgentHookEventPayload, - next: AgentHookEventPayload -): boolean { - const previousToolUseId = previous.toolUseId?.trim() || undefined - const nextToolUseId = next.toolUseId?.trim() || undefined - const previousAgentId = previous.toolAgentId?.trim() || undefined - const nextAgentId = next.toolAgentId?.trim() || undefined - const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined - const previousAgentType = previous.toolAgentType?.trim() || undefined - const nextAgentType = next.toolAgentType?.trim() || undefined - const hasMatchingConcreteAgentId = - previousAgentId !== undefined && previousAgentId === nextAgentId - const hasSameExplicitAgentType = - !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType - const sameToolName = - previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownInputFromConcreteAgent = - hasMatchingConcreteAgentId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - const hasMatchingToolUseId = - previousToolUseId !== undefined && previousToolUseId === nextToolUseId - const hasConflictingToolUseId = - previousToolUseId !== undefined && - nextToolUseId !== undefined && - previousToolUseId !== nextToolUseId - const sameUnknownInputFromToolUseId = - hasMatchingToolUseId && - previous.payload.toolInput === undefined && - next.payload.toolInput === undefined - - return ( - (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && - nextToolUseId !== undefined && - !hasConflictingToolUseId && - // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. - (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && - sameToolName && - (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) - ) -} - -function shouldInheritClaudeToolUseIdForPermission( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): boolean { - if ( - previous?.restoredUnconfirmed || - previous?.payload.agentType !== 'claude' || - previous.payload.state !== 'working' || - previous.hookEventName !== 'PreToolUse' || - typeof previous.toolUseId !== 'string' || - previous.toolUseId.trim().length === 0 || - next.payload.agentType !== 'claude' || - next.payload.state !== 'waiting' || - next.hookEventName !== 'PermissionRequest' || - next.toolUseId !== undefined - ) { - return false - } - const sameKnownToolInput = - previous.payload.toolInput !== undefined && - previous.payload.toolInput === next.payload.toolInput - const sameUnknownToolInput = - previous.payload.toolInput === undefined && next.payload.toolInput === undefined - if ( - previous.toolAgentId !== next.toolAgentId || - previous.toolAgentType !== next.toolAgentType || - previous.payload.toolName === undefined || - previous.payload.toolName !== next.payload.toolName || - (!sameKnownToolInput && !sameUnknownToolInput) - ) { - return false - } - return true -} - -function attachClaudePermissionToolUseId( - previous: EnrichedAgentHookEventPayload | undefined, - next: AgentHookEventPayload -): AgentHookEventPayload { - const inheritedToolUseId = previous?.toolUseId - if ( - !shouldInheritClaudeToolUseIdForPermission(previous, next) || - typeof inheritedToolUseId !== 'string' - ) { - return next - } - return { - ...next, - // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. - toolUseId: inheritedToolUseId - } -} - -export class AgentHookServer { - private server: ReturnType | null = null - private port = 0 - private token = '' - // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. - private env = 'production' - private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null - private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null - private onPaneStatusCleared: PaneStatusClearListener | null = null - private paneStatusClearListeners = new Set() - private statusDropListeners = new Set() - private statusChangeListeners = new Set() - private providerSessionChangeListeners = new Set() - // Why: setListener is a single slot owned by the main-window fanout; the - // plugin event bus (and future consumers) need an additive subscription - // that also works in headless serve, where no window listener exists. - private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() - // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). - private endpointDir: string | null = null - private endpointFilePathCache: string | null = null - private endpointFileWritten = false - // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. - private state: HookListenerState = createHookListenerState() - private onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null - private transportInterference = createHookTransportInterferenceTracker((report) => { - console.warn(describeHookTransportInterference(report)) - this.onTransportInterference?.(report) - }) - // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. - private runtimeObservedStatusPaneKeys = new Set() - private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) - private hydratedLaunchTokenHashByPaneKey = new Map() - private persistedAuthorityCommitmentsByPaneKey = new Map() - private revokedHydratedAuthorityCommitments = new WeakSet() - private currentAuthorityObservations = new Map() - private legacyPaneKeyAliases = new Map() - // Why: indexed by every key the retirement fenced, so a re-attach on any of them - // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps - // it mirrors; an evicted record simply degrades to lifting the key it was handed. - private retiredPaneFencesByKey = new Map() - private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null - // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. - private lastStatusFilePath: string | null = null - // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. - private statusPersistTimer: ReturnType | null = null - private assistantMessageRetryTimers = new Map>() - private codexSubagentPollScheduler = new CodexSubagentPollScheduler( - CODEX_SUBAGENT_POLL_MS, - (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) - ) - private promptSentDedupeByPaneKey = new Map() - private activeHookTurnCompletedAtByPaneKey = new Map() - private promptSentHashSalt = randomBytes(16).toString('hex') - private closedAgentStatusTabIds = new Set() - private closedAgentStatusPaneKeys = new Set() - private restartedStatusLaunchTokenHashByPaneKey = new Map() - private connectionTimestampWatermarkById = new Map() - // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. - private lastWrittenJson: string | null = null - // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own - // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. - private readonly observations = new AgentStatusObservationSequencer( - createAgentStatusAuthorityId('main-agent-hooks') - ) - - /** - * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). - * Why: the listener fails open on every request error, so without this the only symptom is - * agent status quietly going stale — for every runtime at once, since they share this transport. - */ - setTransportInterferenceListener( - listener: ((report: HookTransportInterferenceReport) => void) | null - ): void { - this.onTransportInterference = listener - } - - setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { - this.onAgentStatus = listener - if (!listener) { - return - } - // Why: replay is best-effort per pane so one throwing listener can't starve the rest. - for (const payload of this.state.lastStatusByPaneKey.values()) { - try { - // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. - listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) - } catch (err) { - console.error('[agent-hooks] replay listener threw', err) - } - } - } - - // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. - setClaudeStatusLineListener( - listener: ((event: ClaudeStatusLineRateLimits) => void) | null - ): void { - this.onClaudeStatusLine = listener - } - - subscribeStatusChanges(listener: StatusChangeListener): () => void { - this.statusChangeListeners.add(listener) - return () => { - this.statusChangeListeners.delete(listener) - } - } - - subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { - this.providerSessionChangeListeners.add(listener) - return () => { - this.providerSessionChangeListeners.delete(listener) - } - } - - /** Multi-subscriber tap on every enriched status change (no replay). */ - subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { - this.enrichedStatusListeners.add(listener) - return () => { - this.enrichedStatusListeners.delete(listener) - } - } - - /** Replay is durable evidence from a prior runtime, not a live observation. */ - private withdrawReplayObservation(paneKey: string): void { - if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { - this.notifyStatusChangeListeners() - } - } - - private ingestSpoolRecord(record: SpoolRecord): void { - if (!isAgentHookSource(record.source)) { - return - } - const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) - const normalized = this.normalizeLocalHookPayload(record.source, body) - if (!normalized.event) { - return - } - const replay = { ...normalized.event, isReplay: true as const } - const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { - source: record.source, - hookEventName: replay.hookEventName, - isReplay: true, - hasExplicitPrompt: replay.hasExplicitPrompt, - launchToken: replay.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay - if (statusDisposition === 'restart') { - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus(event, normalized.onAccepted) - if (event.payload.state !== 'done') { - this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) - } - } - - setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { - this.onPaneStatusCleared = listener - } - - /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` - * (a single slot the main window owns and drops on close) this survives window - * teardown and exists at all under headless serve, which never opens one. */ - subscribePaneStatusClear(listener: PaneStatusClearListener): () => void { - this.paneStatusClearListeners.add(listener) - return () => { - this.paneStatusClearListeners.delete(listener) - } - } - - /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user - * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned - * cleanup (synthetic spinners) still has to retire with the row it was driving. */ - subscribeStatusDrop(listener: StatusDropListener): () => void { - this.statusDropListeners.add(listener) - return () => { - this.statusDropListeners.delete(listener) - } - } - - private emitStatusDropped(paneKey: string): void { - for (const listener of this.statusDropListeners) { - // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. - try { - listener(paneKey) - } catch (err) { - console.error('[agent-hooks] status-drop listener threw', err) - } - } - } - - private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { - this.onPaneStatusCleared?.(clear) - for (const listener of this.paneStatusClearListeners) { - // Why: callers are pane/connection teardown paths; one throwing subscriber must - // not strand the rest, matching every other fan-out here. - try { - listener(clear) - } catch (err) { - console.error('[agent-hooks] pane-status-clear listener threw', err) - } - } - } - - /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the - * dashboard catches up on hook events that fired during startup. */ - getStatusSnapshot(): AgentStatusIpcPayload[] { - return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => - toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) - ) - } - - /** Provider-session identities, including Pi's metadata-only rows. */ - getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { - return this.buildStatusChangeNotification().providerSessions - } - - getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { - const entry = this.state.lastStatusByPaneKey.get(paneKey) - return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] - } - - getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { - return this.hydratedAuthorityCommitments - } - - getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { - return Object.freeze( - Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) - ) - } - - attestCompatibilityAuthority(candidate: { - paneKey: string - launchTokenHash: string - connectionId: string | null - terminalProvenance: 'current_runtime' | 'restored' - }): AgentHookAuthorityAttestation | null { - const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) - const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => - entry.launchTokenHash === candidate.launchTokenHash && - entry.connectionId === candidate.connectionId - const commitments = this.hydratedAuthorityCommitments.filter( - (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) - ) - const current = Array.from(this.currentAuthorityObservations.values()) - const observations = current.filter(matchesCandidate) - const paneObservations = current.filter( - (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey - ) - const hasUniqueCurrentObservation = - observations.length === 1 && - paneObservations.length === 1 && - this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey - if (candidate.terminalProvenance === 'current_runtime') { - return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null - } - if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { - return null - } - if (observations.length === 0 && paneObservations.length === 0) { - return Object.freeze({ paneKey, source: 'hydrated_commitment' }) - } - if (!hasUniqueCurrentObservation) { - return null - } - return Object.freeze({ paneKey, source: 'current_hook' }) - } - - inferInterrupt(request: AgentInterruptInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - if (!isAgentInterruptInputIntent(request.intent)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - if (existing.providerSessionOnly) { - return false - } - // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - const agentType: AgentType | undefined = payload.agentType - // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. - if (agentType === 'droid' && request.intent === 'ctrl-c') { - return false - } - // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. - if ( - (agentType === 'opencode' || agentType === 'copilot') && - request.intent === 'plain-escape' && - request.inputCount !== 2 - ) { - return false - } - const dismissesClaudeQuestion = - agentType === 'claude' && - request.intent === 'plain-escape' && - payload.state === 'waiting' && - isAskUserQuestionTool(payload.toolName) - if (dismissesClaudeQuestion) { - return this.inferQuestionAnswered(request) - } - // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. - if ( - payload.state !== 'working' || - !equivalentInterruptAgentType(agentType, request.baselineAgentType) || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. - if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { - return false - } - // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. - if ( - agentType === 'claude' && - (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) - ) { - return false - } - - // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. - if (agentType === 'claude') { - markClaudeLeadTurnInterrupted(this.state, existing.paneKey) - } - if (agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, existing.paneKey) - } - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: 'done', - prompt: payload.prompt, - agentType, - ...(payload.model ? { model: payload.model } : {}), - interrupted: true, - // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred interrupted agent status', { - paneKey: inferred.paneKey, - agentType, - intent: request.intent - }) - return true - } - - /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ - inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { - if (!isValidPaneKey(request.paneKey)) { - return false - } - const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return false - } - // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. - if (existing.restoredUnconfirmed) { - return false - } - const payload = existing.payload - // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. - if ( - payload.agentType !== 'claude' || - payload.state !== 'waiting' || - !isAskUserQuestionTool(payload.toolName) - ) { - return false - } - if ( - payload.agentType !== request.baselineAgentType || - payload.prompt !== request.baselinePrompt || - existing.receivedAt !== request.baselineUpdatedAt || - existing.stateStartedAt !== request.baselineStateStartedAt || - Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS - ) { - return false - } - // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. - const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) - const inferred = this.applyNormalizedStatus({ - paneKey: existing.paneKey, - tabId: existing.tabId, - worktreeId: existing.worktreeId, - connectionId: existing.connectionId, - providerSession: existing.providerSession, - payload: { - state: restored.state, - ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), - prompt: payload.prompt, - agentType: payload.agentType, - ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), - ...(restored.turnCompletedAt !== undefined - ? { turnCompletedAt: restored.turnCompletedAt } - : {}), - ...(payload.subagents ? { subagents: payload.subagents } : {}) - } - }) - console.debug('[agent-hooks] inferred resolved question status', { - paneKey: inferred.paneKey, - state: inferred.payload.state - }) - return true - } - - getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return this.buildStatusChangeNotification().statuses - } - - private buildStatusChangeNotification(): { - statuses: AgentHookStatusChangeEntry[] - providerSessions: AgentHookProviderSessionIdentity[] - } { - const statuses: AgentHookStatusChangeEntry[] = [] - const providerSessions: AgentHookProviderSessionIdentity[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if (enriched.providerSession) { - providerSessions.push({ - paneKey, - sessionId: enriched.providerSession.id, - ...(enriched.providerSession.transcriptPath - ? { transcriptPath: enriched.providerSession.transcriptPath } - : {}), - ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) - }) - } - if (!enriched.providerSessionOnly) { - statuses.push({ - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - }) - } - } - return { statuses, providerSessions } - } - - private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { - return - } - const { statuses, providerSessions } = this.buildStatusChangeNotification() - for (const listener of this.statusChangeListeners) { - try { - listener(statuses) - } catch (err) { - console.error('[agent-hooks] status-change listener threw', err) - } - } - for (const listener of this.providerSessionChangeListeners) { - try { - listener(providerSessions) - } catch (err) { - console.error('[agent-hooks] provider-session listener threw', err) - } - } - } - - private markTabClosedForAgentStatus(tabId: string): void { - // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. - this.closedAgentStatusTabIds.delete(tabId) - this.closedAgentStatusTabIds.add(tabId) - while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { - const oldest = this.closedAgentStatusTabIds.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusTabIds.delete(oldest) - } - } - - private getAgentStatusDisposition( - paneKey: string, - event?: { - source?: AgentHookSource - /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ - rawSource?: unknown - hookEventName?: string - isReplay?: boolean - hasExplicitPrompt?: boolean - launchToken?: string - } - ): 'accept' | 'restart' | 'suppress' { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneRetired = - this.closedAgentStatusPaneKeys.has(paneKey) || - this.closedAgentStatusPaneKeys.has(ownerPaneKey) - const tabId = parsePaneKey(ownerPaneKey)?.tabId - if (tabId && this.closedAgentStatusTabIds.has(tabId)) { - return 'suppress' - } - if (!paneRetired) { - const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) - // Why: deferred retirement lets a new process start in a still-authorized pane, so - // its tokened SessionStart re-fences; prompts recur, so a stale process would win. - if ( - event?.hookEventName === 'SessionStart' && - event.isReplay !== true && - tokenFence !== undefined - ) { - const startedLaunchToken = event.launchToken?.trim() - if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) - return 'accept' - } - } - if (event && tokenFence) { - const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { - return 'suppress' - } - } - return 'accept' - } - // Why: command completion retires launch authority but leaves its shell pane reusable. - // A live new-turn event proves a new agent process owns the retired pane just like a - // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). - // Why the classifier, not literals: only 5 of 18 sources name their boundary - // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. - // Why four branches: `source` collapses to undefined when an older relay omits the field, - // when a newer host sends an unknown string, and when the wire value is malformed. Only an - // unknown string is valid future-provider evidence. Unreachable from the local path, which - // 404s an unresolvable source. - const isNewTurn = - event?.source !== undefined - ? isNewTurnEvent(event.source, event.hookEventName) - : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 - ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and - // the costs are asymmetric — a stranded pane is invisible and permanent with no user - // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. - true - : event?.rawSource === undefined - ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it - // cannot revive a provider whose boundary event is named anything else. - event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' - : false - // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in - // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no - // SessionStart at all, so without this its retired panes never come back. - const freshOpenCodeFamilyPrompt = - (event?.source === 'opencode' || event?.source === 'mimo-code') && - event.hookEventName === 'MessagePart' && - event.hasExplicitPrompt === true - // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up - // status on that launch token stops a stale process reclaiming the pane's row without - // restoring retired orchestration authority. - if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.delete(ownerPaneKey) - const launchToken = event?.launchToken?.trim() - if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) - } else { - this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) - } - return 'restart' - } - return 'suppress' - } - - // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric - // keys never parse as stable ones — resolve both forms so neither slips the tab check. - private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { - const tabId = - parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined - return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) - } - - private recordRetiredPaneFence( - paneKeys: ReadonlySet, - aliases: readonly RetiredPaneAlias[] - ): void { - const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } - for (const key of paneKeys) { - // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. - this.retiredPaneFencesByKey.delete(key) - this.retiredPaneFencesByKey.set(key, fence) - } - while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { - const oldest = this.retiredPaneFencesByKey.keys().next().value - if (oldest === undefined) { - break - } - this.retiredPaneFencesByKey.delete(oldest) - } - } - - private markPaneClosedForAgentStatus(paneKey: string): void { - this.closedAgentStatusPaneKeys.delete(paneKey) - this.closedAgentStatusPaneKeys.add(paneKey) - while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { - const oldest = this.closedAgentStatusPaneKeys.keys().next().value - if (oldest === undefined) { - break - } - this.closedAgentStatusPaneKeys.delete(oldest) - } - } - - private attachStatusTiming( - payload: AgentHookEventPayload, - now = Date.now() - ): EnrichedAgentHookEventPayload { - const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const commandCodeNewTurn = - previous !== undefined && - isCommandCodeNewTurnWhileWorking({ - agentType: payload.payload.agentType, - previousState: previous.payload.state, - incomingState: payload.payload.state, - previousPrompt: previous.payload.prompt, - incomingPrompt: payload.payload.prompt, - hasExplicitPrompt: payload.hasExplicitPrompt, - previousPromptInteractionKey: previous.promptInteractionKey, - incomingPromptInteractionKey: payload.promptInteractionKey - }) - const stateStartedAt = - previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn - ? previous.stateStartedAt - : now - return { - ...payload, - receivedAt: now, - stateStartedAt - } - } - - private hashPromptForTelemetryDedupe(prompt: string): string { - return createHash('sha256') - .update(this.promptSentHashSalt) - .update('\0') - .update(prompt) - .digest('hex') - } - - private maybeTrackAgentPromptSent( - payload: AgentHookEventPayload, - previousStatus: EnrichedAgentHookEventPayload | undefined - ): void { - if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { - return - } - const prompt = payload.payload.prompt?.trim() ?? '' - if (prompt.length === 0) { - return - } - const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) - const promptHash = this.hashPromptForTelemetryDedupe(prompt) - const promptInteractionKey = - typeof payload.promptInteractionKey === 'string' && - payload.promptInteractionKey.trim().length > 0 - ? payload.promptInteractionKey.trim() - : undefined - const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) - const isCompletedTurnBoundary = - previousStatus?.payload.state === 'done' && payload.payload.state === 'working' - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey === promptInteractionKey && - (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) - ) { - return - } - if ( - previousDedupe?.agentKind === agentKind && - previousDedupe.promptHash === promptHash && - !( - previousStatus?.payload.state === 'done' && - payload.payload.state === 'done' && - previousDedupe.promptInteractionKey !== undefined && - promptInteractionKey !== undefined && - previousDedupe.promptInteractionKey !== promptInteractionKey - ) && - !isCompletedTurnBoundary - ) { - return - } - this.promptSentDedupeByPaneKey.set(payload.paneKey, { - agentKind, - promptHash, - promptInteractionKey - }) - try { - // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. - track('agent_prompt_sent', { - agent_kind: agentKind, - launch_source: 'unknown', - request_kind: 'followup', - ...getCohortAtEmit() - }) - } catch (err) { - console.error('[agent-hooks] prompt-sent telemetry failed', err) - } - } - - /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet - * (STA-4293) — it is stamped here because every main-side ingress funnels through - * applyNormalizedStatus, so no origin can silently arrive untagged. */ - private stampObservation( - payload: AgentHookEventPayload, - origin: AgentStatusObservationOrigin, - observedAt: number - ): AgentStatusObservation { - return this.observations.observe(payload.paneKey, { - origin, - observedAt, - // Why: reuse the listener's own per-provider classifier; a second list of raw event-name - // literals here would strand the providers whose boundary event is named anything else. - boundary: - payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), - kind: payload.providerSessionOnly - ? 'identity-only' - : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the - // current state rather than announcing a change — neither is a fresh transition. - payload.isReplay === true || origin === 'osc' - ? 'snapshot' - : 'transition' - }) - } - - private applyNormalizedStatus( - payload: AgentHookEventPayload, - onAccepted?: () => void, - origin: AgentStatusObservationOrigin = 'hook' - ): EnrichedAgentHookEventPayload { - if (payload.hookEventName === 'UserPromptSubmit') { - // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. - this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) - } - let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - const connectionClearWatermark = payload.connectionId - ? this.connectionTimestampWatermarkById.get(payload.connectionId) - : undefined - // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. - const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined - const now = Math.max( - Date.now(), - (connectionClearWatermark ?? -1) + 1, - (restoredStatusWatermark ?? -1) + 1 - ) - if (payload.connectionId) { - this.connectionTimestampWatermarkById.set(payload.connectionId, now) - } - if (payload.providerSessionOnly) { - // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. - onAccepted?.() - const enriched = { - ...this.attachStatusTiming(payload, now), - observation: this.stampObservation(payload, origin, now) - } - this.clearAssistantMessageRetry(enriched.paneKey) - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - const stateReconciledPayload = - payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName - ? { - ...payload, - payload: reconcileRemoteCodexState( - this.state, - payload.paneKey, - payload.hookEventName, - payload.toolAgentId, - payload.payload, - previous?.payload - ) - } - : payload - const previousCodexRoot = - stateReconciledPayload.payload.agentType === 'codex' && - stateReconciledPayload.toolAgentId && - previous?.payload.agentType === 'codex' - ? previous - : undefined - const preservedProviderSession = !stateReconciledPayload.providerSession - ? previousCodexRoot?.providerSession - : undefined - const preservedRootModel = !stateReconciledPayload.payload.model - ? previousCodexRoot?.payload.model - : undefined - // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. - const rootContextPreservingPayload = - preservedProviderSession || preservedRootModel - ? { - ...stateReconciledPayload, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: preservedRootModel - ? { ...stateReconciledPayload.payload, model: preservedRootModel } - : stateReconciledPayload.payload - } - : stateReconciledPayload - const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( - previous, - rootContextPreservingPayload - ) - if (boundaryReconciledPrevious !== previous) { - previous = boundaryReconciledPrevious - if (previous) { - this.state.lastStatusByPaneKey.set(previous.paneKey, previous) - this.scheduleStatusPersist() - } - } - const identity = resolveAgentStatusIdentity({ - existing: previous - ? { - agentType: previous.payload.agentType, - state: previous.payload.state, - updatedAt: previous.receivedAt, - restoredUnconfirmed: previous.restoredUnconfirmed - } - : undefined, - incoming: rootContextPreservingPayload.payload.agentType, - now - }) - if ( - previous && - shouldSuppressInheritedTerminalStatus({ - inheritedFromActivePane: identity.inheritedFromActivePane, - incomingState: rootContextPreservingPayload.payload.state - }) - ) { - return previous - } - const identityResolvedPayload = - identity.agentType === rootContextPreservingPayload.payload.agentType - ? rootContextPreservingPayload - : { - ...rootContextPreservingPayload, - payload: { - ...rootContextPreservingPayload.payload, - agentType: identity.agentType - } - } - const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) - const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) - if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { - return previous - } - // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'done' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS - ) { - return previous - } - if ( - previous?.payload.state === 'done' && - previous.payload.interrupted === true && - effectivePayload.payload.state === 'working' && - previous.payload.agentType === effectivePayload.payload.agentType && - previous.payload.prompt === effectivePayload.payload.prompt && - (effectivePayload.isReplay === true || - isToolProgressWorkingAfterInterrupt(effectivePayload) || - (effectivePayload.hasExplicitPrompt !== true && - Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) - ) { - if (effectivePayload.payload.agentType === 'codex') { - markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) - } - return previous - } - if ( - effectivePayload.payload.state !== 'done' || - effectivePayload.payload.lastAssistantMessage - ) { - this.clearAssistantMessageRetry(effectivePayload.paneKey) - } - onAccepted?.() - if (!identity.inheritedFromActivePane) { - this.maybeTrackAgentPromptSent(effectivePayload, previous) - } - const enriched = { - ...this.attachStatusTiming(boundaryAwarePayload, now), - observation: this.stampObservation(boundaryAwarePayload, origin, now) - } - if ( - typeof enriched.payload.turnCompletedAt === 'number' && - Number.isFinite(enriched.payload.turnCompletedAt) - ) { - this.activeHookTurnCompletedAtByPaneKey.set( - enriched.paneKey, - enriched.payload.turnCompletedAt - ) - } - // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. - if (enriched.restoredUnconfirmed) { - this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) - } else { - this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) - } - this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitEnrichedStatus(enriched) - return enriched - } - - // Why: every status emit must reach plugins too, so a new early-return path - // upstream cannot silently leave the plugin tap behind the main-window fanout. - private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { - this.onAgentStatus?.(enriched) - for (const listener of this.enrichedStatusListeners) { - try { - listener(enriched) - } catch (err) { - console.error('[agent-hooks] enriched status listener threw', err) - } - } - } - - private clearAssistantMessageRetry(paneKey: string): void { - const timer = this.assistantMessageRetryTimers.get(paneKey) - if (!timer) { - return - } - clearTimeout(timer) - this.assistantMessageRetryTimers.delete(paneKey) - } - - private clearCodexSubagentPoll(paneKey: string): void { - this.codexSubagentPollScheduler.clear(paneKey) - } - - private scheduleCodexSubagentPoll( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload - ): void { - // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. - if (source !== 'codex') { - return - } - this.codexSubagentPollScheduler.clear(original.paneKey) - if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { - return - } - this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) - } - - private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { - const { source, body, original } = poll - // Keep the identity check at callback time: a newer event supersedes this - // payload even when its pane still has transcript children. - if ( - paneKey !== original.paneKey || - !this.server || - this.state.lastStatusByPaneKey.get(original.paneKey) !== original - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized) { - return - } - const subagentsChanged = - JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) - const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original - this.scheduleCodexSubagentPoll(source, body, next) - } - - private scheduleAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - attempt = 1, - discoveryReady = false - ): void { - if ( - original.payload.lastAssistantMessage || - !hasPendingAgentResultText(source, body) || - attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS - ) { - return - } - this.clearAssistantMessageRetry(original.paneKey) - if (!discoveryReady) { - const discovery = preparePendingGrokResultDiscovery(source, body) - if (discovery) { - // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. - void discovery - .then(() => { - if (this.server) { - this.applyAssistantMessageRetry(source, body, original, 1, true) - } - }) - .catch((err) => { - console.error('[agent-hooks] Grok result discovery failed:', err) - }) - return - } - } - const timer = setTimeout(() => { - try { - this.assistantMessageRetryTimers.delete(original.paneKey) - this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) - } catch (err) { - console.error('[agent-hooks] assistant message retry failed:', err) - } - }, ASSISTANT_MESSAGE_RETRY_MS) - this.assistantMessageRetryTimers.set(original.paneKey, timer) - if (typeof timer.unref === 'function') { - timer.unref() - } - } - - private applyAssistantMessageRetry( - source: AgentHookSource, - body: unknown, - original: EnrichedAgentHookEventPayload, - nextAttempt: number, - requireExactOriginal: boolean - ): void { - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - (requireExactOriginal && current !== original) || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = this.normalizeLocalHookPayload(source, body) - if (!normalized.event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) - return - } - // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. - this.applyNormalizedStatus(normalized.event, normalized.onAccepted) - } - - setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { - this.paneKeyAliasPersistenceListener = listener - } - - private getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { - return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => - entry.ptyId - ? [ - { - ptyId: entry.ptyId, - legacyPaneKey, - stablePaneKey: entry.stablePaneKey, - updatedAt: entry.updatedAt - } - ] - : [] - ) - } - - private notifyPaneKeyAliasPersistenceListener(): void { - this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) - } - - private boundPaneKeyAliases(): void { - while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { - // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. - const oldestKey = this.legacyPaneKeyAliases.keys().next().value - if (!oldestKey) { - break - } - this.legacyPaneKeyAliases.delete(oldestKey) - } - } - - private getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - let fallbackPaneKey = paneKey - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if ( - entry.stablePaneKey === ownerPaneKey && - (!ptyId || !entry.ptyId || entry.ptyId === ptyId) - ) { - if (entry.authorityVerified) { - return physicalPaneKey - } - fallbackPaneKey = physicalPaneKey - } - } - return fallbackPaneKey - } - - canTransferPaneAuthority( - fromPaneKey: string, - ptyId: string | undefined, - ownsPty: (physicalPaneKey: string, ptyId: string) => boolean - ): boolean { - if (!isValidPaneKey(fromPaneKey)) { - return false - } - const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) - if (ptyId) { - return Boolean( - (alias?.authorityVerified && alias.ptyId === ptyId) || - ownsPty(physicalPaneKey, ptyId) || - (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) - ) - } - // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. - return alias?.authorityVerified === true - } - - registerPaneKeyAlias( - legacyPaneKey: string, - stablePaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { overwriteExisting?: boolean; authorityVerified?: boolean } - ): void { - const fromPaneKey = legacyPaneKey.trim() - const toPaneKey = stablePaneKey.trim() - if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { - return - } - const existing = this.legacyPaneKeyAliases.get(fromPaneKey) - if (existing && options?.overwriteExisting === false) { - return - } - // Why: remint tokens have no embedded tab id; first pane wins so a later spawn - // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. - if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { - return - } - const normalizedPtyId = - typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId - const normalizedUpdatedAt = - Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) - const authorityVerified = options?.authorityVerified ?? false - if ( - existing && - existing.stablePaneKey === toPaneKey && - existing.ptyId === (normalizedPtyId ?? null) && - existing.updatedAt === normalizedUpdatedAt && - existing.authorityVerified === authorityVerified - ) { - return - } - this.legacyPaneKeyAliases.set(fromPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId ?? null, - updatedAt: normalizedUpdatedAt, - authorityVerified - }) - this.boundPaneKeyAliases() - if (normalizedPtyId) { - this.notifyPaneKeyAliasPersistenceListener() - } - } - - transferPaneAuthority( - fromPaneKey: string, - toPaneKey: string, - ptyId?: string, - updatedAt = Date.now(), - options?: { authorityVerified?: boolean } - ): void { - if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { - return - } - const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) - const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) - const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) - const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null - const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) - movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) - const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (movedStatus) { - const owner = parsePaneKey(toPaneKey) - this.state.lastStatusByPaneKey.set(toPaneKey, { - ...movedStatus, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - } - const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - if (hydratedLaunchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) - } - const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) - if (persistedAuthority) { - const owner = parsePaneKey(toPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.set( - toPaneKey, - Object.freeze({ - ...persistedAuthority, - paneKey: toPaneKey, - ...(owner?.tabId ? { tabId: owner.tabId } : {}) - }) - ) - } - if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { - this.runtimeObservedStatusPaneKeys.add(toPaneKey) - } - const restartedTokenHash = - this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) - if (restartedTokenHash) { - this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) - } - const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) - if (activeTurnCompletedAt !== undefined) { - this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) - this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) - } - const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) - if (authorityObservation) { - const owner = parsePaneKey(toPaneKey) - this.currentAuthorityObservations.delete(previousOwnerPaneKey) - this.currentAuthorityObservations.set( - toPaneKey, - Object.freeze({ - ...authorityObservation, - paneKey: toPaneKey, - tabId: owner?.tabId - }) - ) - } - const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) - if (promptDedupe !== undefined) { - this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) - this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) - } - this.clearAssistantMessageRetry(previousOwnerPaneKey) - this.clearCodexSubagentPoll(previousOwnerPaneKey) - // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. - this.legacyPaneKeyAliases.set(physicalPaneKey, { - stablePaneKey: toPaneKey, - ptyId: normalizedPtyId, - updatedAt, - authorityVerified: options?.authorityVerified ?? true - }) - this.boundPaneKeyAliases() - this.closedAgentStatusPaneKeys.delete(toPaneKey) - this.notifyPaneKeyAliasPersistenceListener() - if (hadStatus || persistedAuthority) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - retirePaneAuthority(paneKey: string): void { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, ownerPaneKey]) - const retiredAliases: RetiredPaneAlias[] = [] - let aliasChanged = false - for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { - if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { - this.legacyPaneKeyAliases.delete(physicalPaneKey) - retiredAliases.push({ physicalPaneKey, entry }) - paneKeys.add(physicalPaneKey) - paneKeys.add(entry.stablePaneKey) - aliasChanged = true - } - } - this.recordRetiredPaneFence(paneKeys, retiredAliases) - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) - for (const key of paneKeys) { - this.markPaneClosedForAgentStatus(key) - this.restartedStatusLaunchTokenHashByPaneKey.delete(key) - this.clearAssistantMessageRetry(key) - this.clearCodexSubagentPoll(key) - clearPaneCacheState(this.state, key) - this.activeHookTurnCompletedAtByPaneKey.delete(key) - this.runtimeObservedStatusPaneKeys.delete(key) - this.currentAuthorityObservations.delete(key) - this.promptSentDedupeByPaneKey.delete(key) - this.observations.forget(key) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - // Why: retirement fences a pane and every alias of it, then deletes those aliases. - // Lifting only the key we are handed strands the rest — a detached pane's process - // keeps posting the key it launched under, so it would stay suppressed forever with - // the fence apparently lifted. Replay the recorded fence instead: same key set, same - // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger - // claim survives and a live process is never routed back into a closed tab. - private restoreRetiredPaneFence(fence: RetiredPaneFence): void { - let aliasChanged = false - for (const { physicalPaneKey, entry } of fence.aliases) { - if ( - this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || - this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || - // Why: the pane was rebound in the meantime; the newer alias is the truth. - this.legacyPaneKeyAliases.has(physicalPaneKey) - ) { - continue - } - this.legacyPaneKeyAliases.set(physicalPaneKey, entry) - aliasChanged = true - } - for (const key of fence.paneKeys) { - if (this.retiredPaneFencesByKey.get(key) === fence) { - this.retiredPaneFencesByKey.delete(key) - } - } - if (aliasChanged) { - this.boundPaneKeyAliases() - this.notifyPaneKeyAliasPersistenceListener() - } - } - - // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that - // exact pane disproves the claim at the moment it stops being true, so the fence - // lifts here instead of waiting for the agent to speak again — an agent re-attached - // mid-turn or left idle would otherwise stay suppressed for the rest of its life - // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. - restorePaneAuthority(paneKey: string): boolean { - const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) - if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { - return false - } - const fence = - this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) - let restored = false - for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { - if (this.isClosedAgentStatusTabForPaneKey(key)) { - continue - } - if (this.closedAgentStatusPaneKeys.delete(key)) { - restored = true - } - } - if (fence) { - this.restoreRetiredPaneFence(fence) - } - return restored - } - - clearPaneKeyAliasesForPty( - ptyId: string, - options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } - ): void { - let aliasChanged = false - let statusChanged = false - const clearedStatusPaneKeys = new Set() - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - if (entry.ptyId === ptyId) { - const shouldClearStablePaneKey = - options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true - const revokedPaneKeys = new Set([legacyPaneKey]) - if (shouldClearStablePaneKey) { - revokedPaneKeys.add(entry.stablePaneKey) - } - if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { - statusChanged = true - } - this.legacyPaneKeyAliases.delete(legacyPaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { - statusChanged = true - clearedStatusPaneKeys.add(entry.stablePaneKey) - } - if (shouldClearStablePaneKey) { - // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. - clearPaneCacheState(this.state, entry.stablePaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) - this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) - this.currentAuthorityObservations.delete(entry.stablePaneKey) - this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) - } - aliasChanged = true - } - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - for (const paneKey of clearedStatusPaneKeys) { - this.emitPaneStatusCleared({ paneKey }) - } - } - } - - private resolvePaneKeyAlias(paneKey: string): string { - return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey - } - - private revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { - let changed = false - for (const commitment of this.hydratedAuthorityCommitments) { - if ( - paneKeys.has(commitment.paneKey) || - paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) - ) { - this.revokedHydratedAuthorityCommitments.add(commitment) - changed = true - } - } - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed - changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed - changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed - } - return changed - } - - private normalizeHookBodyPaneKeyAlias(body: unknown): unknown { - if (typeof body !== 'object' || body === null) { - return body - } - const record = body as Record - const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' - const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey - if (!stablePaneKey) { - return body - } - // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. - return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } - } - - private normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { - if (source !== 'claude' || typeof body !== 'object' || body === null) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const rawPaneKey = (body as Record).paneKey - const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' - if (!paneKey) { - return { event: normalizeHookPayload(this.state, source, body, this.env) } - } - const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - const event = normalizeHookPayload(this.state, source, body, this.env) - const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) - const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) - this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) - if (!event || event.paneKey !== paneKey) { - return { event } - } - // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. - return { - event, - onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) - } - } - - private setClaudeBackgroundEvidence( - paneKey: string, - hasRunningTask: boolean, - hasActiveCron: boolean - ): void { - if (hasRunningTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - if (hasActiveCron) { - this.state.claudeActiveSessionCronPaneKeys.add(paneKey) - } else { - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - } - } - - ingestTerminalStatus(event: { - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - payload: ParsedAgentStatusPayload - }): void { - const physicalPaneKey = event.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { - return - } - const reportedTabId = - event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - if (this.getAgentStatusDisposition(paneKey) !== 'accept') { - return - } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null - const previous = this.state.lastStatusByPaneKey.get(paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - previous?.claudeLeadBoundaryChildOnly === true && - previous.payload.agentType === 'claude' && - event.payload.agentType === 'claude' - ) { - // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. - return - } - const preserveActiveTurnStamp = - previous?.payload.turnCompletedAt !== undefined && - previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) - if ( - !previous?.restoredUnconfirmed && - previous?.connectionId === connectionId && - previous.tabId === tabId && - previous.worktreeId === worktreeId && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) - ) { - return - } - // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is - // never evidence that the session ended — yet overwriting the row dropped the cached identity. - // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which - // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). - // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. - // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an - // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so - // it must not be read as a mismatch and strip the session the renderer would have kept. - const claimedAgentType = - event.payload.agentType && event.payload.agentType !== 'unknown' - ? event.payload.agentType - : undefined - const preservedProviderSession = - previous?.providerSession && - (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && - (previous.payload.state !== 'done' || event.payload.state === 'done') - ? previous.providerSession - : undefined - // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. - this.applyNormalizedStatus( - { - paneKey, - tabId, - worktreeId, - connectionId, - ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), - payload: event.payload - }, - undefined, - 'osc' - ) - } - - /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - hookEventName?: string - source?: unknown - providerPromptId?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - payload: unknown - }, - connectionId: string | null - ): void { - // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. - if (connectionId !== null && typeof connectionId !== 'string') { - return - } - const trimmedConnectionId = connectionId?.trim() ?? null - if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { - return - } - if (!envelope || typeof envelope.paneKey !== 'string') { - return - } - // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. - const physicalPaneKey = envelope.paneKey.trim() - const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) - const parsedPaneKey = parsePaneKey(paneKey) - if (paneKey.length === 0) { - track('agent_hook_unattributed', { reason: 'empty_pane_key' }) - return - } - if (paneKey.length > MAX_PANE_KEY_LEN) { - return - } - if (!parsedPaneKey) { - return - } - // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. - if (envelope.isReplay === true) { - const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) - if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { - return - } - } - if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { - return - } - if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { - return - } - // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. - const reportedTabId = - envelope.tabId !== undefined && envelope.tabId.trim().length > 0 - ? envelope.tabId.trim() - : undefined - if ( - paneKey === physicalPaneKey && - reportedTabId !== undefined && - reportedTabId !== parsedPaneKey.tabId - ) { - return - } - const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId - const hookEventName = - typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 - ? envelope.hookEventName.trim() - : undefined - const source = isAgentHookSource(envelope.source) ? envelope.source : undefined - const providerPromptId = - source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined - const compactTrigger = - source === 'claude' && - (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') - ? envelope.compactTrigger - : undefined - const statusDisposition = this.getAgentStatusDisposition(paneKey, { - source, - rawSource: envelope.source, - hookEventName, - isReplay: envelope.isReplay === true, - hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken - }) - if (statusDisposition === 'suppress') { - return - } - if (statusDisposition === 'restart') { - // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. - // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the - // stable pane, so the rebind cannot land on a legacy key. - this.observations.rebind(paneKey) - } - const worktreeId = - envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 - ? envelope.worktreeId.trim() - : undefined - const promptInteractionKey = - typeof envelope.promptInteractionKey === 'string' && - envelope.promptInteractionKey.trim().length > 0 - ? envelope.promptInteractionKey.trim() - : undefined - const toolUseId = - typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 - ? envelope.toolUseId.trim() - : undefined - const toolAgentId = - typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 - ? envelope.toolAgentId.trim() - : undefined - const teammateName = - typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 - ? envelope.teammateName.trim() - : undefined - const toolAgentType = - typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 - ? envelope.toolAgentType.trim() - : undefined - const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined - // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). - const validatedPayload = normalizeAgentStatusPayload(envelope.payload) - if (!validatedPayload) { - return - } - // Why: restore a shed roster only when its digest and turn identity still match the cache. - let normalizedPayload = restoreShedStatusFields( - validatedPayload, - envelope.shedFields, - this.state.lastStatusByPaneKey.get(paneKey)?.payload - ) - const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) - let acceptedCompactCompletion = false - if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { - // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); - // reject it here too so a host on any version cannot drive pane state from it. - if (hookEventName === 'PreCompact' || source !== 'claude') { - return - } - // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact - // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is - // fixed and known — manual produced `done`, auto produced `working` — so the payload state - // stands in for the missing trigger. Trigger substitution only; ownership is still checked. - const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) - // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older - // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so - // without this drop, every auto compact on such a host mints exactly the stuck `working` this - // change removes. - if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { - return - } - if ( - isClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) || - !canAcceptClaudeCompactCompletion(previousStatus, { - source, - connectionId: trimmedConnectionId, - providerPromptId, - providerSession - }) - ) { - return - } - markClaudeCompactCompletionConsumed( - this.state.claudeConsumedCompactPromptIdByPaneKey, - paneKey, - providerPromptId - ) - // Why: an older relay built this payload before the boundary flag existed, so it arrives as a - // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the - // boundary here so a compact stays silent regardless of which relay normalized it. - if (normalizedPayload.sessionBoundary !== true) { - normalizedPayload = { ...normalizedPayload, sessionBoundary: true } - } - acceptedCompactCompletion = true - } - // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the - // trigger-stripped replay is exactly the shape that arrives without one, and it is still the - // compact's own promptless event, so it still needs the summarized turn's label. - if ( - source === 'claude' && - (compactTrigger !== undefined || acceptedCompactCompletion) && - normalizedPayload.prompt.length === 0 && - previousStatus?.payload.prompt - ) { - normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } - } - if ( - envelope.providerSessionOnly === true && - !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) - ) { - return - } - const applyClaudeBackgroundWork = - normalizedPayload.agentType === 'claude' && - typeof envelope.claudeRunningNonAgentTask === 'boolean' && - // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. - (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) - // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. - warnOnHookEnvOrVersionMismatch(this.state, { - version: envelope.version, - env: envelope.env, - expectedEnv: this.env - }) - const event: AgentHookEventPayload = { - paneKey, - source, - launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, - tabId, - worktreeId, - connectionId: trimmedConnectionId, - hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, - promptInteractionKey, - hookEventName, - providerPromptId, - compactTrigger, - toolUseId, - toolAgentId, - teammateName, - toolAgentType, - providerSession, - providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, - isReplay: envelope.isReplay === true ? true : undefined, - claudeRunningNonAgentTask: - typeof envelope.claudeRunningNonAgentTask === 'boolean' - ? envelope.claudeRunningNonAgentTask - : undefined, - payload: normalizedPayload - } - this.recordCurrentAuthorityObservation(event) - this.applyNormalizedStatus( - event, - applyClaudeBackgroundWork - ? () => { - if (envelope.claudeRunningNonAgentTask) { - this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) - } else { - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - } - } - : undefined - ) - } - - async start(options?: { - env?: string - userDataPath?: string - endpointNamespace?: string - }): Promise { - if (this.server) { - return - } - - if (options?.env) { - this.env = options.env - } - if (options?.userDataPath) { - // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. - this.endpointDir = options.endpointNamespace - ? join(options.userDataPath, 'agent-hooks', options.endpointNamespace) - : join(options.userDataPath, 'agent-hooks') - this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) - this.lastStatusFilePath = join(this.endpointDir, LAST_STATUS_FILE_NAME) - } - this.token = randomUUID() - this.endpointFileWritten = false - this.lastWrittenJson = null - // Why: hydrate before binding the listener so an early hook POST runs against a populated map. - if (this.lastStatusFilePath) { - this.hydrateLastStatusFromDisk() - } - this.captureHydratedAuthorityCommitments() - // Drain before binding the listener so replay cannot race a live hook during startup. - if (this.endpointDir) { - drainAgentHookSpool({ - endpointDir: this.endpointDir, - getPersistedLaunchTokenHash: (paneKey) => - this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), - ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) - }) - } - const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { - if (req.method !== 'POST') { - res.writeHead(404) - res.end() - return - } - - if (req.headers['x-orca-agent-hook-token'] !== this.token) { - res.writeHead(403) - res.end() - return - } - - // Why: bound request time so a stalled client can't hold a socket open (slowloris). - // Why: track our own destroy so the slowloris cap can't be misread as outside interference. - let destroyedBySlowlorisCap = false - req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { - destroyedBySlowlorisCap = true - req.destroy() - }) - - const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname - try { - const body = await readRequestBody(req) - if (pathname === CLAUDE_STATUSLINE_PATHNAME) { - const statusLineEvent = parseClaudeStatusLineBody(body) - if (statusLineEvent) { - this.onClaudeStatusLine?.(statusLineEvent) - } - res.writeHead(204) - res.end() - return - } - const source = resolveHookSource(pathname) - if (!source) { - res.writeHead(404) - res.end() - return - } - - const hookBody = mergeAgentHookRequestHeaders(body, req.headers) - trackEmptyPaneKeyHook(hookBody) - const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) - const normalized = this.normalizeLocalHookPayload(source, aliasedBody) - const statusDisposition = normalized.event - ? this.getAgentStatusDisposition(normalized.event.paneKey, { - source, - hookEventName: normalized.event.hookEventName, - isReplay: normalized.event.isReplay, - hasExplicitPrompt: normalized.event.hasExplicitPrompt, - launchToken: normalized.event.launchToken - }) - : 'suppress' - if (normalized.event && statusDisposition !== 'suppress') { - const event = - statusDisposition === 'restart' - ? { ...normalized.event, launchToken: undefined } - : normalized.event - if (statusDisposition === 'restart') { - // Why: a retired pane accepting a new turn is a different agent session behind the - // same key — later observations must not be ordered against the retired one. - this.observations.rebind(event.paneKey) - } - this.recordCurrentAuthorityObservation(event) - const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) - this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) - this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) - } - - res.writeHead(204) - res.end() - } catch (error) { - // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut - // by something on the loopback path, not by a bad payload. Fail open as before, but count it — - // this is the one failure mode that silently stops status for every runtime at once. - if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { - this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) - } - // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. - res.writeHead(204) - res.end() - } - } - // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. - this.server = createServer((req, res) => { - void handleRequest(req, res) - }) - - await new Promise((resolve, reject) => { - // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. - const onStartupError = (err: Error): void => { - this.server?.off('listening', onListening) - reject(err) - } - const onListening = (): void => { - this.server?.off('error', onStartupError) - this.server?.on('error', (err) => { - console.error('[agent-hooks] server error', err) - }) - const address = this.server!.address() - if (address && typeof address === 'object') { - this.port = address.port - } - this.maybeWriteEndpointFile() - resolve() - } - this.server!.once('error', onStartupError) - this.server!.listen(0, '127.0.0.1', onListening) - }) - } - - stop(): void { - // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. - this.flushStatusPersistSync() - this.server?.close() - this.server = null - this.port = 0 - this.token = '' - this.env = 'production' - this.onAgentStatus = null - this.onPaneStatusCleared = null - for (const timer of this.assistantMessageRetryTimers.values()) { - clearTimeout(timer) - } - this.assistantMessageRetryTimers.clear() - this.codexSubagentPollScheduler.clearAll() - // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. - this.endpointDir = null - this.endpointFilePathCache = null - this.endpointFileWritten = false - this.lastStatusFilePath = null - this.lastWrittenJson = null - this.runtimeObservedStatusPaneKeys.clear() - this.hydratedAuthorityCommitments = Object.freeze([]) - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - this.revokedHydratedAuthorityCommitments = new WeakSet() - this.currentAuthorityObservations.clear() - this.promptSentDedupeByPaneKey.clear() - this.closedAgentStatusTabIds.clear() - this.closedAgentStatusPaneKeys.clear() - this.restartedStatusLaunchTokenHashByPaneKey.clear() - this.retiredPaneFencesByKey.clear() - this.connectionTimestampWatermarkById.clear() - this.legacyPaneKeyAliases.clear() - clearAllListenerCaches(this.state) - this.notifyStatusChangeListeners() - } - - /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state - * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live - * claims. Returns null when the row has no resumable session to keep. */ - private toRetainedProviderSessionRow( - entry: EnrichedAgentHookEventPayload | null | undefined - ): EnrichedAgentHookEventPayload | null { - if ( - !entry?.providerSession || - !entry.payload.agentType || - entry.payload.agentType === 'unknown' - ) { - return null - } - const { launchToken: _launchToken, ...resumeIdentity } = entry - return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } - } - - /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ - dropStatusEntry(paneKey: string): void { - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (!deleted) { - return - } - const retained = this.toRetainedProviderSessionRow(deleted) - if (retained) { - this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) - } - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitStatusDropped(deleted.paneKey) - } - - /** Retire panes whose owning process is certifiably dead. - * - * The ordinary teardown already does this: every attributable PTY exit reaches - * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that - * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may - * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left - * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, - * so a dead pane is cleaned up identically however its keys were resolved. */ - reconcileEndedProcessForPaneKeys( - paneKeys: Iterable, - options?: { - /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still - * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` - * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to - * resume into, and dropping it matches what `clearProviderPtyState` already does. */ - preserveResumeIdentity?: boolean - } - ): number { - let cleared = 0 - for (const paneKey of paneKeys) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { - continue - } - const retained = options?.preserveResumeIdentity - ? this.toRetainedProviderSessionRow( - this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - ) - : null - this.clearPaneState(resolvedPaneKey) - if (retained) { - this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - cleared += 1 - } - return cleared - } - - /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through - * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list - * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a - * different file. */ - private hasLiveClaimsForPaneKey(paneKey: string): boolean { - return paneHasStateClaims(this.state, paneKey) - } - - /** Clear statuses proven to belong to one lost SSH transport. */ - clearStatusEntriesForConnection(connectionId: string): void { - const normalizedConnectionId = connectionId.trim() - if (normalizedConnectionId.length === 0) { - return - } - const clearedAt = Math.max( - Date.now(), - (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 - ) - this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) - let statusChanged = false - for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { - const entry = rawEntry as EnrichedAgentHookEventPayload - // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. - if (entry.connectionId !== normalizedConnectionId) { - continue - } - const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) - if (deleted) { - statusChanged = true - if (deleted.payload.agentType === 'codex') { - // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. - this.state.codexSubagentRosterByPaneKey.delete(paneKey) - this.state.codexLeadStateByPaneKey.delete(paneKey) - } else if (deleted.payload.agentType === 'claude') { - this.state.claudeSubagentRosterByPaneKey.delete(paneKey) - this.state.claudeLeadStateByPaneKey.delete(paneKey) - this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) - this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) - this.state.claudeSessionOwnerByPaneKey.delete(paneKey) - } - } - } - for (const [paneKey, evidence] of this.currentAuthorityObservations) { - if (evidence.connectionId === normalizedConnectionId) { - this.currentAuthorityObservations.delete(paneKey) - } - } - if (statusChanged) { - // Why: persist/notify once — one disconnect can own many panes. - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. - this.emitPaneStatusCleared({ - transient: true, - connectionId: normalizedConnectionId, - clearedAt - }) - } - - private deleteStatusEntry( - paneKey: string, - options?: { preserveAuthority?: boolean } - ): EnrichedAgentHookEventPayload | null { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as - | EnrichedAgentHookEventPayload - | undefined - if (!existing) { - return null - } - this.state.lastStatusByPaneKey.delete(resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - if (!options?.preserveAuthority) { - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - } - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - if (existing.payload.state === 'done') { - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - } - return existing - } - - dropStatusEntriesByTabPrefix(tabId: string): void { - this.markTabClosedForAgentStatus(tabId) - const paneKeysToClear = new Set() - for (const key of this.state.lastStatusByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key) - } - } - for (const key of this.state.lastPromptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.lastToolByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const key of this.state.ampCompletedCacheKeys) { - if (paneCacheKeyMatchesTab(key, tabId)) { - paneKeysToClear.add(key.split('\0', 1)[0] ?? key) - } - } - for (const paneKey of this.runtimeObservedStatusPaneKeys) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { - if (paneCacheKeyMatchesTab(paneKey, tabId)) { - paneKeysToClear.add(paneKey) - } - } - for (const commitment of this.hydratedAuthorityCommitments) { - if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { - paneKeysToClear.add(commitment.paneKey) - } - } - - let aliasChanged = false - for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { - const ownerMatches = paneCacheKeyMatchesTab(entry.stablePaneKey, tabId) - if (ownerMatches) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeysToClear.add(legacyPaneKey) - paneKeysToClear.add(entry.stablePaneKey) - this.markPaneClosedForAgentStatus(legacyPaneKey) - this.markPaneClosedForAgentStatus(entry.stablePaneKey) - aliasChanged = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) - - let statusChanged = false - for (const paneKey of paneKeysToClear) { - if (this.state.lastStatusByPaneKey.has(paneKey)) { - statusChanged = true - } - this.clearAssistantMessageRetry(paneKey) - this.clearCodexSubagentPoll(paneKey) - clearPaneCacheState(this.state, paneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) - this.runtimeObservedStatusPaneKeys.delete(paneKey) - this.currentAuthorityObservations.delete(paneKey) - this.promptSentDedupeByPaneKey.delete(paneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) - } - if (aliasChanged) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (statusChanged || authorityChanged) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - } - - clearPaneState(paneKey: string): void { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const paneKeys = new Set([paneKey, resolvedPaneKey]) - // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. - const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) - this.clearAssistantMessageRetry(resolvedPaneKey) - this.clearCodexSubagentPoll(resolvedPaneKey) - clearPaneCacheState(this.state, resolvedPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) - this.currentAuthorityObservations.delete(resolvedPaneKey) - this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - let clearedAlias = false - for (const [legacyPaneKey, stablePaneKey] of this.legacyPaneKeyAliases) { - if (stablePaneKey.stablePaneKey === resolvedPaneKey) { - this.legacyPaneKeyAliases.delete(legacyPaneKey) - paneKeys.add(legacyPaneKey) - paneKeys.add(stablePaneKey.stablePaneKey) - clearPaneCacheState(this.state, legacyPaneKey) - this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) - this.currentAuthorityObservations.delete(legacyPaneKey) - this.promptSentDedupeByPaneKey.delete(legacyPaneKey) - this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) - clearedAlias = true - } - } - const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) - if (clearedAlias) { - this.notifyPaneKeyAliasPersistenceListener() - } - if (hadStatus || authorityChanged) { - this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) - } - } - - /** Second reap path for restored Claude subagent rows: drop the ones whose pane - * has no live local agent process behind it any more. A PTY that dies while Orca - * is down never runs the teardown that clears pane state, so hydrate rebuilds a - * roster nothing can ever retire — the inventory reap needs the parent to emit a - * complete `background_tasks` list and an idle parent never does. The row then - * gates the pane 'working' for the rest of its life and hibernation, which - * requires 'done', can never reclaim the agent's heap. - * - * Both the execution host and relay binding must prove local ownership before - * targeted PTY liveness is consulted. Panes that reported in this runtime are - * also skipped. Returns the number of panes changed. */ - async reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalExecutionHost: (worktreeId: string | undefined) => boolean, - isLocalPaneAgentLive: (paneKey: string) => Promise, - isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean - ): Promise { - const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - const enriched = entry as EnrichedAgentHookEventPayload - if ( - enriched.payload.agentType === 'claude' && - enriched.connectionId === null && - isLocalExecutionHost(enriched.worktreeId) && - // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, - // or a background-task/cron latch nothing will refresh, strands the pane just as - // permanently — and unlike the roster case there is no child event left to reap it. - (claudeRosterHasRestoredSnapshotSubagent( - this.state.claudeSubagentRosterByPaneKey.get(paneKey) - ) || - enriched.payload.state !== 'done' || - this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || - this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && - !this.runtimeObservedStatusPaneKeys.has(paneKey) - ) { - candidates.push({ paneKey, entry: enriched }) - } - } - const liveness = await Promise.all( - candidates.map(async (candidate) => { - try { - return await isLocalPaneAgentLive(candidate.paneKey) - } catch { - return true - } - }) - ) - let changedPanes = 0 - for (const [index, candidate] of candidates.entries()) { - const { paneKey, entry: enriched } = candidate - if ( - liveness[index] || - !isLocalPaneLivenessEvidenceCurrent(paneKey) || - this.state.lastStatusByPaneKey.get(paneKey) !== enriched || - this.runtimeObservedStatusPaneKeys.has(paneKey) || - !isLocalExecutionHost(enriched.worktreeId) - ) { - continue - } - if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { - // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably - // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the - // pane the same way an observed exit would — otherwise the widened candidate set is inert. - // - // Why delete rather than downgrade to `done` like the reap branch below: that branch has a - // real turn to describe — a parent whose children it just reaped — while these panes' only - // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would - // invent a completion that never happened, and leaving it non-terminal keeps the bug. This - // sweep stands in for the exit Orca never observed, so it does what that exit does: - // `clearProviderPtyState` -> `clearPaneState`. - if (this.hasLiveClaimsForPaneKey(paneKey)) { - this.clearPaneState(paneKey) - changedPanes += 1 - } - continue - } - changedPanes += 1 - const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) - const subagents = claudeRosterToSnapshots(roster) - // Why: the pane's persisted 'working' was the child gate holding a finished - // lead open (subagent events never set lead state). With the last working row - // gone and no process left to report, 'done' is the only truthful state — and - // the one hibernation needs once this pane's agent is restored. - const state = - enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) - ? 'done' - : enriched.payload.state - const stateChanged = state !== enriched.payload.state - const reconciledAt = stateChanged - ? Math.max(Date.now(), enriched.receivedAt + 1) - : enriched.receivedAt - // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying - // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. - const { restoredUnconfirmed, ...reconciledBase } = enriched - const reconciled: EnrichedAgentHookEventPayload = { - ...reconciledBase, - ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - receivedAt: reconciledAt, - stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, - payload: { - ...enriched.payload, - state, - workingMode: state === 'working' ? enriched.payload.workingMode : undefined, - subagents - } - } - this.state.lastStatusByPaneKey.set(paneKey, reconciled) - } - if (changedPanes > 0) { - this.scheduleStatusPersist() - this.notifyStatusChangeListeners() - } - return changedPanes - } - - buildPtyEnv(): Record { - if (this.port <= 0 || !this.token) { - return {} - } - - const env: Record = { - ORCA_AGENT_HOOK_PORT: String(this.port), - ORCA_AGENT_HOOK_TOKEN: this.token, - ORCA_AGENT_HOOK_ENV: this.env, - ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, - ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT - } - // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. - if (this.endpointFileWritten && this.endpointFilePathCache) { - env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache - } - return env - } - - get endpointFilePath(): string | null { - return this.endpointFilePathCache - } - - /** Test/diagnostic accessor for the on-disk last-status file path. */ - get lastStatusPath(): string | null { - return this.lastStatusFilePath - } - - private maybeWriteEndpointFile(): void { - if (!this.endpointDir || !this.endpointFilePathCache) { - return - } - this.endpointFileWritten = false - const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { - port: this.port, - token: this.token, - env: this.env, - version: ORCA_HOOK_PROTOCOL_VERSION, - transport: ORCA_HOOK_RAW_JSON_TRANSPORT - }) - this.endpointFileWritten = ok - } - - private hydrateLastStatusFromDisk(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. - this.state.lastStatusByPaneKey.clear() - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() - let raw: string - try { - raw = readFileSync(this.lastStatusFilePath, 'utf8') - } catch (err) { - // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[agent-hooks] failed to read last-status file:', err) - } - return - } - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') - return - } - if (typeof parsed !== 'object' || parsed === null) { - console.warn('[agent-hooks] last-status file is not an object; ignoring') - return - } - const file = parsed as Partial - if (file.version !== LAST_STATUS_FILE_VERSION) { - console.warn( - `[agent-hooks] last-status file version mismatch (${String( - file.version - )} != ${LAST_STATUS_FILE_VERSION}); ignoring` - ) - return - } - const entries = file.entries - if (typeof entries !== 'object' || entries === null) { - console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') - return - } - let hydrated = 0 - let dropped = 0 - let prunedLegacyClaudeSubagents = 0 - let scrubbedLegacyLaunchTokens = 0 - // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). - const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS - for (const [paneKey, rawEntry] of Object.entries(entries)) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const rawResolvedEntry = - resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null - ? rawEntry - : { ...(rawEntry as Record), paneKey: resolvedPaneKey } - const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) - if (entry && entry.receivedAt >= ttlCutoff) { - const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) - if (launchTokenHash) { - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) - const evidence = this.toAuthorityEvidence(entry, launchTokenHash) - if (evidence) { - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) - } - } - if ( - typeof rawResolvedEntry === 'object' && - rawResolvedEntry !== null && - typeof (rawResolvedEntry as Record).launchToken === 'string' - ) { - scrubbedLegacyLaunchTokens += 1 - } - const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) - if (hydratedPayload !== entry.payload) { - prunedLegacyClaudeSubagents += - (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) - entry.payload = hydratedPayload - } - if (entry.payload.state !== 'done') { - // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. - entry.restoredUnconfirmed = true - } - this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) - if (entry.connectionId) { - // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. - const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) - this.connectionTimestampWatermarkById.set( - entry.connectionId, - Math.max(previousWatermark ?? -1, entry.receivedAt) - ) - } - // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. - if (entry.payload.agentType === 'codex') { - seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) - } else if (entry.payload.agentType === 'claude') { - seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { - childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true - }) - if (entry.payload.subagents) { - seedClaudeSubagentRosterFromSnapshots( - this.state, - resolvedPaneKey, - entry.payload.subagents - ) - } - } - hydrated += 1 - } else { - dropped += 1 - } - } - for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { - const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) - const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) - if (!commitment || commitment.observedAt < ttlCutoff) { - dropped += 1 - continue - } - const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) - this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) - dropped += 1 - continue - } - this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) - this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) - } - if (dropped > 0) { - console.warn( - `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` - ) - } - if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { - // Why: persist load-time pruning and bearer scrubbing once. - this.runStatusPersist() - } else if (hydrated > 0) { - // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. - this.lastWrittenJson = raw - } - } - - private captureHydratedAuthorityCommitments(): void { - this.revokedHydratedAuthorityCommitments = new WeakSet() - for (const entry of this.state.lastStatusByPaneKey.values()) { - const evidence = this.toAuthorityEvidence( - entry as EnrichedAgentHookEventPayload, - this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) - ) - if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { - this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) - } - } - this.hydratedAuthorityCommitments = Object.freeze( - Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) - ) - } - - private recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { - const evidence = this.toAuthorityEvidence(payload) - if (evidence) { - this.currentAuthorityObservations.set(evidence.paneKey, evidence) - this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) - this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) - } - } - - private toAuthorityEvidence( - payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, - launchTokenHashOverride?: string - ): AgentHookAuthorityEvidence | null { - const launchToken = payload.launchToken?.trim() - const launchTokenHash = - launchTokenHashOverride ?? - (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) - if (!launchTokenHash) { - return null - } - return Object.freeze({ - paneKey: payload.paneKey, - launchTokenHash, - connectionId: payload.connectionId, - ...(payload.tabId ? { tabId: payload.tabId } : {}), - ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), - observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() - }) - } - - private serializeStatusFile(): string { - const entries: Record = {} - const authorityCommitments: Record = {} - const conflictedCommitments = new Set() - for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { - authorityCommitments[paneKey] = { ...commitment } - } - for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { - // Why: never persist invalid keys (matches the hydrate-path invariant). - if (!isValidPaneKey(paneKey)) { - continue - } - const enrichedPayload = payload as EnrichedAgentHookEventPayload - const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true - const { - claudeRunningNonAgentTask: _claudeRunningNonAgentTask, - promptInteractionKey: _promptInteractionKey, - // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. - restoredUnconfirmed: _restoredUnconfirmed, - // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). - observation: _observation, - // Replay provenance is runtime-only and must not survive another restart. - isReplay: _isReplay, - launchToken, - ...persistedPayload - } = enrichedPayload - const launchTokenHash = launchToken?.trim() - ? createHash('sha256').update(launchToken.trim()).digest('hex') - : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) - entries[paneKey] = { - ...persistedPayload, - ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), - ...(launchTokenHash ? { launchTokenHash } : {}) - } - const commitment = this.toAuthorityEvidence(payload, launchTokenHash) - if (commitment && !conflictedCommitments.has(paneKey)) { - const existing = authorityCommitments[paneKey] - if (existing && !authorityCommitmentsMatch(existing, commitment)) { - delete authorityCommitments[paneKey] - conflictedCommitments.add(paneKey) - } else { - authorityCommitments[paneKey] = { ...commitment } - } - } - } - const file: LastStatusFile = { - version: LAST_STATUS_FILE_VERSION, - entries, - authorityCommitments - } - return JSON.stringify(file) - } - - private scheduleStatusPersist(): void { - if (!this.lastStatusFilePath) { - return - } - // Why: reset the timer each call so the write fires only after the last event in a burst. - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - } - this.statusPersistTimer = setTimeout(() => { - this.statusPersistTimer = null - this.runStatusPersist() - }, STATUS_PERSIST_DEBOUNCE_MS) - // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. - if (typeof this.statusPersistTimer.unref === 'function') { - this.statusPersistTimer.unref() - } - } - - flushStatusPersistSync(): void { - if (this.statusPersistTimer) { - clearTimeout(this.statusPersistTimer) - this.statusPersistTimer = null - } - if (!this.lastStatusFilePath) { - return - } - this.runStatusPersist() - } - - private runStatusPersist(): void { - if (!this.lastStatusFilePath || !this.endpointDir) { - return - } - const json = this.serializeStatusFile() - if (json === this.lastWrittenJson) { - return - } - const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) - let tmpWritten = false - try { - mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) - if (process.platform !== 'win32') { - try { - chmodSync(this.endpointDir, 0o700) - } catch { - // best-effort - } - } - writeFileSync(tmpPath, json, { mode: 0o600 }) - tmpWritten = true - renameSync(tmpPath, this.lastStatusFilePath) - this.lastWrittenJson = json - } catch (err) { - console.warn('[agent-hooks] failed to write last-status file:', err) - if (tmpWritten) { - try { - unlinkSync(tmpPath) - } catch { - // tmp already gone - } - } - } - } - - /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ - _getStateForTests(): HookListenerState { - return this.state - } - - _resetPromptSentDedupeForTests(): void { - this.promptSentDedupeByPaneKey.clear() - } - - _resetConnectionTimestampWatermarksForTests(): void { - this.connectionTimestampWatermarkById.clear() - } -} +export { + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + PANE_KEY_ALIASES_MAX, + RETIRED_PANE_FENCES_MAX +} from './server/server-constants' +export { isValidPaneKey } + +/** Public composition seam for the loopback hook listener and relay status adapter. */ +export class AgentHookServer extends AgentHookServerLifecycle {} export const agentHookServer = new AgentHookServer() @@ -3540,3 +44,5 @@ export const _internals = { agentHookServer._resetConnectionTimestampWatermarksForTests() } } + +export type { HookListenerState } from '../../shared/agent-hook-listener/listener-state' diff --git a/src/main/agent-hooks/server/server-authority-aliases.ts b/src/main/agent-hooks/server/server-authority-aliases.ts new file mode 100644 index 00000000000..18756cb459c --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-aliases.ts @@ -0,0 +1,222 @@ +import { movePaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { canRegisterPaneKeyAlias, isOpaqueRemintedPaneKey } from '../../../shared/pane-key-alias' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { PANE_KEY_ALIASES_MAX } from './server-constants' +import type { EnrichedAgentHookEventPayload, PaneKeyAliasPersistenceListener } from './server-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import { isValidPaneKey } from './server-status-identity' +import { AgentHookServerAuthorityEvidence } from './server-authority-evidence' + +export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAuthorityEvidence { + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { + this.paneKeyAliasPersistenceListener = listener + } + + protected getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] { + return Array.from(this.legacyPaneKeyAliases.entries()).flatMap(([legacyPaneKey, entry]) => + entry.ptyId + ? [ + { + ptyId: entry.ptyId, + legacyPaneKey, + stablePaneKey: entry.stablePaneKey, + updatedAt: entry.updatedAt + } + ] + : [] + ) + } + + protected notifyPaneKeyAliasPersistenceListener(): void { + this.paneKeyAliasPersistenceListener?.(this.getPersistedPaneKeyAliases()) + } + + protected boundPaneKeyAliases(): void { + while (this.legacyPaneKeyAliases.size > PANE_KEY_ALIASES_MAX) { + // Why: renderer-originated aliases are untrusted; insertion-order eviction bounds memory and per-message cleanup. + const oldestKey = this.legacyPaneKeyAliases.keys().next().value + if (!oldestKey) { + break + } + this.legacyPaneKeyAliases.delete(oldestKey) + } + } + + protected getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + let fallbackPaneKey = paneKey + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if ( + entry.stablePaneKey === ownerPaneKey && + (!ptyId || !entry.ptyId || entry.ptyId === ptyId) + ) { + if (entry.authorityVerified) { + return physicalPaneKey + } + fallbackPaneKey = physicalPaneKey + } + } + return fallbackPaneKey + } + + canTransferPaneAuthority( + fromPaneKey: string, + ptyId: string | undefined, + ownsPty: (physicalPaneKey: string, ptyId: string) => boolean + ): boolean { + if (!isValidPaneKey(fromPaneKey)) { + return false + } + const ownerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const alias = this.legacyPaneKeyAliases.get(physicalPaneKey) + if (ptyId) { + return Boolean( + (alias?.authorityVerified && alias.ptyId === ptyId) || + ownsPty(physicalPaneKey, ptyId) || + (ownerPaneKey !== physicalPaneKey && ownsPty(ownerPaneKey, ptyId)) + ) + } + // Why: hook status is renderer evidence, not PTY ownership; ID-less moves are safe only after a verified transfer minted an alias. + return alias?.authorityVerified === true + } + + registerPaneKeyAlias( + legacyPaneKey: string, + stablePaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { overwriteExisting?: boolean; authorityVerified?: boolean } + ): void { + const fromPaneKey = legacyPaneKey.trim() + const toPaneKey = stablePaneKey.trim() + if (!canRegisterPaneKeyAlias(fromPaneKey, toPaneKey)) { + return + } + const existing = this.legacyPaneKeyAliases.get(fromPaneKey) + if (existing && options?.overwriteExisting === false) { + return + } + // Why: remint tokens have no embedded tab id; first pane wins so a later spawn + // cannot steal leftover $$…:L$$ posts onto a different tab:leaf. + if (existing && existing.stablePaneKey !== toPaneKey && isOpaqueRemintedPaneKey(fromPaneKey)) { + return + } + const normalizedPtyId = + typeof ptyId === 'string' && ptyId.trim().length > 0 ? ptyId.trim() : existing?.ptyId + const normalizedUpdatedAt = + Number.isFinite(updatedAt) && updatedAt > 0 ? updatedAt : (existing?.updatedAt ?? Date.now()) + const authorityVerified = options?.authorityVerified ?? false + if ( + existing && + existing.stablePaneKey === toPaneKey && + existing.ptyId === (normalizedPtyId ?? null) && + existing.updatedAt === normalizedUpdatedAt && + existing.authorityVerified === authorityVerified + ) { + return + } + this.legacyPaneKeyAliases.set(fromPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId ?? null, + updatedAt: normalizedUpdatedAt, + authorityVerified + }) + this.boundPaneKeyAliases() + if (normalizedPtyId) { + this.notifyPaneKeyAliasPersistenceListener() + } + } + + transferPaneAuthority( + fromPaneKey: string, + toPaneKey: string, + ptyId?: string, + updatedAt = Date.now(), + options?: { authorityVerified?: boolean } + ): void { + if (!isValidPaneKey(fromPaneKey) || !isValidPaneKey(toPaneKey)) { + return + } + const previousOwnerPaneKey = this.resolvePaneKeyAlias(fromPaneKey) + const physicalPaneKey = this.getPhysicalPaneKeyForAuthority(fromPaneKey, ptyId) + const existing = this.legacyPaneKeyAliases.get(physicalPaneKey) + const normalizedPtyId = ptyId?.trim() || existing?.ptyId || null + const hadStatus = this.state.lastStatusByPaneKey.has(previousOwnerPaneKey) + movePaneCacheState(this.state, previousOwnerPaneKey, toPaneKey) + const movedStatus = this.state.lastStatusByPaneKey.get(toPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (movedStatus) { + const owner = parsePaneKey(toPaneKey) + this.state.lastStatusByPaneKey.set(toPaneKey, { + ...movedStatus, + paneKey: toPaneKey, + tabId: owner?.tabId + }) + } + const hydratedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + if (hydratedLaunchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.hydratedLaunchTokenHashByPaneKey.set(toPaneKey, hydratedLaunchTokenHash) + } + const persistedAuthority = this.persistedAuthorityCommitmentsByPaneKey.get(previousOwnerPaneKey) + if (persistedAuthority) { + const owner = parsePaneKey(toPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(previousOwnerPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.set( + toPaneKey, + Object.freeze({ + ...persistedAuthority, + paneKey: toPaneKey, + ...(owner?.tabId ? { tabId: owner.tabId } : {}) + }) + ) + } + if (this.runtimeObservedStatusPaneKeys.delete(previousOwnerPaneKey)) { + this.runtimeObservedStatusPaneKeys.add(toPaneKey) + } + const restartedTokenHash = + this.restartedStatusLaunchTokenHashByPaneKey.get(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(previousOwnerPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(toPaneKey) + if (restartedTokenHash) { + this.restartedStatusLaunchTokenHashByPaneKey.set(toPaneKey, restartedTokenHash) + } + const activeTurnCompletedAt = this.activeHookTurnCompletedAtByPaneKey.get(previousOwnerPaneKey) + if (activeTurnCompletedAt !== undefined) { + this.activeHookTurnCompletedAtByPaneKey.delete(previousOwnerPaneKey) + this.activeHookTurnCompletedAtByPaneKey.set(toPaneKey, activeTurnCompletedAt) + } + const authorityObservation = this.currentAuthorityObservations.get(previousOwnerPaneKey) + if (authorityObservation) { + const owner = parsePaneKey(toPaneKey) + this.currentAuthorityObservations.delete(previousOwnerPaneKey) + this.currentAuthorityObservations.set( + toPaneKey, + Object.freeze({ ...authorityObservation, paneKey: toPaneKey, tabId: owner?.tabId }) + ) + } + const promptDedupe = this.promptSentDedupeByPaneKey.get(previousOwnerPaneKey) + if (promptDedupe !== undefined) { + this.promptSentDedupeByPaneKey.delete(previousOwnerPaneKey) + this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe) + } + this.clearAssistantMessageRetry(previousOwnerPaneKey) + this.clearCodexSubagentPoll(previousOwnerPaneKey) + // Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner. + this.legacyPaneKeyAliases.set(physicalPaneKey, { + stablePaneKey: toPaneKey, + ptyId: normalizedPtyId, + updatedAt, + authorityVerified: options?.authorityVerified ?? true + }) + this.boundPaneKeyAliases() + this.closedAgentStatusPaneKeys.delete(toPaneKey) + this.notifyPaneKeyAliasPersistenceListener() + if (hadStatus || persistedAuthority) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } +} diff --git a/src/main/agent-hooks/server/server-authority-evidence.ts b/src/main/agent-hooks/server/server-authority-evidence.ts new file mode 100644 index 00000000000..8cda3426629 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-evidence.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentHookAuthorityAttestation, + AgentHookAuthorityEvidence, + EnrichedAgentHookEventPayload +} from './server-types' +import { AgentHookServerStatusRetries } from './server-status-retries' + +export abstract class AgentHookServerAuthorityEvidence extends AgentHookServerStatusRetries { + attestCompatibilityAuthority(candidate: { + paneKey: string + launchTokenHash: string + connectionId: string | null + terminalProvenance: 'current_runtime' | 'restored' + }): AgentHookAuthorityAttestation | null { + const paneKey = this.resolvePaneKeyAlias(candidate.paneKey) + const matchesCandidate = (entry: AgentHookAuthorityEvidence): boolean => + entry.launchTokenHash === candidate.launchTokenHash && + entry.connectionId === candidate.connectionId + const commitments = this.hydratedAuthorityCommitments.filter( + (entry) => matchesCandidate(entry) && !this.revokedHydratedAuthorityCommitments.has(entry) + ) + const current = Array.from(this.currentAuthorityObservations.values()) + const observations = current.filter(matchesCandidate) + const paneObservations = current.filter( + (entry) => this.resolvePaneKeyAlias(entry.paneKey) === paneKey + ) + const hasUniqueCurrentObservation = + observations.length === 1 && + paneObservations.length === 1 && + this.resolvePaneKeyAlias(observations[0]!.paneKey) === paneKey + if (candidate.terminalProvenance === 'current_runtime') { + return hasUniqueCurrentObservation ? Object.freeze({ paneKey, source: 'current_hook' }) : null + } + if (commitments.length !== 1 || this.resolvePaneKeyAlias(commitments[0]!.paneKey) !== paneKey) { + return null + } + if (observations.length === 0 && paneObservations.length === 0) { + return Object.freeze({ paneKey, source: 'hydrated_commitment' }) + } + if (!hasUniqueCurrentObservation) { + return null + } + return Object.freeze({ paneKey, source: 'current_hook' }) + } + + protected captureHydratedAuthorityCommitments(): void { + this.revokedHydratedAuthorityCommitments = new WeakSet() + for (const entry of this.state.lastStatusByPaneKey.values()) { + const evidence = this.toAuthorityEvidence( + entry as EnrichedAgentHookEventPayload, + this.hydratedLaunchTokenHashByPaneKey.get(entry.paneKey) + ) + if (evidence && !this.persistedAuthorityCommitmentsByPaneKey.has(entry.paneKey)) { + this.persistedAuthorityCommitmentsByPaneKey.set(entry.paneKey, evidence) + } + } + this.hydratedAuthorityCommitments = Object.freeze( + Array.from(this.persistedAuthorityCommitmentsByPaneKey.values()) + ) + } + + protected recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void { + const evidence = this.toAuthorityEvidence(payload) + if (evidence) { + this.currentAuthorityObservations.set(evidence.paneKey, evidence) + this.persistedAuthorityCommitmentsByPaneKey.set(evidence.paneKey, evidence) + this.hydratedLaunchTokenHashByPaneKey.set(evidence.paneKey, evidence.launchTokenHash) + } + } + + protected toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null { + const launchToken = payload.launchToken?.trim() + const launchTokenHash = + launchTokenHashOverride ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : null) + if (!launchTokenHash) { + return null + } + return Object.freeze({ + paneKey: payload.paneKey, + launchTokenHash, + connectionId: payload.connectionId, + ...(payload.tabId ? { tabId: payload.tabId } : {}), + ...(payload.worktreeId ? { worktreeId: payload.worktreeId } : {}), + observedAt: 'receivedAt' in payload ? payload.receivedAt : Date.now() + }) + } +} diff --git a/src/main/agent-hooks/server/server-authority-fences.ts b/src/main/agent-hooks/server/server-authority-fences.ts new file mode 100644 index 00000000000..0ad1bdeba62 --- /dev/null +++ b/src/main/agent-hooks/server/server-authority-fences.ts @@ -0,0 +1,193 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { AgentHookServerAuthorityAliases } from './server-authority-aliases' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' + +export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuthorityAliases { + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + retirePaneAuthority(paneKey: string): void { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, ownerPaneKey]) + const retiredAliases: RetiredPaneAlias[] = [] + let aliasChanged = false + for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) { + if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) { + this.legacyPaneKeyAliases.delete(physicalPaneKey) + retiredAliases.push({ physicalPaneKey, entry }) + paneKeys.add(physicalPaneKey) + paneKeys.add(entry.stablePaneKey) + aliasChanged = true + } + } + this.recordRetiredPaneFence(paneKeys, retiredAliases) + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key)) + for (const key of paneKeys) { + this.markPaneClosedForAgentStatus(key) + this.restartedStatusLaunchTokenHashByPaneKey.delete(key) + this.clearAssistantMessageRetry(key) + this.clearCodexSubagentPoll(key) + clearPaneCacheState(this.state, key) + this.activeHookTurnCompletedAtByPaneKey.delete(key) + this.runtimeObservedStatusPaneKeys.delete(key) + this.currentAuthorityObservations.delete(key) + this.promptSentDedupeByPaneKey.delete(key) + this.observations.forget(key) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + // Why: retirement fences a pane and every alias of it, then deletes those aliases. + // Lifting only the key we are handed strands the rest — a detached pane's process + // keeps posting the key it launched under, so it would stay suppressed forever with + // the fence apparently lifted. Replay the recorded fence instead: same key set, same + // aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger + // claim survives and a live process is never routed back into a closed tab. + protected restoreRetiredPaneFence(fence: RetiredPaneFence): void { + let aliasChanged = false + for (const { physicalPaneKey, entry } of fence.aliases) { + if ( + this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) || + this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) || + // Why: the pane was rebound in the meantime; the newer alias is the truth. + this.legacyPaneKeyAliases.has(physicalPaneKey) + ) { + continue + } + this.legacyPaneKeyAliases.set(physicalPaneKey, entry) + aliasChanged = true + } + for (const key of fence.paneKeys) { + if (this.retiredPaneFencesByKey.get(key) === fence) { + this.retiredPaneFencesByKey.delete(key) + } + } + if (aliasChanged) { + this.boundPaneKeyAliases() + this.notifyPaneKeyAliasPersistenceListener() + } + } + + restorePaneAuthority(paneKey: string): boolean { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) { + return false + } + // Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that + // exact pane disproves the claim at the moment it stops being true, so the fence + // lifts here instead of waiting for the agent to speak again — an agent re-attached + // mid-turn or left idle would otherwise stay suppressed for the rest of its life + // (STA-4114). A closed *tab* is a separate, stronger claim and is left standing. + const fence = + this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey) + let restored = false + for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) { + if (this.isClosedAgentStatusTabForPaneKey(key)) { + continue + } + if (this.closedAgentStatusPaneKeys.delete(key)) { + restored = true + } + } + if (fence) { + this.restoreRetiredPaneFence(fence) + } + return restored + } + + clearPaneKeyAliasesForPty( + ptyId: string, + options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean } + ): void { + let aliasChanged = false + let statusChanged = false + const clearedStatusPaneKeys = new Set() + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (entry.ptyId !== ptyId) { + continue + } + const shouldClearStablePaneKey = + options?.shouldClearStablePaneKey?.(entry.stablePaneKey) ?? true + const revokedPaneKeys = new Set([legacyPaneKey]) + if (shouldClearStablePaneKey) { + revokedPaneKeys.add(entry.stablePaneKey) + } + if (this.revokeHydratedAuthorityForPaneKeys(revokedPaneKeys)) { + statusChanged = true + } + this.legacyPaneKeyAliases.delete(legacyPaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + if (shouldClearStablePaneKey && this.state.lastStatusByPaneKey.has(entry.stablePaneKey)) { + statusChanged = true + clearedStatusPaneKeys.add(entry.stablePaneKey) + } + if (shouldClearStablePaneKey) { + // Why: hydrated rows live under the stable key; if this PTY dies before ptyPaneKey rebuilds, alias cleanup is the only evictor. + clearPaneCacheState(this.state, entry.stablePaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(entry.stablePaneKey) + this.runtimeObservedStatusPaneKeys.delete(entry.stablePaneKey) + this.currentAuthorityObservations.delete(entry.stablePaneKey) + this.promptSentDedupeByPaneKey.delete(entry.stablePaneKey) + } + aliasChanged = true + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + for (const paneKey of clearedStatusPaneKeys) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + + protected resolvePaneKeyAlias(paneKey: string): string { + return this.legacyPaneKeyAliases.get(paneKey)?.stablePaneKey ?? paneKey + } + + protected revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean { + let changed = false + for (const commitment of this.hydratedAuthorityCommitments) { + if ( + paneKeys.has(commitment.paneKey) || + paneKeys.has(this.resolvePaneKeyAlias(commitment.paneKey)) + ) { + this.revokedHydratedAuthorityCommitments.add(commitment) + changed = true + } + } + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + changed = this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) || changed + changed = this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) || changed + changed = this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) || changed + } + return changed + } + + protected normalizeHookBodyPaneKeyAlias(body: unknown): unknown { + if (typeof body !== 'object' || body === null) { + return body + } + const record = body as Record + const rawPaneKey = typeof record.paneKey === 'string' ? record.paneKey.trim() : '' + const stablePaneKey = this.legacyPaneKeyAliases.get(rawPaneKey)?.stablePaneKey + if (!stablePaneKey) { + return body + } + // Why: detached shells keep posting the immutable physical pane key; normalize pane and tab identity to the current owner. + return { ...record, paneKey: stablePaneKey, tabId: parsePaneKey(stablePaneKey)?.tabId } + } +} diff --git a/src/main/agent-hooks/server/server-claude-status-rules.ts b/src/main/agent-hooks/server/server-claude-status-rules.ts new file mode 100644 index 00000000000..9607e4a9dc8 --- /dev/null +++ b/src/main/agent-hooks/server/server-claude-status-rules.ts @@ -0,0 +1,191 @@ +import { claudeTeammateIdMatchesName } from '../../../shared/claude-subagent-roster' +import { isAskUserQuestionTool } from '../../../shared/agent-question-answered-intent' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export function attachClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } { + const establishesBoundary = + next.payload.agentType === 'claude' && + (next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') && + !next.toolAgentId && + next.payload.state === 'working' && + next.payload.subagents?.some((subagent) => subagent.state === 'working') === true && + next.claudeRunningNonAgentTask === false + const carriesBoundary = + previous?.claudeLeadBoundaryChildOnly === true && + next.payload.agentType === 'claude' && + next.claudeRunningNonAgentTask === false && + (next.toolAgentId !== undefined || + next.hookEventName === 'SubagentStart' || + next.hookEventName === 'SubagentStop' || + next.hookEventName === 'TeammateIdle') + return establishesBoundary || carriesBoundary + ? { ...next, claudeLeadBoundaryChildOnly: true } + : next +} + +export function invalidateClaudeChildOnlyBoundary( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): EnrichedAgentHookEventPayload | undefined { + if ( + previous?.claudeLeadBoundaryChildOnly !== true || + attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true + ) { + return previous + } + const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous + return withoutBoundary +} + +export function shouldKeepClaudePermissionVisible( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if (previous?.restoredUnconfirmed) { + return false + } + if ( + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || + next.payload.agentType !== 'claude' || + next.payload.state !== 'working' + ) { + return false + } + if (next.hasExplicitPrompt === true) { + return false + } + if (isClaudePermissionOwningChildEnding(previous, next)) { + return false + } + if (isClaudePermissionResumingApprovedTool(previous, next)) { + return false + } + // Why: only real permission requests stay sticky; newer Claude reports AskUserQuestion as a PermissionRequest, so tool name (not event) decides. + if (isAskUserQuestionTool(previous.payload.toolName)) { + return false + } + return true +} + +function isClaudePermissionOwningChildEnding( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const ownerId = previous.toolAgentId?.trim() + if (!ownerId) { + return false + } + if (next.hookEventName === 'SubagentStop') { + return ownerId === next.toolAgentId?.trim() + } + return ( + next.hookEventName === 'TeammateIdle' && + next.teammateName !== undefined && + claudeTeammateIdMatchesName(ownerId, next.teammateName) + ) +} + +function isClaudePermissionResumingApprovedTool( + previous: EnrichedAgentHookEventPayload, + next: AgentHookEventPayload +): boolean { + const previousToolUseId = previous.toolUseId?.trim() || undefined + const nextToolUseId = next.toolUseId?.trim() || undefined + const previousAgentId = previous.toolAgentId?.trim() || undefined + const nextAgentId = next.toolAgentId?.trim() || undefined + const hasAgentId = previousAgentId !== undefined || nextAgentId !== undefined + const previousAgentType = previous.toolAgentType?.trim() || undefined + const nextAgentType = next.toolAgentType?.trim() || undefined + const hasMatchingConcreteAgentId = + previousAgentId !== undefined && previousAgentId === nextAgentId + const hasSameExplicitAgentType = + !hasAgentId && previousAgentType !== undefined && previousAgentType === nextAgentType + const sameToolName = + previous.payload.toolName !== undefined && previous.payload.toolName === next.payload.toolName + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownInputFromConcreteAgent = + hasMatchingConcreteAgentId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + const hasMatchingToolUseId = + previousToolUseId !== undefined && previousToolUseId === nextToolUseId + const hasConflictingToolUseId = + previousToolUseId !== undefined && + nextToolUseId !== undefined && + previousToolUseId !== nextToolUseId + const sameUnknownInputFromToolUseId = + hasMatchingToolUseId && + previous.payload.toolInput === undefined && + next.payload.toolInput === undefined + + return ( + (next.hookEventName === 'PreToolUse' || next.hookEventName === 'PostToolUse') && + nextToolUseId !== undefined && + !hasConflictingToolUseId && + // Why: subagents share agent_type, so a concrete agent id (or the preserved PostToolUse tool_use_id) is the safest resume signal. + (hasMatchingConcreteAgentId || hasSameExplicitAgentType || hasMatchingToolUseId) && + sameToolName && + (sameKnownToolInput || sameUnknownInputFromConcreteAgent || sameUnknownInputFromToolUseId) + ) +} + +export function shouldInheritClaudeToolUseIdForPermission( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): boolean { + if ( + previous?.restoredUnconfirmed || + previous?.payload.agentType !== 'claude' || + previous.payload.state !== 'working' || + previous.hookEventName !== 'PreToolUse' || + typeof previous.toolUseId !== 'string' || + previous.toolUseId.trim().length === 0 || + next.payload.agentType !== 'claude' || + next.payload.state !== 'waiting' || + next.hookEventName !== 'PermissionRequest' || + next.toolUseId !== undefined + ) { + return false + } + const sameKnownToolInput = + previous.payload.toolInput !== undefined && + previous.payload.toolInput === next.payload.toolInput + const sameUnknownToolInput = + previous.payload.toolInput === undefined && next.payload.toolInput === undefined + if ( + previous.toolAgentId !== next.toolAgentId || + previous.toolAgentType !== next.toolAgentType || + previous.payload.toolName === undefined || + previous.payload.toolName !== next.payload.toolName || + (!sameKnownToolInput && !sameUnknownToolInput) + ) { + return false + } + return true +} + +export function attachClaudePermissionToolUseId( + previous: EnrichedAgentHookEventPayload | undefined, + next: AgentHookEventPayload +): AgentHookEventPayload { + const inheritedToolUseId = previous?.toolUseId + if ( + !shouldInheritClaudeToolUseIdForPermission(previous, next) || + typeof inheritedToolUseId !== 'string' + ) { + return next + } + return { + ...next, + // Why: Claude emits PermissionRequest without tool_use_id, then PostToolUse carries the original PreToolUse id. + toolUseId: inheritedToolUseId + } +} diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts new file mode 100644 index 00000000000..04acc058dea --- /dev/null +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -0,0 +1,167 @@ +import { paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerAuthorityFences } from './server-authority-fences' + +export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFences { + /** The resume-identity remnant of a dropped row: a `providerSessionOnly` entry carries no state + * claim — it cannot gate a pane `working` — so it survives teardowns that end the pane's live + * claims. Returns null when the row has no resumable session to keep. */ + protected toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null { + if ( + !entry?.providerSession || + !entry.payload.agentType || + entry.payload.agentType === 'unknown' + ) { + return null + } + const { launchToken: _launchToken, ...resumeIdentity } = entry + return { ...resumeIdentity, providerSessionOnly: true, retainedForLiveness: true } + } + + /** Drop only the status row (user dismissal); do NOT wipe prompt/tool caches since the pane's agent may still be alive. Use clearPaneState for PTY-teardown. */ + dropStatusEntry(paneKey: string): void { + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (!deleted) { + return + } + const retained = this.toRetainedProviderSessionRow(deleted) + if (retained) { + this.state.lastStatusByPaneKey.set(deleted.paneKey, retained) + } + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitStatusDropped(deleted.paneKey) + } + + /** Retire panes whose owning process is certifiably dead. + * + * The ordinary teardown already does this: every attributable PTY exit reaches + * `clearProviderPtyState`, which resolves the pane key and calls `clearPaneState`. But that + * resolution depends on the spawn-time `ptyPaneKey` mapping, which a restored/reattached PTY may + * never rebuild — so those panes keep a `working` row and its latches for good, with no hook left + * to retire them. This is the same operation reached from the runtime's own pane-key knowledge, + * so a dead pane is cleaned up identically however its keys were resolved. */ + reconcileEndedProcessForPaneKeys( + paneKeys: Iterable, + options?: { + /** The pane's PTY outlived its agent (a confirmed shell foreground), so the session can still + * be resumed in place — keep the `providerSessionOnly` remnant the paired `agentStatus:drop` + * minted for exactly this case. A certified PTY exit passes nothing: there is no pane left to + * resume into, and dropping it matches what `clearProviderPtyState` already does. */ + preserveResumeIdentity?: boolean + } + ): number { + // A certified PTY exit passes no resume identity; a surviving shell may opt into the remnant. + let cleared = 0 + for (const paneKey of paneKeys) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + if (!this.hasLiveClaimsForPaneKey(resolvedPaneKey)) { + continue + } + const retained = options?.preserveResumeIdentity + ? this.toRetainedProviderSessionRow( + this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + ) + : null + this.clearPaneState(resolvedPaneKey) + if (retained) { + this.state.lastStatusByPaneKey.set(resolvedPaneKey, retained) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + cleared += 1 + } + return cleared + } + + /** Anything a dead pane could still be asserting: a row, or a latch that would re-gate one through + * `resolveClaudePaneState` on the pane's next event even after the row reads `done`. The list + * itself lives beside `clearPaneCacheState`, so adding a latch cannot leave this behind in a + * different file. */ + protected hasLiveClaimsForPaneKey(paneKey: string): boolean { + return paneHasStateClaims(this.state, paneKey) + } + + /** Clear statuses proven to belong to one lost SSH transport. */ + clearStatusEntriesForConnection(connectionId: string): void { + const normalizedConnectionId = connectionId.trim() + if (normalizedConnectionId.length === 0) { + return + } + const clearedAt = Math.max( + Date.now(), + (this.connectionTimestampWatermarkById.get(normalizedConnectionId) ?? -1) + 1 + ) + this.connectionTimestampWatermarkById.set(normalizedConnectionId, clearedAt) + let statusChanged = false + for (const [paneKey, rawEntry] of this.state.lastStatusByPaneKey) { + const entry = rawEntry as EnrichedAgentHookEventPayload + // Why: unstamped rows can't be attributed to one host; leave them for normal pane teardown. + if (entry.connectionId !== normalizedConnectionId) { + continue + } + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + if (deleted) { + statusChanged = true + if (deleted.payload.agentType === 'codex') { + // Why: a replacement remote process may reuse the pane; don't merge it with the lost connection's children. + this.state.codexSubagentRosterByPaneKey.delete(paneKey) + this.state.codexLeadStateByPaneKey.delete(paneKey) + } else if (deleted.payload.agentType === 'claude') { + this.state.claudeSubagentRosterByPaneKey.delete(paneKey) + this.state.claudeLeadStateByPaneKey.delete(paneKey) + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + this.state.claudeSessionOwnerByPaneKey.delete(paneKey) + } + } + } + for (const [paneKey, evidence] of this.currentAuthorityObservations) { + if (evidence.connectionId === normalizedConnectionId) { + this.currentAuthorityObservations.delete(paneKey) + } + } + if (statusChanged) { + // Why: persist/notify once — one disconnect can own many panes. + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. + this.emitPaneStatusCleared({ + transient: true, + connectionId: normalizedConnectionId, + clearedAt + }) + } + + protected deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return null + } + this.state.lastStatusByPaneKey.delete(resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + if (!options?.preserveAuthority) { + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + } + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + if (existing.payload.state === 'done') { + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + } + return existing + } +} diff --git a/src/main/agent-hooks/server/server-constants.ts b/src/main/agent-hooks/server/server-constants.ts new file mode 100644 index 00000000000..7fa7901ae73 --- /dev/null +++ b/src/main/agent-hooks/server/server-constants.ts @@ -0,0 +1,29 @@ +import { AGENT_KIND_VALUES, type AgentKind } from '../../../shared/telemetry-events' + +// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together. +export const LAST_STATUS_FILE_NAME = 'last-status.json' +export const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5 +export const ASSISTANT_MESSAGE_RETRY_MS = 50 +export const CODEX_SUBAGENT_POLL_MS = 1_000 +export const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000 + +// Why: starts at 2 — pre-merge v1 lacked receivedAt/stateStartedAt (never shipped); a mismatched version hydrates empty (treated as corrupt). +export const LAST_STATUS_FILE_VERSION = 2 + +// Why: trailing-edge debounce so a burst of hook events yields one disk write, not N; quit-time flushStatusPersistSync() guarantees the final flush. +export const STATUS_PERSIST_DEBOUNCE_MS = 250 +export const TOOL_PROGRESS_HOOK_EVENTS = new Set([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure' +]) +export const AGENT_PROMPT_SENT_AGENT_KINDS = new Set(AGENT_KIND_VALUES) + +// Why: bound file growth from PTYs that never re-attach; 7 days is the "still relevant?" horizon beyond which entries shouldn't resurrect on hydrate. +export const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 + +// Why: a long-closed tab can't receive status events; bound the set so it can't grow one entry per close for the whole session. +export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024 +export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024 +export const PANE_KEY_ALIASES_MAX = 1024 +export const RETIRED_PANE_FENCES_MAX = 1024 diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts new file mode 100644 index 00000000000..70da93b7c3b --- /dev/null +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -0,0 +1,162 @@ +import { readFileSync } from 'node:fs' + +import { + seedClaudeLeadTurnFromPersistedStatus, + seedClaudeSubagentRosterFromSnapshots +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/providers/codex-state' +import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants' +import type { LastStatusFile } from './server-types' +import { + authorityCommitmentsMatch, + dropHydratedIdleClaudeSubagents, + readPersistedLaunchTokenHash, + sanitizeHydratedEntry, + sanitizePersistedAuthorityCommitment +} from './server-persistence-validation' +import { AgentHookServerReaping } from './server-reaping' + +export abstract class AgentHookServerHydration extends AgentHookServerReaping { + /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ + protected hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + this.state.lastStatusByPaneKey.clear() + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + let raw: string + try { + raw = readFileSync(this.lastStatusFilePath, 'utf8') + } catch (err) { + // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[agent-hooks] failed to read last-status file:', err) + } + return + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') + return + } + if (typeof parsed !== 'object' || parsed === null) { + console.warn('[agent-hooks] last-status file is not an object; ignoring') + return + } + const file = parsed as Partial + if (file.version !== LAST_STATUS_FILE_VERSION) { + console.warn( + `[agent-hooks] last-status file version mismatch (${String( + file.version + )} != ${LAST_STATUS_FILE_VERSION}); ignoring` + ) + return + } + const entries = file.entries + if (typeof entries !== 'object' || entries === null) { + console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') + return + } + let hydrated = 0 + let dropped = 0 + let prunedLegacyClaudeSubagents = 0 + let scrubbedLegacyLaunchTokens = 0 + // Why: drop entries older than HYDRATE_MAX_AGE_MS to bound disk growth (one Date.now() for a consistent cutoff). + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + for (const [paneKey, rawEntry] of Object.entries(entries)) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const rawResolvedEntry = + resolvedPaneKey === paneKey || typeof rawEntry !== 'object' || rawEntry === null + ? rawEntry + : { ...(rawEntry as Record), paneKey: resolvedPaneKey } + const entry = sanitizeHydratedEntry(resolvedPaneKey, rawResolvedEntry) + if (entry && entry.receivedAt >= ttlCutoff) { + const launchTokenHash = readPersistedLaunchTokenHash(rawResolvedEntry) + if (launchTokenHash) { + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, launchTokenHash) + const evidence = this.toAuthorityEvidence(entry, launchTokenHash) + if (evidence) { + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, evidence) + } + } + if ( + typeof rawResolvedEntry === 'object' && + rawResolvedEntry !== null && + typeof (rawResolvedEntry as Record).launchToken === 'string' + ) { + scrubbedLegacyLaunchTokens += 1 + } + const hydratedPayload = dropHydratedIdleClaudeSubagents(entry.payload) + if (hydratedPayload !== entry.payload) { + prunedLegacyClaudeSubagents += + (entry.payload.subagents?.length ?? 0) - (hydratedPayload.subagents?.length ?? 0) + entry.payload = hydratedPayload + } + if (entry.payload.state !== 'done') { + // Why: the terminal transition may have fired while no receiver was up; restore as unconfirmed, never as live truth. + entry.restoredUnconfirmed = true + } + this.state.lastStatusByPaneKey.set(resolvedPaneKey, entry) + if (entry.connectionId) { + // Why: a restart can see an earlier wall clock; seed ordering so new events stay after disk state. + const previousWatermark = this.connectionTimestampWatermarkById.get(entry.connectionId) + this.connectionTimestampWatermarkById.set( + entry.connectionId, + Math.max(previousWatermark ?? -1, entry.receivedAt) + ) + } + // Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds. + if (entry.payload.agentType === 'codex') { + seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload) + } else if (entry.payload.agentType === 'claude') { + seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, { + childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true + }) + if (entry.payload.subagents) { + seedClaudeSubagentRosterFromSnapshots( + this.state, + resolvedPaneKey, + entry.payload.subagents + ) + } + } + hydrated += 1 + } else { + dropped += 1 + } + } + for (const [paneKey, rawCommitment] of Object.entries(file.authorityCommitments ?? {})) { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const commitment = sanitizePersistedAuthorityCommitment(resolvedPaneKey, rawCommitment) + if (!commitment || commitment.observedAt < ttlCutoff) { + dropped += 1 + continue + } + const existing = this.persistedAuthorityCommitmentsByPaneKey.get(resolvedPaneKey) + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + dropped += 1 + continue + } + this.persistedAuthorityCommitmentsByPaneKey.set(resolvedPaneKey, commitment) + this.hydratedLaunchTokenHashByPaneKey.set(resolvedPaneKey, commitment.launchTokenHash) + } + if (dropped > 0) { + console.warn( + `[agent-hooks] last-status hydrate dropped ${dropped} entries (kept ${hydrated})` + ) + } + if (dropped > 0 || prunedLegacyClaudeSubagents > 0 || scrubbedLegacyLaunchTokens > 0) { + // Why: persist load-time pruning and bearer scrubbing once. + this.runStatusPersist() + } else if (hydrated > 0) { + // Why: prime dedup from raw bytes (not re-serialized) only when hydration was lossless. + this.lastWrittenJson = raw + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-normalization.ts b/src/main/agent-hooks/server/server-ingest-normalization.ts new file mode 100644 index 00000000000..0a1e8d761d6 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-normalization.ts @@ -0,0 +1,81 @@ +import { buildSpoolHookBody, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +import type { NormalizedLocalHook } from './server-types' +import { AgentHookServerPersistence } from './server-persistence' + +export abstract class AgentHookServerIngestNormalization extends AgentHookServerPersistence { + protected setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void { + if (hasRunningTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + if (hasActiveCron) { + this.state.claudeActiveSessionCronPaneKeys.add(paneKey) + } else { + this.state.claudeActiveSessionCronPaneKeys.delete(paneKey) + } + } + + protected normalizeLocalHookPayload(source: AgentHookSource, body: unknown): NormalizedLocalHook { + if (source !== 'claude' || typeof body !== 'object' || body === null) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const rawPaneKey = (body as Record).paneKey + const paneKey = typeof rawPaneKey === 'string' ? rawPaneKey.trim() : '' + if (!paneKey) { + return { event: normalizeHookPayload(this.state, source, body, this.env) } + } + const previousRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const previousActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + const event = normalizeHookPayload(this.state, source, body, this.env) + const nextRunningTask = this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) + const nextActiveCron = this.state.claudeActiveSessionCronPaneKeys.has(paneKey) + this.setClaudeBackgroundEvidence(paneKey, previousRunningTask, previousActiveCron) + if (!event || event.paneKey !== paneKey) { + return { event } + } + // Why: nested CLIs may inherit the pane key; only accepted statuses may mutate its background-work gate. + return { + event, + onAccepted: () => this.setClaudeBackgroundEvidence(paneKey, nextRunningTask, nextActiveCron) + } + } + + // Spool records are durable replay evidence, not a live observation. + protected ingestSpoolRecord(record: SpoolRecord): void { + if (!isAgentHookSource(record.source)) { + return + } + const body = this.normalizeHookBodyPaneKeyAlias(buildSpoolHookBody(record)) + const normalized = this.normalizeLocalHookPayload(record.source, body) + if (!normalized.event) { + return + } + const replay = { ...normalized.event, isReplay: true as const } + const statusDisposition = this.getAgentStatusDisposition(replay.paneKey, { + source: record.source, + hookEventName: replay.hookEventName, + isReplay: true, + hasExplicitPrompt: replay.hasExplicitPrompt, + launchToken: replay.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + const event = statusDisposition === 'restart' ? { ...replay, launchToken: undefined } : replay + if (statusDisposition === 'restart') { + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus(event, normalized.onAccepted) + if (event.payload.state !== 'done') { + this.withdrawReplayObservation(this.resolvePaneKeyAlias(event.paneKey)) + } + } +} diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts new file mode 100644 index 00000000000..18b0a837c32 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -0,0 +1,282 @@ +import { track } from '../../telemetry/client' +import { normalizeAgentStatusPayload } from '../../../shared/agent-status-types' +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { isAgentHookSource, restoreShedStatusFields } from '../../../shared/agent-hook-relay' +import { + MAX_PANE_KEY_LEN, + normalizeClaudePromptId, + warnOnHookEnvOrVersionMismatch +} from '../../../shared/agent-hook-listener/listener-limits' +import { + canAcceptClaudeCompactCompletion, + isClaudeCompactCompletionConsumed, + markClaudeCompactCompletionConsumed, + resolveLegacyCompactTrigger +} from '../../../shared/claude-compact-completion' +import { launchTokenHash } from '../../../shared/agent-hook-spool' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { isValidPiProviderSessionOnly } from './server-status-identity' +import { AgentHookServerIngestTerminal } from './server-ingest-terminal' + +export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestTerminal { + /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ + ingestRemote( + envelope: { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + payload: unknown + }, + connectionId: string | null + ): void { + // Why: wire crosses a trust boundary — re-check/trim so an empty connectionId can't poison caches. + if (connectionId !== null && typeof connectionId !== 'string') { + return + } + const trimmedConnectionId = connectionId?.trim() ?? null + if (trimmedConnectionId !== null && trimmedConnectionId.length === 0) { + return + } + if (!envelope || typeof envelope.paneKey !== 'string') { + return + } + // Why: trim paneKey to match the HTTP path, else remote-vs-local events for one pane diverge. + const physicalPaneKey = envelope.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + // Why: fence relay spool replay at main so stale generations cannot overwrite hydrated state. + if (envelope.isReplay === true) { + const expectedLaunchTokenHash = this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + const actualLaunchTokenHash = launchTokenHash(envelope.launchToken) + if (expectedLaunchTokenHash && actualLaunchTokenHash !== expectedLaunchTokenHash) { + return + } + } + if (envelope.tabId !== undefined && typeof envelope.tabId !== 'string') { + return + } + if (envelope.worktreeId !== undefined && typeof envelope.worktreeId !== 'string') { + return + } + // Why: mirror the HTTP path's readStringField — trim and treat empty-after-trim as undefined. + const reportedTabId = + envelope.tabId !== undefined && envelope.tabId.trim().length > 0 + ? envelope.tabId.trim() + : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + const hookEventName = + typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 + ? envelope.hookEventName.trim() + : undefined + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined + const statusDisposition = this.getAgentStatusDisposition(paneKey, { + source, + rawSource: envelope.source, + hookEventName, + isReplay: envelope.isReplay === true, + hasExplicitPrompt: envelope.hasExplicitPrompt === true, + launchToken: envelope.launchToken + }) + if (statusDisposition === 'suppress') { + return + } + if (statusDisposition === 'restart') { + // Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session. + // Why paneKey, not envelope.paneKey: alias resolution already mapped it to the + // stable pane, so the rebind cannot land on a legacy key. + this.observations.rebind(paneKey) + } + const worktreeId = + envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0 + ? envelope.worktreeId.trim() + : undefined + const promptInteractionKey = + typeof envelope.promptInteractionKey === 'string' && + envelope.promptInteractionKey.trim().length > 0 + ? envelope.promptInteractionKey.trim() + : undefined + const toolUseId = + typeof envelope.toolUseId === 'string' && envelope.toolUseId.trim().length > 0 + ? envelope.toolUseId.trim() + : undefined + const toolAgentId = + typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0 + ? envelope.toolAgentId.trim() + : undefined + const teammateName = + typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0 + ? envelope.teammateName.trim() + : undefined + const toolAgentType = + typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 + ? envelope.toolAgentType.trim() + : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined + // Why: relay crosses a trust boundary — re-run the canonical normalizer to enforce caps/invariants (returns null on malformed). + const validatedPayload = normalizeAgentStatusPayload(envelope.payload) + if (!validatedPayload) { + return + } + // Why: restore a shed roster only when its digest and turn identity still match the cache. + let normalizedPayload = restoreShedStatusFields( + validatedPayload, + envelope.shedFields, + this.state.lastStatusByPaneKey.get(paneKey)?.payload + ) + const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + let acceptedCompactCompletion = false + if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { + // Why: PreCompact is never registered and proves nothing (an aborted compact emits it alone); + // reject it here too so a host on any version cannot drive pane state from it. + if (hookEventName === 'PreCompact' || source !== 'claude') { + return + } + // Why: a relay predating this change strips `compactTrigger` from its cached PostCompact + // before replaying it, so the replay has no manual/auto discriminator. That relay's mapping is + // fixed and known — manual produced `done`, auto produced `working` — so the payload state + // stands in for the missing trigger. Trigger substitution only; ownership is still checked. + const effectiveTrigger = resolveLegacyCompactTrigger(compactTrigger, normalizedPayload.state) + // Why: an auto compact happens inside a turn that resumes and emits its own Stop. An older + // relay maps it to `working`, and this ingest applies the relay's payload verbatim — so + // without this drop, every auto compact on such a host mints exactly the stuck `working` this + // change removes. + if (effectiveTrigger !== 'manual' || normalizedPayload.agentType !== source) { + return + } + if ( + isClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) || + !canAcceptClaudeCompactCompletion(previousStatus, { + source, + connectionId: trimmedConnectionId, + providerPromptId, + providerSession + }) + ) { + return + } + markClaudeCompactCompletionConsumed( + this.state.claudeConsumedCompactPromptIdByPaneKey, + paneKey, + providerPromptId + ) + // Why: an older relay built this payload before the boundary flag existed, so it arrives as a + // plain `done` — which every completion-reactive consumer reads as a finished turn. Stamp the + // boundary here so a compact stays silent regardless of which relay normalized it. + if (normalizedPayload.sessionBoundary !== true) { + normalizedPayload = { ...normalizedPayload, sessionBoundary: true } + } + acceptedCompactCompletion = true + } + // Why: keyed on "did we accept a completion", not on the trigger surviving the wire — the + // trigger-stripped replay is exactly the shape that arrives without one, and it is still the + // compact's own promptless event, so it still needs the summarized turn's label. + if ( + source === 'claude' && + (compactTrigger !== undefined || acceptedCompactCompletion) && + normalizedPayload.prompt.length === 0 && + previousStatus?.payload.prompt + ) { + normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } + } + if ( + envelope.providerSessionOnly === true && + !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) + ) { + return + } + const applyClaudeBackgroundWork = + normalizedPayload.agentType === 'claude' && + typeof envelope.claudeRunningNonAgentTask === 'boolean' && + // Why: reconnect replay may seed a restarted listener, but cannot override any observation made by this runtime. + (envelope.isReplay !== true || !this.runtimeObservedStatusPaneKeys.has(paneKey)) + // Why: run the HTTP path's warn-once version/env-mismatch diagnostics with this.env as expected. + warnOnHookEnvOrVersionMismatch(this.state, { + version: envelope.version, + env: envelope.env, + expectedEnv: this.env + }) + const event = { + paneKey, + source, + launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, + tabId, + worktreeId, + connectionId: trimmedConnectionId, + hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, + promptInteractionKey, + hookEventName, + providerPromptId, + compactTrigger, + toolUseId, + toolAgentId, + teammateName, + toolAgentType, + providerSession, + providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined, + isReplay: envelope.isReplay === true ? true : undefined, + claudeRunningNonAgentTask: + typeof envelope.claudeRunningNonAgentTask === 'boolean' + ? envelope.claudeRunningNonAgentTask + : undefined, + payload: normalizedPayload + } as AgentHookEventPayload + this.recordCurrentAuthorityObservation(event) + this.applyNormalizedStatus( + event, + applyClaudeBackgroundWork + ? () => { + if (envelope.claudeRunningNonAgentTask) { + this.state.claudeRunningNonAgentTaskPaneKeys.add(paneKey) + } else { + this.state.claudeRunningNonAgentTaskPaneKeys.delete(paneKey) + } + } + : undefined + ) + } +} diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts new file mode 100644 index 00000000000..822c7e76f02 --- /dev/null +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -0,0 +1,104 @@ +import { track } from '../../telemetry/client' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { terminalStatusPayloadMatchesHook } from '../../../shared/agent-terminal-status-equivalence' +import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AgentHookServerIngestNormalization } from './server-ingest-normalization' + +export abstract class AgentHookServerIngestTerminal extends AgentHookServerIngestNormalization { + ingestTerminalStatus(event: { + paneKey: string + tabId?: string + worktreeId?: string + connectionId?: string | null + payload: ParsedAgentStatusPayload + }): void { + const physicalPaneKey = event.paneKey.trim() + const paneKey = this.resolvePaneKeyAlias(physicalPaneKey) + const parsedPaneKey = parsePaneKey(paneKey) + if (paneKey.length === 0) { + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) + return + } + if (paneKey.length > MAX_PANE_KEY_LEN || !parsedPaneKey) { + return + } + const reportedTabId = + event.tabId !== undefined && event.tabId.trim().length > 0 ? event.tabId.trim() : undefined + if ( + paneKey === physicalPaneKey && + reportedTabId !== undefined && + reportedTabId !== parsedPaneKey.tabId + ) { + return + } + const tabId = paneKey !== physicalPaneKey ? parsedPaneKey.tabId : reportedTabId + if (this.getAgentStatusDisposition(paneKey) !== 'accept') { + return + } + const worktreeId = + event.worktreeId !== undefined && event.worktreeId.trim().length > 0 + ? event.worktreeId.trim() + : undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const previous = this.state.lastStatusByPaneKey.get(paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + previous?.claudeLeadBoundaryChildOnly === true && + previous.payload.agentType === 'claude' && + event.payload.agentType === 'claude' + ) { + // Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives. + return + } + // Why: preserve the hook-completed turn stamp while OSC repaints the current state. + const preserveActiveTurnStamp = + previous?.payload.turnCompletedAt !== undefined && + previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey) + if ( + !previous?.restoredUnconfirmed && + previous?.connectionId === connectionId && + previous.tabId === tabId && + previous.worktreeId === worktreeId && + terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) + ) { + return + } + // Why: the OSC 9999 wire payload has no providerSession field at all, so an OSC observation is + // never evidence that the session ended — yet overwriting the row dropped the cached identity. + // That erased it from persisted rows (lost across restart) and from headless `orca serve`, which + // serves these rows to mobile directly instead of the renderer store, blanking Chat UI (#10630). + // A new turn after `done` still starts clean so a reused pane cannot inherit a finished session. + // Why: mirror resolveAgentStatusIdentity, which treats a literal 'unknown' exactly like an + // omitted type — an OSC ping that names no agent makes no claim about the pane's identity, so + // it must not be read as a mismatch and strip the session the renderer would have kept. + const claimedAgentType = + event.payload.agentType && event.payload.agentType !== 'unknown' + ? event.payload.agentType + : undefined + const preservedProviderSession = + previous?.providerSession && + (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && + (previous.payload.state !== 'done' || event.payload.state === 'done') + ? previous.providerSession + : undefined + // Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks. + this.applyNormalizedStatus( + { + paneKey, + tabId, + worktreeId, + connectionId, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: event.payload + }, + undefined, + 'osc' + ) + } +} diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts new file mode 100644 index 00000000000..9beb0ad0bbb --- /dev/null +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -0,0 +1,197 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomUUID } from 'node:crypto' + +import { + CLAUDE_STATUSLINE_PATHNAME, + parseClaudeStatusLineBody +} from '../../../shared/claude-statusline-rate-limits' +import { mergeAgentHookRequestHeaders } from '../../../shared/agent-hook-listener/hook-envelope' +import { readRequestBody } from '../../../shared/agent-hook-listener/request-body' +import { resolveHookSource } from '../../../shared/agent-hook-listener/source-routing' +import { HOOK_REQUEST_SLOWLORIS_MS } from '../../../shared/agent-hook-listener/listener-limits' +import { isHookRequestTruncatedError } from '../../../shared/agent-hook-transport-interference' +import { drainAgentHookSpool, type SpoolRecord } from '../../../shared/agent-hook-spool' +import { clearAllListenerCaches } from '../../../shared/agent-hook-listener/listener-state' +import { trackEmptyPaneKeyHook } from './server-transport-rules' +import { AgentHookServerRuntimeEnv } from './server-runtime-env' + +export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv { + /** Start the loopback listener after hydration and spool replay have settled. */ + async start(options?: { + env?: string + userDataPath?: string + endpointNamespace?: string + }): Promise { + if (this.server) { + return + } + + if (options?.env) { + this.env = options.env + } + if (options?.userDataPath) { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.configureEndpointPaths(options.userDataPath, options.endpointNamespace) + } + this.token = randomUUID() + this.endpointFileWritten = false + this.lastWrittenJson = null + // Why: hydrate before binding the listener so an early hook POST runs against a populated map. + if (this.lastStatusFilePath) { + this.hydrateLastStatusFromDisk() + } + this.captureHydratedAuthorityCommitments() + // Drain before binding the listener so replay cannot race a live hook during startup. + if (this.endpointDir) { + drainAgentHookSpool({ + endpointDir: this.endpointDir, + getPersistedLaunchTokenHash: (paneKey) => + this.hydratedLaunchTokenHashByPaneKey.get(this.resolvePaneKeyAlias(paneKey)), + ingest: (record: SpoolRecord) => this.ingestSpoolRecord(record) + }) + } + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + if (req.method !== 'POST') { + res.writeHead(404) + res.end() + return + } + // Why: authenticate before spending work reading an untrusted body. + if (req.headers['x-orca-agent-hook-token'] !== this.token) { + res.writeHead(403) + res.end() + return + } + // Why: bound request time so a stalled client can't hold a socket open (slowloris). + // Why: track our own destroy so the slowloris cap can't be misread as outside interference. + let destroyedBySlowlorisCap = false + req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => { + destroyedBySlowlorisCap = true + req.destroy() + }) + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname + try { + const body = await readRequestBody(req) + if (pathname === CLAUDE_STATUSLINE_PATHNAME) { + const statusLineEvent = parseClaudeStatusLineBody(body) + if (statusLineEvent) { + this.onClaudeStatusLine?.(statusLineEvent) + } + res.writeHead(204) + res.end() + return + } + const source = resolveHookSource(pathname) + if (!source) { + res.writeHead(404) + res.end() + return + } + // Why: merge transport headers before normalization so relay-compatible fields have one canonical path. + const hookBody = mergeAgentHookRequestHeaders(body, req.headers) + trackEmptyPaneKeyHook(hookBody) + const aliasedBody = this.normalizeHookBodyPaneKeyAlias(hookBody) + const normalized = this.normalizeLocalHookPayload(source, aliasedBody) + const statusDisposition = normalized.event + ? this.getAgentStatusDisposition(normalized.event.paneKey, { + source, + hookEventName: normalized.event.hookEventName, + isReplay: normalized.event.isReplay, + hasExplicitPrompt: normalized.event.hasExplicitPrompt, + launchToken: normalized.event.launchToken + }) + : 'suppress' + if (normalized.event && statusDisposition !== 'suppress') { + const event = + statusDisposition === 'restart' + ? { ...normalized.event, launchToken: undefined } + : normalized.event + if (statusDisposition === 'restart') { + // Why: a retired pane accepting a new turn is a different agent session behind the + // same key — later observations must not be ordered against the retired one. + this.observations.rebind(event.paneKey) + } + this.recordCurrentAuthorityObservation(event) + const enriched = this.applyNormalizedStatus(event, normalized.onAccepted) + this.scheduleAssistantMessageRetry(source, aliasedBody, enriched) + this.scheduleCodexSubagentPoll(source, aliasedBody, enriched) + } + res.writeHead(204) + res.end() + } catch (error) { + // Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut + // by something on the loopback path, not by a bad payload. Fail open as before, but count it — + // this is the one failure mode that silently stops status for every runtime at once. + if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) { + this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error }) + } + // Why: fail open — return success on malformed payloads so a broken hook never blocks the agent. + res.writeHead(204) + res.end() + } + } + // Why: node ignores a returned promise, so the handler must settle it itself; handleRequest never rejects. + this.server = createServer((req, res) => { + void handleRequest(req, res) + }) + await new Promise((resolve, reject) => { + const onStartupError = (err: Error): void => { + // Why: swap the startup reject-handler for a logging one so a later runtime 'error' can't crash main as an unhandled event. + this.server?.off('listening', onListening) + reject(err) + } + const onListening = (): void => { + this.server?.off('error', onStartupError) + this.server?.on('error', (err) => { + console.error('[agent-hooks] server error', err) + }) + const address = this.server!.address() + if (address && typeof address === 'object') { + this.port = address.port + } + this.maybeWriteEndpointFile() + resolve() + } + this.server!.once('error', onStartupError) + this.server!.listen(0, '127.0.0.1', onListening) + }) + } + + stop(): void { + // Why: flush the pending debounced write before clearing the map, else a hook <250ms before quit is lost on relaunch. + this.flushStatusPersistSync() + this.server?.close() + this.server = null + this.port = 0 + this.token = '' + this.env = 'production' + this.onAgentStatus = null + this.onPaneStatusCleared = null + for (const timer of this.assistantMessageRetryTimers.values()) { + clearTimeout(timer) + } + this.assistantMessageRetryTimers.clear() + this.clearAllCodexSubagentPolls() + this.endpointDir = null + this.endpointFilePathCache = null + this.endpointFileWritten = false + this.lastStatusFilePath = null + this.lastWrittenJson = null + this.runtimeObservedStatusPaneKeys.clear() + this.hydratedAuthorityCommitments = Object.freeze([]) + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + this.revokedHydratedAuthorityCommitments = new WeakSet() + this.currentAuthorityObservations.clear() + this.promptSentDedupeByPaneKey.clear() + this.closedAgentStatusTabIds.clear() + this.closedAgentStatusPaneKeys.clear() + this.restartedStatusLaunchTokenHashByPaneKey.clear() + this.retiredPaneFencesByKey.clear() + this.connectionTimestampWatermarkById.clear() + this.legacyPaneKeyAliases.clear() + // Why: don't unlink the endpoint file — a stale file matches fail-open and avoids a TOCTOU race with a concurrent Orca. + clearAllListenerCaches(this.state) + this.notifyStatusChangeListeners() + } +} diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts new file mode 100644 index 00000000000..08d2ef21a70 --- /dev/null +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -0,0 +1,218 @@ +import type { + AgentStatusClearIpcPayload, + AgentStatusIpcPayload +} from '../../../shared/agent-status-types' +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-transport-interference' +import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + EnrichedAgentHookEventPayload, + StatusDropListener +} from './server-types' +import { toAgentStatusIpcPayload } from './server-status-identity' +import { AgentHookServerState } from './server-state' + +export abstract class AgentHookServerListeners extends AgentHookServerState { + /** + * Notified once per process when repeated hook POSTs are cut off mid-body (#11217). + * Why: the listener fails open on every request error, so without this the only symptom is + * agent status quietly going stale — for every runtime at once, since they share this transport. + */ + setTransportInterferenceListener( + listener: ((report: HookTransportInterferenceReport) => void) | null + ): void { + this.onTransportInterference = listener + } + + setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void { + this.onAgentStatus = listener + if (!listener) { + return + } + // Why: replay is best-effort per pane so one throwing listener can't starve the rest. + for (const payload of this.state.lastStatusByPaneKey.values()) { + try { + // Why: cache always holds enriched payloads; the map's declared type is the bare shape only because the shared module never reads it. + listener({ ...(payload as EnrichedAgentHookEventPayload), isReplay: true }) + } catch (err) { + console.error('[agent-hooks] replay listener threw', err) + } + } + } + + // Why: statusline posts carry live Claude usage windows, not agent status; they feed RateLimitService directly. + setClaudeStatusLineListener( + listener: ((event: ClaudeStatusLineRateLimits) => void) | null + ): void { + this.onClaudeStatusLine = listener + } + + subscribeStatusChanges(listener: (statuses: AgentHookStatusChangeEntry[]) => void): () => void { + this.statusChangeListeners.add(listener) + return () => { + this.statusChangeListeners.delete(listener) + } + } + + subscribeProviderSessionChanges( + listener: (providerSessions: AgentHookProviderSessionIdentity[]) => void + ): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + + /** Multi-subscriber tap on definitive live-row deletions. `dropStatusEntry` is a user + * dismissal, so it never routes through the pane-status-clear fan-out — pane-owned + * cleanup (synthetic spinners) still has to retire with the row it was driving. */ + subscribeStatusDrop(listener: StatusDropListener): () => void { + this.statusDropListeners.add(listener) + return () => { + this.statusDropListeners.delete(listener) + } + } + + protected emitStatusDropped(paneKey: string): void { + for (const listener of this.statusDropListeners) { + // Why: matches every other fan-out here — one throwing subscriber must not strand the rest. + try { + listener(paneKey) + } catch (err) { + console.error('[agent-hooks] status-drop listener threw', err) + } + } + } + + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + + /** Replay is durable evidence from a prior runtime, not a live observation. */ + protected withdrawReplayObservation(paneKey: string): void { + if (this.runtimeObservedStatusPaneKeys.delete(paneKey)) { + this.notifyStatusChangeListeners() + } + } + + setPaneStatusClearListener(listener: ((clear: AgentStatusClearIpcPayload) => void) | null): void { + this.onPaneStatusCleared = listener + } + + /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` + * (a single slot the main window owns and drops on close) this survives window + * teardown and exists at all under headless serve, which never opens one. */ + subscribePaneStatusClear(listener: (clear: AgentStatusClearIpcPayload) => void): () => void { + this.paneStatusClearListeners.add(listener) + return () => { + this.paneStatusClearListeners.delete(listener) + } + } + + protected emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { + this.onPaneStatusCleared?.(clear) + for (const listener of this.paneStatusClearListeners) { + // Why: callers are pane/connection teardown paths; one throwing subscriber must + // not strand the rest, matching every other fan-out here. + try { + listener(clear) + } catch (err) { + console.error('[agent-hooks] pane-status-clear listener threw', err) + } + } + } + + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the + * dashboard catches up on hook events that fired during startup. */ + getStatusSnapshot(): AgentStatusIpcPayload[] { + return Array.from(this.state.lastStatusByPaneKey.values(), (entry) => + toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload) + ) + } + + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + + getHydratedAuthorityCommitments(): readonly AgentHookAuthorityEvidence[] { + return this.hydratedAuthorityCommitments + } + + getCurrentAuthorityObservations(): readonly AgentHookAuthorityEvidence[] { + return Object.freeze( + Array.from(this.currentAuthorityObservations.values(), (entry) => Object.freeze({ ...entry })) + ) + } + + protected buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } + } + + protected notifyStatusChangeListeners(): void { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { + return + } + const { statuses, providerSessions } = this.buildStatusChangeNotification() + for (const listener of this.statusChangeListeners) { + try { + listener(statuses) + } catch (err) { + console.error('[agent-hooks] status-change listener threw', err) + } + } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } + } + + getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { + return this.buildStatusChangeNotification().statuses + } + + /** Test-only accessor for the per-instance listener state (narrow getter avoids an `as unknown` cast). */ + _getStateForTests(): HookListenerState { + return this.state + } +} diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts new file mode 100644 index 00000000000..061646731ee --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto' + +import { normalizeAgentProviderSession } from '../../../shared/agent-session-resume' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../../shared/agent-status-types' +import { isAgentHookSource } from '../../../shared/agent-hook-relay' +import { normalizeClaudePromptId } from '../../../shared/agent-hook-listener/listener-limits' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookAuthorityEvidence, EnrichedAgentHookEventPayload } from './server-types' +import { isValidPaneKey, isValidPiProviderSessionOnly } from './server-status-identity' + +export function dropHydratedIdleClaudeSubagents( + payload: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + if ( + payload.agentType !== 'claude' || + !payload.subagents?.some((subagent) => subagent.state === 'idle') + ) { + return payload + } + const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') + // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. + return { + ...payload, + subagents: activeSubagents.length > 0 ? activeSubagents : undefined + } +} + +export function sanitizeHydratedEntry( + paneKey: string, + rawEntry: unknown +): EnrichedAgentHookEventPayload | null { + const parsedPaneKey = parsePaneKey(paneKey) + if (!parsedPaneKey) { + return null + } + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + if (record.paneKey !== paneKey) { + return null + } + const tabId = record.tabId + if (tabId !== undefined && (typeof tabId !== 'string' || tabId.length === 0)) { + return null + } + // Why: a stored tabId that diverges from the paneKey's tab segment is corruption; drop instead of hydrating an inconsistent row. + if (typeof tabId === 'string' && tabId !== parsedPaneKey.tabId) { + return null + } + const worktreeId = record.worktreeId + if (worktreeId !== undefined && (typeof worktreeId !== 'string' || worktreeId.length === 0)) { + return null + } + const receivedAt = record.receivedAt + if (typeof receivedAt !== 'number' || !Number.isFinite(receivedAt) || receivedAt <= 0) { + return null + } + const stateStartedAt = record.stateStartedAt + if ( + typeof stateStartedAt !== 'number' || + !Number.isFinite(stateStartedAt) || + stateStartedAt <= 0 + ) { + return null + } + // Why: connectionId is null (local) or string (relay); any other shape is rejected to keep the typed surface honest. + const connectionIdRaw = record.connectionId + let connectionId: string | null + if (connectionIdRaw === null || connectionIdRaw === undefined) { + connectionId = null + } else if (typeof connectionIdRaw === 'string') { + connectionId = connectionIdRaw + } else { + return null + } + const payload = normalizeAgentStatusPayload(record.payload) + if (!payload) { + return null + } + const providerSession = normalizeAgentProviderSession(record.providerSession) ?? undefined + const providerSessionOnly = record.providerSessionOnly === true + const retainedForLiveness = record.retainedForLiveness === true + const validRetainedIdentity = Boolean( + retainedForLiveness && providerSession && payload.agentType && payload.agentType !== 'unknown' + ) + if ( + providerSessionOnly && + !isValidPiProviderSessionOnly(providerSession, payload.agentType) && + !validRetainedIdentity + ) { + return null + } + const source = isAgentHookSource(record.source) ? record.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') + ? record.compactTrigger + : undefined + return { + paneKey, + source, + tabId: typeof tabId === 'string' ? tabId : undefined, + worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, + connectionId, + hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, + hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, + providerPromptId, + compactTrigger, + toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, + toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, + teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined, + toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined, + providerSession, + providerSessionOnly: providerSessionOnly ? true : undefined, + retainedForLiveness: retainedForLiveness ? true : undefined, + payload, + receivedAt, + stateStartedAt + } +} + +export function readPersistedLaunchTokenHash(rawEntry: unknown): string | null { + if (typeof rawEntry !== 'object' || rawEntry === null) { + return null + } + const record = rawEntry as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + if (/^[a-f0-9]{64}$/.test(launchTokenHash)) { + return launchTokenHash + } + const legacyLaunchToken = typeof record.launchToken === 'string' ? record.launchToken.trim() : '' + return legacyLaunchToken ? createHash('sha256').update(legacyLaunchToken).digest('hex') : null +} + +export function sanitizePersistedAuthorityCommitment( + paneKey: string, + value: unknown +): AgentHookAuthorityEvidence | null { + if (!isValidPaneKey(paneKey) || typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + const launchTokenHash = + typeof record.launchTokenHash === 'string' ? record.launchTokenHash.trim() : '' + const connectionId = record.connectionId + const observedAt = record.observedAt + if ( + !/^[a-f0-9]{64}$/.test(launchTokenHash) || + (connectionId !== null && typeof connectionId !== 'string') || + typeof observedAt !== 'number' || + !Number.isFinite(observedAt) + ) { + return null + } + return Object.freeze({ + paneKey, + launchTokenHash, + connectionId: connectionId as string | null, + ...(typeof record.tabId === 'string' ? { tabId: record.tabId } : {}), + ...(typeof record.worktreeId === 'string' ? { worktreeId: record.worktreeId } : {}), + observedAt + }) +} + +export function authorityCommitmentsMatch( + left: AgentHookAuthorityEvidence, + right: AgentHookAuthorityEvidence +): boolean { + return ( + left.paneKey === right.paneKey && + left.launchTokenHash === right.launchTokenHash && + left.connectionId === right.connectionId && + left.tabId === right.tabId && + left.worktreeId === right.worktreeId + ) +} diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts new file mode 100644 index 00000000000..ecb33c44d37 --- /dev/null +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -0,0 +1,141 @@ +import { chmodSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +import { isValidPaneKey } from './server-status-identity' +import { LAST_STATUS_FILE_VERSION, STATUS_PERSIST_DEBOUNCE_MS } from './server-constants' +import type { + EnrichedAgentHookEventPayload, + LastStatusFile, + PersistedAgentHookAuthorityCommitment, + PersistedAgentHookEventPayload +} from './server-types' +import { authorityCommitmentsMatch } from './server-persistence-validation' +import { AgentHookServerHydration } from './server-hydration' + +export abstract class AgentHookServerPersistence extends AgentHookServerHydration { + protected serializeStatusFile(): string { + const entries: Record = {} + const authorityCommitments: Record = {} + const conflictedCommitments = new Set() + for (const [paneKey, commitment] of this.persistedAuthorityCommitmentsByPaneKey) { + authorityCommitments[paneKey] = { ...commitment } + } + for (const [paneKey, payload] of this.state.lastStatusByPaneKey) { + // Why: never persist invalid keys (matches the hydrate-path invariant). + if (!isValidPaneKey(paneKey)) { + continue + } + const enrichedPayload = payload as EnrichedAgentHookEventPayload + const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true + const { + claudeRunningNonAgentTask: _claudeRunningNonAgentTask, + promptInteractionKey: _promptInteractionKey, + // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. + restoredUnconfirmed: _restoredUnconfirmed, + // Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload). + observation: _observation, + // Replay provenance is runtime-only and must not survive another restart. + isReplay: _isReplay, + launchToken, + ...persistedPayload + } = enrichedPayload + const launchTokenHash = launchToken?.trim() + ? createHash('sha256').update(launchToken.trim()).digest('hex') + : this.hydratedLaunchTokenHashByPaneKey.get(paneKey) + entries[paneKey] = { + ...persistedPayload, + ...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}), + ...(launchTokenHash ? { launchTokenHash } : {}) + } + const commitment = this.toAuthorityEvidence(payload, launchTokenHash) + if (commitment && !conflictedCommitments.has(paneKey)) { + const existing = authorityCommitments[paneKey] + if (existing && !authorityCommitmentsMatch(existing, commitment)) { + delete authorityCommitments[paneKey] + conflictedCommitments.add(paneKey) + } else { + authorityCommitments[paneKey] = { ...commitment } + } + } + } + const file: LastStatusFile = { + version: LAST_STATUS_FILE_VERSION, + entries, + authorityCommitments + } + return JSON.stringify(file) + } + + protected scheduleStatusPersist(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: reset the timer each call so the write fires only after the last event in a burst. + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + } + this.statusPersistTimer = setTimeout(() => { + this.statusPersistTimer = null + this.runStatusPersist() + }, STATUS_PERSIST_DEBOUNCE_MS) + // Why: don't keep the event loop alive just for a status flush — quit already flushes sync. + if (typeof this.statusPersistTimer.unref === 'function') { + this.statusPersistTimer.unref() + } + } + + flushStatusPersistSync(): void { + if (this.statusPersistTimer) { + clearTimeout(this.statusPersistTimer) + this.statusPersistTimer = null + } + if (!this.lastStatusFilePath) { + return + } + this.runStatusPersist() + } + + protected runStatusPersist(): void { + if (!this.lastStatusFilePath || !this.endpointDir) { + return + } + const json = this.serializeStatusFile() + if (json === this.lastWrittenJson) { + return + } + const tmpPath = join(this.endpointDir, `.last-status-${process.pid}-${randomUUID()}.tmp`) + let tmpWritten = false + try { + mkdirSync(this.endpointDir, { recursive: true, mode: 0o700 }) + if (process.platform !== 'win32') { + try { + chmodSync(this.endpointDir, 0o700) + } catch { + // best-effort + } + } + writeFileSync(tmpPath, json, { mode: 0o600 }) + tmpWritten = true + renameSync(tmpPath, this.lastStatusFilePath) + this.lastWrittenJson = json + } catch (err) { + console.warn('[agent-hooks] failed to write last-status file:', err) + if (tmpWritten) { + try { + unlinkSync(tmpPath) + } catch { + // tmp already gone + } + } + } + } + + _resetPromptSentDedupeForTests(): void { + this.promptSentDedupeByPaneKey.clear() + } + + _resetConnectionTimestampWatermarksForTests(): void { + this.connectionTimestampWatermarkById.clear() + } +} diff --git a/src/main/agent-hooks/server/server-reaping.ts b/src/main/agent-hooks/server/server-reaping.ts new file mode 100644 index 00000000000..7805303ced1 --- /dev/null +++ b/src/main/agent-hooks/server/server-reaping.ts @@ -0,0 +1,124 @@ +import { + claudeRosterHasRestoredSnapshotSubagent, + claudeRosterHasWorkingSubagent, + claudeRosterToSnapshots +} from '../../../shared/claude-subagent-roster' +import { reapRestoredClaudeSubagentsForDeadPane } from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { AgentHookServerTabCleanup } from './server-tab-cleanup' +import type { EnrichedAgentHookEventPayload } from './server-types' + +export abstract class AgentHookServerReaping extends AgentHookServerTabCleanup { + /** Second reap path for restored Claude subagent rows: drop the ones whose pane + * has no live local agent process behind it any more. A PTY that dies while Orca + * is down never runs the teardown that clears pane state, so hydrate rebuilds a + * roster nothing can ever retire — the inventory reap needs the parent to emit a + * complete `background_tasks` list and an idle parent never does. The row then + * gates the pane 'working' for the rest of its life and hibernation, which + * requires 'done', can never reclaim the agent's heap. + * + * Both the execution host and relay binding must prove local ownership before + * targeted PTY liveness is consulted. Panes that reported in this runtime are + * also skipped. Returns the number of panes changed. */ + async reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalExecutionHost: (worktreeId: string | undefined) => boolean, + isLocalPaneAgentLive: (paneKey: string) => Promise, + isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean + ): Promise { + const candidates: { paneKey: string; entry: EnrichedAgentHookEventPayload }[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { + const enriched = entry as EnrichedAgentHookEventPayload + if ( + enriched.payload.agentType === 'claude' && + enriched.connectionId === null && + isLocalExecutionHost(enriched.worktreeId) && + // Why: a restored roster is only one shape of stranded claim. A lead row left non-terminal, + // or a background-task/cron latch nothing will refresh, strands the pane just as + // permanently — and unlike the roster case there is no child event left to reap it. + (claudeRosterHasRestoredSnapshotSubagent( + this.state.claudeSubagentRosterByPaneKey.get(paneKey) + ) || + enriched.payload.state !== 'done' || + this.state.claudeRunningNonAgentTaskPaneKeys.has(paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(paneKey)) && + !this.runtimeObservedStatusPaneKeys.has(paneKey) + ) { + candidates.push({ paneKey, entry: enriched }) + } + } + const liveness = await Promise.all( + candidates.map(async (candidate) => { + try { + return await isLocalPaneAgentLive(candidate.paneKey) + } catch { + return true + } + }) + ) + let changedPanes = 0 + for (const [index, candidate] of candidates.entries()) { + const { paneKey, entry: enriched } = candidate + if ( + liveness[index] || + !isLocalPaneLivenessEvidenceCurrent(paneKey) || + this.state.lastStatusByPaneKey.get(paneKey) !== enriched || + this.runtimeObservedStatusPaneKeys.has(paneKey) || + !isLocalExecutionHost(enriched.worktreeId) + ) { + continue + } + if (!reapRestoredClaudeSubagentsForDeadPane(this.state, paneKey)) { + // Why: the roster reap only speaks for restored child rows. A pane whose PTY is provably + // gone and whose claim is a lead row or a latch has nothing for it to reap, so retire the + // pane the same way an observed exit would — otherwise the widened candidate set is inert. + // + // Why delete rather than downgrade to `done` like the reap branch below: that branch has a + // real turn to describe — a parent whose children it just reaped — while these panes' only + // claim IS the stale non-terminal row. Rewriting a `waiting`/`blocked` row to `done` would + // invent a completion that never happened, and leaving it non-terminal keeps the bug. This + // sweep stands in for the exit Orca never observed, so it does what that exit does: + // `clearProviderPtyState` -> `clearPaneState`. + if (this.hasLiveClaimsForPaneKey(paneKey)) { + this.clearPaneState(paneKey) + changedPanes += 1 + } + continue + } + changedPanes += 1 + const roster = this.state.claudeSubagentRosterByPaneKey.get(paneKey) + const subagents = claudeRosterToSnapshots(roster) + // Why: the pane's persisted 'working' was the child gate holding a finished + // lead open (subagent events never set lead state). With the last working row + // gone and no process left to report, 'done' is the only truthful state — and + // the one hibernation needs once this pane's agent is restored. + const state = + enriched.payload.state === 'working' && !claudeRosterHasWorkingSubagent(roster) + ? 'done' + : enriched.payload.state + const stateChanged = state !== enriched.payload.state + const reconciledAt = stateChanged + ? Math.max(Date.now(), enriched.receivedAt + 1) + : enriched.receivedAt + // Why: a reconciled `done` is process-probe-verified, not hydrated guesswork — carrying + // restoredUnconfirmed onto it would make freshness gates suppress a legitimate completion. + const { restoredUnconfirmed, ...reconciledBase } = enriched + const reconciled: EnrichedAgentHookEventPayload = { + ...reconciledBase, + ...(state !== 'done' && restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + receivedAt: reconciledAt, + stateStartedAt: stateChanged ? reconciledAt : enriched.stateStartedAt, + payload: { + ...enriched.payload, + state, + workingMode: state === 'working' ? enriched.payload.workingMode : undefined, + subagents + } + } + this.state.lastStatusByPaneKey.set(paneKey, reconciled) + } + if (changedPanes > 0) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + return changedPanes + } +} diff --git a/src/main/agent-hooks/server/server-runtime-env.ts b/src/main/agent-hooks/server/server-runtime-env.ts new file mode 100644 index 00000000000..e5b9e4c1363 --- /dev/null +++ b/src/main/agent-hooks/server/server-runtime-env.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' +import { + getEndpointFileName, + writeEndpointFile +} from '../../../shared/agent-hook-listener/endpoint-publication' +import { + ORCA_HOOK_PROTOCOL_VERSION, + ORCA_HOOK_RAW_JSON_TRANSPORT +} from '../../../shared/agent-hook-types' +import { AgentHookServerIngestRemote } from './server-ingest-remote' + +export abstract class AgentHookServerRuntimeEnv extends AgentHookServerIngestRemote { + buildPtyEnv(): Record { + if (this.port <= 0 || !this.token) { + return {} + } + const env: Record = { + ORCA_AGENT_HOOK_PORT: String(this.port), + ORCA_AGENT_HOOK_TOKEN: this.token, + ORCA_AGENT_HOOK_ENV: this.env, + ORCA_AGENT_HOOK_VERSION: ORCA_HOOK_PROTOCOL_VERSION, + ORCA_AGENT_HOOK_TRANSPORT: ORCA_HOOK_RAW_JSON_TRANSPORT + } + // Why: hooks source this file at invocation; dev namespaces it so parallel `pnpm dev` runs don't steal each other's hooks. + if (this.endpointFileWritten && this.endpointFilePathCache) { + env.ORCA_AGENT_HOOK_ENDPOINT = this.endpointFilePathCache + } + return env + } + + get endpointFilePath(): string | null { + return this.endpointFilePathCache + } + + /** Test/diagnostic accessor for the on-disk last-status file path. */ + get lastStatusPath(): string | null { + return this.lastStatusFilePath + } + + protected maybeWriteEndpointFile(): void { + if (!this.endpointDir || !this.endpointFilePathCache) { + return + } + this.endpointFileWritten = false + const ok = writeEndpointFile(this.endpointDir, this.endpointFilePathCache, { + port: this.port, + token: this.token, + env: this.env, + version: ORCA_HOOK_PROTOCOL_VERSION, + transport: ORCA_HOOK_RAW_JSON_TRANSPORT + }) + this.endpointFileWritten = ok + } + + protected configureEndpointPaths(userDataPath: string, endpointNamespace?: string): void { + // Why: dev builds share one userData path; namespace per instance while packaged keeps the stable path for PTY reconnect. + this.endpointDir = endpointNamespace + ? join(userDataPath, 'agent-hooks', endpointNamespace) + : join(userDataPath, 'agent-hooks') + this.endpointFilePathCache = join(this.endpointDir, getEndpointFileName()) + this.lastStatusFilePath = join(this.endpointDir, 'last-status.json') + } +} diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts new file mode 100644 index 00000000000..956be136ca6 --- /dev/null +++ b/src/main/agent-hooks/server/server-state.ts @@ -0,0 +1,217 @@ +import type { createServer } from 'node:http' +import { randomBytes } from 'node:crypto' + +import { + createHookListenerState, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' +import { + createHookTransportInterferenceTracker, + describeHookTransportInterference, + type HookTransportInterferenceReport +} from '../../../shared/agent-hook-transport-interference' +import { + AgentStatusObservationSequencer, + createAgentStatusAuthorityId, + type AgentStatusObservation, + type AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import type { AgentStatusClearIpcPayload } from '../../../shared/agent-status-types' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { SpoolRecord } from '../../../shared/agent-hook-spool' +import type { + AgentHookAuthorityEvidence, + AgentHookProviderSessionIdentity, + AgentHookStatusChangeEntry, + AgentPromptSentDedupeEntry, + EnrichedAgentHookEventPayload, + NormalizedLocalHook, + PaneKeyAliasEntry, + PaneKeyAliasPersistenceListener, + PaneStatusClearListener, + ProviderSessionChangeListener, + RetiredPaneAlias, + RetiredPaneFence, + ServerAgentStatusListener, + ServerStatusLineListener, + StatusChangeListener, + StatusDropListener +} from './server-types' + +/** Shared mutable state for the layered hook-server implementation. */ +export abstract class AgentHookServerState { + protected server: ReturnType | null = null + protected port = 0 + protected token = '' + // Why: identifies this Orca instance so the server can detect dev vs. prod cross-talk; set at start() from packaged-build knowledge. + protected env = 'production' + protected onAgentStatus: ServerAgentStatusListener = null + protected onClaudeStatusLine: ServerStatusLineListener = null + protected onPaneStatusCleared: PaneStatusClearListener | null = null + protected paneStatusClearListeners = new Set() + protected statusDropListeners = new Set() + protected statusChangeListeners = new Set() + protected providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + protected enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() + // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). + protected endpointDir: string | null = null + protected endpointFilePathCache: string | null = null + protected endpointFileWritten = false + // Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination. + protected state: HookListenerState = createHookListenerState() + protected onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = + null + protected transportInterference = createHookTransportInterferenceTracker( + (report: HookTransportInterferenceReport) => { + console.warn(describeHookTransportInterference(report)) + this.onTransportInterference?.(report) + } + ) + // Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime. + protected runtimeObservedStatusPaneKeys = new Set() + protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([]) + protected hydratedLaunchTokenHashByPaneKey = new Map() + protected persistedAuthorityCommitmentsByPaneKey = new Map() + protected revokedHydratedAuthorityCommitments = new WeakSet() + protected currentAuthorityObservations = new Map() + protected legacyPaneKeyAliases = new Map() + // Why: indexed by every key the retirement fenced, so a re-attach on any of them + // (owner, physical, or a deleted alias) finds the same record. Bounded like the maps + // it mirrors; an evicted record simply degrades to lifting the key it was handed. + protected retiredPaneFencesByKey = new Map() + protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null + // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. + protected lastStatusFilePath: string | null = null + // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. + protected statusPersistTimer: ReturnType | null = null + protected assistantMessageRetryTimers = new Map>() + protected promptSentDedupeByPaneKey = new Map() + protected activeHookTurnCompletedAtByPaneKey = new Map() + protected promptSentHashSalt = randomBytes(16).toString('hex') + protected closedAgentStatusTabIds = new Set() + protected closedAgentStatusPaneKeys = new Set() + protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected connectionTimestampWatermarkById = new Map() + // Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed. + protected lastWrittenJson: string | null = null + // Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own + // OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here. + protected readonly observations = new AgentStatusObservationSequencer( + createAgentStatusAuthorityId('main-agent-hooks') + ) + + protected abstract withdrawReplayObservation(paneKey: string): void + protected abstract ingestSpoolRecord(record: SpoolRecord): void + protected abstract emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void + protected abstract buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } + protected abstract notifyStatusChangeListeners(): void + protected abstract markTabClosedForAgentStatus(tabId: string): void + protected abstract getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' + protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean + protected abstract recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void + protected abstract markPaneClosedForAgentStatus(paneKey: string): void + protected abstract attachStatusTiming( + payload: AgentHookEventPayload, + now?: number + ): EnrichedAgentHookEventPayload + protected abstract hashPromptForTelemetryDedupe(prompt: string): string + protected abstract maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void + protected abstract stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation + protected abstract applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin?: AgentStatusObservationOrigin + ): EnrichedAgentHookEventPayload + protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void + protected abstract clearAssistantMessageRetry(paneKey: string): void + protected abstract clearCodexSubagentPoll(paneKey: string): void + protected abstract clearAllCodexSubagentPolls(): void + protected abstract scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void + protected abstract scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt?: number, + discoveryReady?: boolean + ): void + protected abstract applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void + protected abstract getPersistedPaneKeyAliases(): LegacyPaneKeyAliasEntry[] + protected abstract notifyPaneKeyAliasPersistenceListener(): void + protected abstract boundPaneKeyAliases(): void + protected abstract getPhysicalPaneKeyForAuthority(paneKey: string, ptyId?: string): string + protected abstract restoreRetiredPaneFence(fence: RetiredPaneFence): void + protected abstract revokeHydratedAuthorityForPaneKeys(paneKeys: ReadonlySet): boolean + protected abstract resolvePaneKeyAlias(paneKey: string): string + protected abstract normalizeHookBodyPaneKeyAlias(body: unknown): unknown + protected abstract normalizeLocalHookPayload( + source: AgentHookSource, + body: unknown + ): NormalizedLocalHook + protected abstract setClaudeBackgroundEvidence( + paneKey: string, + hasRunningTask: boolean, + hasActiveCron: boolean + ): void + protected abstract toRetainedProviderSessionRow( + entry: EnrichedAgentHookEventPayload | null | undefined + ): EnrichedAgentHookEventPayload | null + protected abstract hasLiveClaimsForPaneKey(paneKey: string): boolean + protected abstract clearPaneState(paneKey: string): void + protected abstract deleteStatusEntry( + paneKey: string, + options?: { preserveAuthority?: boolean } + ): EnrichedAgentHookEventPayload | null + protected abstract maybeWriteEndpointFile(): void + protected abstract hydrateLastStatusFromDisk(): void + protected abstract captureHydratedAuthorityCommitments(): void + protected abstract recordCurrentAuthorityObservation(payload: AgentHookEventPayload): void + protected abstract toAuthorityEvidence( + payload: AgentHookEventPayload | EnrichedAgentHookEventPayload, + launchTokenHashOverride?: string + ): AgentHookAuthorityEvidence | null + protected abstract serializeStatusFile(): string + protected abstract scheduleStatusPersist(): void + protected abstract runStatusPersist(): void + + abstract _getStateForTests(): HookListenerState + abstract _resetPromptSentDedupeForTests(): void + abstract _resetConnectionTimestampWatermarksForTests(): void +} diff --git a/src/main/agent-hooks/server/server-status-application.ts b/src/main/agent-hooks/server/server-status-application.ts new file mode 100644 index 00000000000..7fbb6a96bc1 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-application.ts @@ -0,0 +1,141 @@ +import { createHash } from 'node:crypto' + +import { getCohortAtEmit } from '../../telemetry/cohort-classifier' +import { track } from '../../telemetry/client' +import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary' +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusObservation, + AgentStatusObservationOrigin +} from '../../../shared/agent-status-observation' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { agentTypeToPromptSentAgentKind } from './server-status-identity' +import { AgentHookServerStatusDisposition } from './server-status-disposition' + +export abstract class AgentHookServerStatusApplication extends AgentHookServerStatusDisposition { + protected attachStatusTiming( + payload: AgentHookEventPayload, + now = Date.now() + ): EnrichedAgentHookEventPayload { + const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const commandCodeNewTurn = + previous !== undefined && + isCommandCodeNewTurnWhileWorking({ + agentType: payload.payload.agentType, + previousState: previous.payload.state, + incomingState: payload.payload.state, + previousPrompt: previous.payload.prompt, + incomingPrompt: payload.payload.prompt, + hasExplicitPrompt: payload.hasExplicitPrompt, + previousPromptInteractionKey: previous.promptInteractionKey, + incomingPromptInteractionKey: payload.promptInteractionKey + }) + const stateStartedAt = + previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn + ? previous.stateStartedAt + : now + // Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival. + return { + ...payload, + receivedAt: now, + stateStartedAt + } + } + + protected hashPromptForTelemetryDedupe(prompt: string): string { + return createHash('sha256') + .update(this.promptSentHashSalt) + .update('\0') + .update(prompt) + .digest('hex') + } + + protected maybeTrackAgentPromptSent( + payload: AgentHookEventPayload, + previousStatus: EnrichedAgentHookEventPayload | undefined + ): void { + if (payload.isReplay === true || payload.hasExplicitPrompt !== true) { + return + } + const prompt = payload.payload.prompt?.trim() ?? '' + if (prompt.length === 0) { + return + } + const agentKind = agentTypeToPromptSentAgentKind(payload.payload.agentType) + const promptHash = this.hashPromptForTelemetryDedupe(prompt) + const promptInteractionKey = + typeof payload.promptInteractionKey === 'string' && + payload.promptInteractionKey.trim().length > 0 + ? payload.promptInteractionKey.trim() + : undefined + const previousDedupe = this.promptSentDedupeByPaneKey.get(payload.paneKey) + const isCompletedTurnBoundary = + previousStatus?.payload.state === 'done' && payload.payload.state === 'working' + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey === promptInteractionKey && + (agentKind === 'opencode' || previousDedupe.promptHash === promptHash) + ) { + return + } + if ( + previousDedupe?.agentKind === agentKind && + previousDedupe.promptHash === promptHash && + !( + previousStatus?.payload.state === 'done' && + payload.payload.state === 'done' && + previousDedupe.promptInteractionKey !== undefined && + promptInteractionKey !== undefined && + previousDedupe.promptInteractionKey !== promptInteractionKey + ) && + !isCompletedTurnBoundary + ) { + return + } + this.promptSentDedupeByPaneKey.set(payload.paneKey, { + agentKind, + promptHash, + promptInteractionKey + }) + try { + // Why: hooks prove a turn was submitted but not which UI launched the terminal; keep attribution low-cardinality. + track('agent_prompt_sent', { + agent_kind: agentKind, + launch_source: 'unknown', + request_kind: 'followup', + ...getCohortAtEmit() + }) + } catch (err) { + console.error('[agent-hooks] prompt-sent telemetry failed', err) + } + } + + /** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet + * (STA-4293) — it is stamped here because every main-side ingress funnels through + * applyNormalizedStatus, so no origin can silently arrive untagged. */ + protected stampObservation( + payload: AgentHookEventPayload, + origin: AgentStatusObservationOrigin, + observedAt: number + ): AgentStatusObservation { + return this.observations.observe(payload.paneKey, { + origin, + observedAt, + // Why: reuse the listener's own per-provider classifier; a second list of raw event-name + // literals here would strand the providers whose boundary event is named anything else. + boundary: + payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName), + kind: payload.providerSessionOnly + ? 'identity-only' + : // Why: a replay restates a turn that already happened, and OSC 9999 repaints the + // current state rather than announcing a change — neither is a fresh transition. + payload.isReplay === true || origin === 'osc' + ? 'snapshot' + : 'transition' + }) + } +} diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts new file mode 100644 index 00000000000..b6c69967280 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -0,0 +1,161 @@ +import { createHash } from 'node:crypto' + +import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { + CLOSED_AGENT_STATUS_PANE_KEYS_MAX, + CLOSED_AGENT_STATUS_TAB_IDS_MAX, + RETIRED_PANE_FENCES_MAX +} from './server-constants' +import type { RetiredPaneAlias, RetiredPaneFence } from './server-types' +import { AgentHookServerStatusInference } from './server-status-inference' + +export abstract class AgentHookServerStatusDisposition extends AgentHookServerStatusInference { + protected markTabClosedForAgentStatus(tabId: string): void { + // Delete-then-add keeps recently closed tabs most-recent so eviction sheds only the oldest ids. + this.closedAgentStatusTabIds.delete(tabId) + this.closedAgentStatusTabIds.add(tabId) + while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) { + const oldest = this.closedAgentStatusTabIds.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusTabIds.delete(oldest) + } + } + + protected getAgentStatusDisposition( + paneKey: string, + event?: { + source?: AgentHookSource + /** Raw wire value, so the gate can tell "field absent" from "field present but unknown". */ + rawSource?: unknown + hookEventName?: string + isReplay?: boolean + hasExplicitPrompt?: boolean + launchToken?: string + } + ): 'accept' | 'restart' | 'suppress' { + const ownerPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneRetired = + this.closedAgentStatusPaneKeys.has(paneKey) || + this.closedAgentStatusPaneKeys.has(ownerPaneKey) + const tabId = parsePaneKey(ownerPaneKey)?.tabId + if (tabId && this.closedAgentStatusTabIds.has(tabId)) { + return 'suppress' + } + if (!paneRetired) { + const tokenFence = this.restartedStatusLaunchTokenHashByPaneKey.get(ownerPaneKey) + // Why: deferred retirement lets a new process start in a still-authorized pane, so + // its tokened SessionStart re-fences; prompts recur, so a stale process would win. + if ( + event?.hookEventName === 'SessionStart' && + event.isReplay !== true && + tokenFence !== undefined + ) { + const startedLaunchToken = event.launchToken?.trim() + if (startedLaunchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(startedLaunchToken).digest('hex') + ) + return 'accept' + } + } + if (event && tokenFence) { + const launchToken = event.launchToken?.trim() + if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + return 'suppress' + } + } + return 'accept' + } + // Why: command completion retires launch authority but leaves its shell pane reusable. + // A live new-turn event proves a new agent process owns the retired pane just like a + // fresh prompt does — without it, a session resumed in a reused pane stays rowless (STA-3386). + // Why the classifier, not literals: only 5 of 18 sources name their boundary + // `UserPromptSubmit`/`SessionStart`; the rest stayed retired forever. + // Why four branches: `source` collapses to undefined when an older relay omits the field, + // when a newer host sends an unknown string, and when the wire value is malformed. Only an + // unknown string is valid future-provider evidence. Unreachable from the local path, which + // 404s an unresolvable source. + const isNewTurn = + event?.source !== undefined + ? isNewTurnEvent(event.source, event.hookEventName) + : typeof event?.rawSource === 'string' && event.rawSource.trim().length > 0 + ? // Why fail OPEN for an unknown provider: its boundary event is unknowable here, and + // the costs are asymmetric — a stranded pane is invisible and permanent with no user + // recovery, while a spurious revive decays after AGENT_STATUS_STALE_AFTER_MS. + true + : event?.rawSource === undefined + ? // Why literals here: an older relay omits `source` entirely. Legacy shim only — it + // cannot revive a provider whose boundary event is named anything else. + event?.hookEventName === 'UserPromptSubmit' || event?.hookEventName === 'SessionStart' + : false + // Why in addition to the classifier: the OpenCode family carries its mid-session boundary in + // an explicit-prompt MessagePart, which isNewTurnEvent cannot name — and mimo-code has no + // SessionStart at all, so without this its retired panes never come back. + const freshOpenCodeFamilyPrompt = + (event?.source === 'opencode' || event?.source === 'mimo-code') && + event.hookEventName === 'MessagePart' && + event.hasExplicitPrompt === true + // Why the token is minted here: a revive proves a live lifecycle, and fencing follow-up + // status on that launch token stops a stale process reclaiming the pane's row without + // restoring retired orchestration authority. + if ((isNewTurn || freshOpenCodeFamilyPrompt) && event?.isReplay !== true) { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.delete(ownerPaneKey) + const launchToken = event?.launchToken?.trim() + if (launchToken) { + this.restartedStatusLaunchTokenHashByPaneKey.set( + ownerPaneKey, + createHash('sha256').update(launchToken).digest('hex') + ) + } else { + this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) + } + return 'restart' + } + return 'suppress' + } + + // Why: a fence can span tabs (a pane detached into another tab), and legacy numeric + // keys never parse as stable ones — resolve both forms so neither slips the tab check. + protected isClosedAgentStatusTabForPaneKey(paneKey: string): boolean { + const tabId = + parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined + return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId) + } + + protected recordRetiredPaneFence( + paneKeys: ReadonlySet, + aliases: readonly RetiredPaneAlias[] + ): void { + const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases } + for (const key of paneKeys) { + // Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest. + this.retiredPaneFencesByKey.delete(key) + this.retiredPaneFencesByKey.set(key, fence) + } + while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) { + const oldest = this.retiredPaneFencesByKey.keys().next().value + if (oldest === undefined) { + break + } + this.retiredPaneFencesByKey.delete(oldest) + } + } + + protected markPaneClosedForAgentStatus(paneKey: string): void { + this.closedAgentStatusPaneKeys.delete(paneKey) + this.closedAgentStatusPaneKeys.add(paneKey) + while (this.closedAgentStatusPaneKeys.size > CLOSED_AGENT_STATUS_PANE_KEYS_MAX) { + const oldest = this.closedAgentStatusPaneKeys.keys().next().value + if (oldest === undefined) { + break + } + this.closedAgentStatusPaneKeys.delete(oldest) + } + } +} diff --git a/src/main/agent-hooks/server/server-status-identity.ts b/src/main/agent-hooks/server/server-status-identity.ts new file mode 100644 index 00000000000..41a87b4d7de --- /dev/null +++ b/src/main/agent-hooks/server/server-status-identity.ts @@ -0,0 +1,94 @@ +import { createHash } from 'node:crypto' + +import type { AgentKind } from '../../../shared/telemetry-events' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata +} from '../../../shared/agent-session-resume' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { AgentStatusIpcPayload, AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { AGENT_PROMPT_SENT_AGENT_KINDS, TOOL_PROGRESS_HOOK_EVENTS } from './server-constants' +import { MAX_PANE_KEY_LEN } from '../../../shared/agent-hook-listener/listener-limits' + +export function agentTypeToPromptSentAgentKind(agentType: AgentType | undefined): AgentKind { + const normalized = agentType?.trim().toLowerCase() + if (!normalized || normalized === 'unknown') { + return 'other' + } + if (normalized === 'claude') { + return 'claude-code' + } + return AGENT_PROMPT_SENT_AGENT_KINDS.has(normalized as AgentKind) + ? (normalized as AgentKind) + : 'other' +} + +export function equivalentInterruptAgentType( + actual: AgentType | undefined, + baseline: AgentType | undefined +): boolean { + const normalizedActual = actual === 'unknown' ? undefined : actual + const normalizedBaseline = baseline === 'unknown' ? undefined : baseline + return normalizedActual === normalizedBaseline +} + +// Why: validate the durable `${tabId}:${leafUuid}` leaf suffix at write/hydrate so legacy numeric rows fail closed. +export function isValidPaneKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= MAX_PANE_KEY_LEN && parsePaneKey(value) !== null + ) +} + +// Why: remote metadata-only rows are currently a Pi contract; user-dismissed rows use an internal persisted marker instead. +export function isValidPiProviderSessionOnly( + providerSession: AgentProviderSessionMetadata | undefined, + agentType: AgentType | undefined +): boolean { + return Boolean(providerSession && agentType === 'pi' && getAgentResumeArgv('pi', providerSession)) +} + +export function toAgentStatusIpcPayload( + entry: EnrichedAgentHookEventPayload +): AgentStatusIpcPayload { + return { + paneKey: entry.paneKey, + ...(entry.launchToken ? { launchToken: entry.launchToken } : {}), + tabId: entry.tabId, + worktreeId: entry.worktreeId, + connectionId: entry.connectionId, + receivedAt: entry.receivedAt, + stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), + ...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}), + ...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(entry.observation ? { observation: entry.observation } : {}), + ...entry.payload + } +} + +export function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boolean { + if (next.payload.state !== 'working') { + return false + } + if (next.payload.agentType !== 'claude' && next.payload.agentType !== 'codex') { + return false + } + // Why: a same-prompt retry is another UserPromptSubmit, while late post-Ctrl+C progress arrives as tool lifecycle work. + return next.hookEventName !== undefined && TOOL_PROGRESS_HOOK_EVENTS.has(next.hookEventName) +} + +export function paneCacheKeyTabId(key: string): string | null { + const paneKey = key.split('\0', 1)[0] ?? key + return parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? null +} + +export function paneCacheKeyMatchesTab(key: string, tabId: string): boolean { + return paneCacheKeyTabId(key) === tabId +} + +export function hashLaunchToken(value: string): string { + return createHash('sha256').update(value).digest('hex') +} diff --git a/src/main/agent-hooks/server/server-status-inference.ts b/src/main/agent-hooks/server/server-status-inference.ts new file mode 100644 index 00000000000..ec651691982 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-inference.ts @@ -0,0 +1,175 @@ +import { + markClaudeLeadTurnInterrupted, + clearClaudeAnsweredQuestionWait +} from '../../../shared/agent-hook-listener/providers/claude-roster-state' +import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state' +import { + isAgentInterruptInputIntent, + type AgentInterruptInferenceRequest +} from '../../../shared/agent-interrupt-intent' +import { + isAskUserQuestionTool, + type AgentQuestionAnsweredInferenceRequest +} from '../../../shared/agent-question-answered-intent' +import { AGENT_STATUS_STALE_AFTER_MS, type AgentType } from '../../../shared/agent-status-types' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { equivalentInterruptAgentType, isValidPaneKey } from './server-status-identity' +import { AgentHookServerListeners } from './server-listeners' + +export abstract class AgentHookServerStatusInference extends AgentHookServerListeners { + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + if (!isAgentInterruptInputIntent(request.intent)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + if (existing.providerSessionOnly) { + return false + } + // Why: inference must not fabricate a `done` onto a row whose `working` was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + const agentType: AgentType | undefined = payload.agentType + // Why: Droid's Ctrl+C exits the CLI (handled by PTY lifecycle) rather than interrupting the current turn. + if (agentType === 'droid' && request.intent === 'ctrl-c') { + return false + } + // Why: these agents use the first Escape as a TUI cancel that can leave the turn running; only a double Escape infers an interrupt. + if ( + (agentType === 'opencode' || agentType === 'copilot') && + request.intent === 'plain-escape' && + request.inputCount !== 2 + ) { + return false + } + const dismissesClaudeQuestion = + agentType === 'claude' && + request.intent === 'plain-escape' && + payload.state === 'waiting' && + isAskUserQuestionTool(payload.toolName) + if (dismissesClaudeQuestion) { + return this.inferQuestionAnswered(request) + } + // Why: inference is a fallback for a missing final hook; a strict baseline match keeps a delayed timer from clobbering any newer hook. + if ( + payload.state !== 'working' || + !equivalentInterruptAgentType(agentType, request.baselineAgentType) || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: a 'working' pane can be child-driven; Ctrl+C doesn't stop background children, so inferring done would retire live child rows. + if (payload.subagents?.some((subagent) => subagent.state !== 'idle')) { + return false + } + // Why: Escape/Ctrl+C at Claude's idle prompt does not stop provider-owned shells or session crons. + if ( + agentType === 'claude' && + (this.state.claudeRunningNonAgentTaskPaneKeys.has(existing.paneKey) || + this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)) + ) { + return false + } + // Why: keep the Claude lead-turn record in sync, or a later child event re-emits the stale 'working' state and resurrects the cancelled pane. + if (agentType === 'claude') { + markClaudeLeadTurnInterrupted(this.state, existing.paneKey) + } + if (agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, existing.paneKey) + } + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: 'done', + prompt: payload.prompt, + agentType, + ...(payload.model ? { model: payload.model } : {}), + interrupted: true, + // Why: idle children are display state; dropping them on an inferred interrupt blanks rows a later hook would restore. + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred interrupted agent status', { + paneKey: inferred.paneKey, + agentType, + intent: request.intent + }) + return true + } + + /** Guarded fallback for the hook Claude omits after answering or dismissing AskUserQuestion. */ + inferQuestionAnswered(request: AgentQuestionAnsweredInferenceRequest): boolean { + if (!isValidPaneKey(request.paneKey)) { + return false + } + const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if (!existing) { + return false + } + // Why: inference must not fabricate a transition onto a row whose state was never confirmed this runtime. + if (existing.restoredUnconfirmed) { + return false + } + const payload = existing.payload + // Why: only Claude's interactive question clears on typed input — tool name (not hook event) discriminates; real permission waits stay sticky. + if ( + payload.agentType !== 'claude' || + payload.state !== 'waiting' || + !isAskUserQuestionTool(payload.toolName) + ) { + return false + } + if ( + payload.agentType !== request.baselineAgentType || + payload.prompt !== request.baselinePrompt || + existing.receivedAt !== request.baselineUpdatedAt || + existing.stateStartedAt !== request.baselineStateStartedAt || + Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS + ) { + return false + } + // Why: sync the listener's lead-turn record too, or a later child event re-emits the stale waiting state and resurrects the card. + const restored = clearClaudeAnsweredQuestionWait(this.state, existing.paneKey) + const inferred = this.applyNormalizedStatus({ + paneKey: existing.paneKey, + tabId: existing.tabId, + worktreeId: existing.worktreeId, + connectionId: existing.connectionId, + providerSession: existing.providerSession, + payload: { + state: restored.state, + ...(restored.workingMode ? { workingMode: restored.workingMode } : {}), + prompt: payload.prompt, + agentType: payload.agentType, + ...(restored.state === 'done' && restored.interrupted ? { interrupted: true } : {}), + ...(restored.turnCompletedAt !== undefined + ? { turnCompletedAt: restored.turnCompletedAt } + : {}), + ...(payload.subagents ? { subagents: payload.subagents } : {}) + } + }) + console.debug('[agent-hooks] inferred resolved question status', { + paneKey: inferred.paneKey, + state: inferred.payload.state + }) + return true + } +} diff --git a/src/main/agent-hooks/server/server-status-retries.ts b/src/main/agent-hooks/server/server-status-retries.ts new file mode 100644 index 00000000000..2757806b293 --- /dev/null +++ b/src/main/agent-hooks/server/server-status-retries.ts @@ -0,0 +1,155 @@ +import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state' +import { normalizeHookPayload } from '../../../shared/agent-hook-listener' +import { + hasPendingAgentResultText, + preparePendingGrokResultDiscovery +} from '../../../shared/agent-hook-listener/grok-result-discovery' +import type { AgentHookSource } from '../../../shared/agent-hook-relay' +import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { + ASSISTANT_MESSAGE_RETRY_ATTEMPTS, + ASSISTANT_MESSAGE_RETRY_MS, + CODEX_SUBAGENT_POLL_MS +} from './server-constants' +import { AgentHookServerStatusUpdate } from './server-status-update' + +type CodexSubagentPoll = { + source: AgentHookSource + body: unknown + original: EnrichedAgentHookEventPayload +} + +export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate { + private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler( + CODEX_SUBAGENT_POLL_MS, + (paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll) + ) + + protected clearAllCodexSubagentPolls(): void { + this.codexSubagentPollScheduler.clearAll() + } + + protected clearAssistantMessageRetry(paneKey: string): void { + const timer = this.assistantMessageRetryTimers.get(paneKey) + if (!timer) { + return + } + clearTimeout(timer) + this.assistantMessageRetryTimers.delete(paneKey) + } + + protected clearCodexSubagentPoll(paneKey: string): void { + this.codexSubagentPollScheduler.clear(paneKey) + } + + protected scheduleCodexSubagentPoll( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload + ): void { + // Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll. + if (source !== 'codex') { + return + } + this.codexSubagentPollScheduler.clear(original.paneKey) + if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) { + return + } + this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original }) + } + + private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void { + const { source, body, original } = poll + // Keep the identity check at callback time: a newer event supersedes this + // payload even when its pane still has transcript children. + if ( + paneKey !== original.paneKey || + !this.server || + this.state.lastStatusByPaneKey.get(original.paneKey) !== original + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized) { + return + } + const subagentsChanged = + JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents) + const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original + this.scheduleCodexSubagentPoll(source, body, next) + } + + protected scheduleAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + attempt = 1, + discoveryReady = false + ): void { + if ( + original.payload.lastAssistantMessage || + !hasPendingAgentResultText(source, body) || + attempt > ASSISTANT_MESSAGE_RETRY_ATTEMPTS + ) { + return + } + this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded flush timers; its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } + const timer = setTimeout(() => { + try { + this.assistantMessageRetryTimers.delete(original.paneKey) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) + } catch (err) { + console.error('[agent-hooks] assistant message retry failed:', err) + } + }, ASSISTANT_MESSAGE_RETRY_MS) + this.assistantMessageRetryTimers.set(original.paneKey, timer) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + protected applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = this.normalizeLocalHookPayload(source, body) + if (!normalized.event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript line is flushed; discovery is event-driven, later content retries stay timed. + this.applyNormalizedStatus(normalized.event, normalized.onAccepted) + } +} diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts new file mode 100644 index 00000000000..981da873e3d --- /dev/null +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -0,0 +1,219 @@ +import { + reconcileRemoteCodexState, + markCodexLeadTurnInterrupted +} from '../../../shared/agent-hook-listener/providers/codex-state' +import { + resolveAgentStatusIdentity, + shouldSuppressInheritedTerminalStatus +} from '../../../shared/agent-status-identity' +import { INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS } from './server-constants' +import type { EnrichedAgentHookEventPayload } from './server-types' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { AgentStatusObservationOrigin } from '../../../shared/agent-status-observation' +import { + attachClaudeChildOnlyBoundary, + attachClaudePermissionToolUseId, + invalidateClaudeChildOnlyBoundary, + shouldKeepClaudePermissionVisible +} from './server-claude-status-rules' +import { isToolProgressWorkingAfterInterrupt } from './server-status-identity' +import { AgentHookServerStatusApplication } from './server-status-application' + +export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusApplication { + protected applyNormalizedStatus( + payload: AgentHookEventPayload, + onAccepted?: () => void, + origin: AgentStatusObservationOrigin = 'hook' + ): EnrichedAgentHookEventPayload { + if (payload.hookEventName === 'UserPromptSubmit') { + // Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp. + this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey) + } + let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + const connectionClearWatermark = payload.connectionId + ? this.connectionTimestampWatermarkById.get(payload.connectionId) + : undefined + // Why: renderer ordering rejects older rows; live evidence must sort after reconnect clears and restored rows across clock rollback. + const restoredStatusWatermark = previous?.restoredUnconfirmed ? previous.receivedAt : undefined + const now = Math.max( + Date.now(), + (connectionClearWatermark ?? -1) + 1, + (restoredStatusWatermark ?? -1) + 1 + ) + if (payload.connectionId) { + this.connectionTimestampWatermarkById.set(payload.connectionId, now) + } + if (payload.providerSessionOnly) { + // Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status. + onAccepted?.() + const enriched = { + ...this.attachStatusTiming(payload, now), + observation: this.stampObservation(payload, origin, now) + } + this.clearAssistantMessageRetry(enriched.paneKey) + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + const stateReconciledPayload = + payload.connectionId && payload.payload.agentType === 'codex' && payload.hookEventName + ? { + ...payload, + payload: reconcileRemoteCodexState( + this.state, + payload.paneKey, + payload.hookEventName, + payload.toolAgentId, + payload.payload, + previous?.payload + ) + } + : payload + const previousCodexRoot = + stateReconciledPayload.payload.agentType === 'codex' && + stateReconciledPayload.toolAgentId && + previous?.payload.agentType === 'codex' + ? previous + : undefined + const preservedProviderSession = !stateReconciledPayload.providerSession + ? previousCodexRoot?.providerSession + : undefined + const preservedRootModel = !stateReconciledPayload.payload.model + ? previousCodexRoot?.payload.model + : undefined + // Why: an SSH relay restart forgets root-only fields; child hooks must not erase durable resume/model identity. + const rootContextPreservingPayload = + preservedProviderSession || preservedRootModel + ? { + ...stateReconciledPayload, + ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), + payload: preservedRootModel + ? { ...stateReconciledPayload.payload, model: preservedRootModel } + : stateReconciledPayload.payload + } + : stateReconciledPayload + const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary( + previous, + rootContextPreservingPayload + ) + if (boundaryReconciledPrevious !== previous) { + previous = boundaryReconciledPrevious + if (previous) { + this.state.lastStatusByPaneKey.set(previous.paneKey, previous) + this.scheduleStatusPersist() + } + } + const identity = resolveAgentStatusIdentity({ + existing: previous + ? { + agentType: previous.payload.agentType, + state: previous.payload.state, + updatedAt: previous.receivedAt, + restoredUnconfirmed: previous.restoredUnconfirmed + } + : undefined, + incoming: rootContextPreservingPayload.payload.agentType, + now + }) + if ( + previous && + shouldSuppressInheritedTerminalStatus({ + inheritedFromActivePane: identity.inheritedFromActivePane, + incomingState: rootContextPreservingPayload.payload.state + }) + ) { + return previous + } + const identityResolvedPayload = + identity.agentType === rootContextPreservingPayload.payload.agentType + ? rootContextPreservingPayload + : { + ...rootContextPreservingPayload, + payload: { ...rootContextPreservingPayload.payload, agentType: identity.agentType } + } + const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload) + const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload) + if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) { + return previous + } + // Why: some TUIs emit a delayed tool/working hook after Ctrl+C stopped the turn; don't let it resurrect the row. + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'done' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS + ) { + return previous + } + if ( + previous?.payload.state === 'done' && + previous.payload.interrupted === true && + effectivePayload.payload.state === 'working' && + previous.payload.agentType === effectivePayload.payload.agentType && + previous.payload.prompt === effectivePayload.payload.prompt && + (effectivePayload.isReplay === true || + isToolProgressWorkingAfterInterrupt(effectivePayload) || + (effectivePayload.hasExplicitPrompt !== true && + Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS)) + ) { + if (effectivePayload.payload.agentType === 'codex') { + markCodexLeadTurnInterrupted(this.state, effectivePayload.paneKey) + } + return previous + } + if ( + effectivePayload.payload.state !== 'done' || + effectivePayload.payload.lastAssistantMessage + ) { + this.clearAssistantMessageRetry(effectivePayload.paneKey) + } + onAccepted?.() + if (!identity.inheritedFromActivePane) { + this.maybeTrackAgentPromptSent(effectivePayload, previous) + } + const enriched = { + ...this.attachStatusTiming(boundaryAwarePayload, now), + observation: this.stampObservation(boundaryAwarePayload, origin, now) + } + if ( + typeof enriched.payload.turnCompletedAt === 'number' && + Number.isFinite(enriched.payload.turnCompletedAt) + ) { + this.activeHookTurnCompletedAtByPaneKey.set( + enriched.paneKey, + enriched.payload.turnCompletedAt + ) + } + // Why: an identity-matched event can still leave the aggregate backed only by another restored child; keep liveness reconciliation eligible. + if (enriched.restoredUnconfirmed) { + this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey) + } else { + this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) + } + this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitEnrichedStatus(enriched) + return enriched + } + + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + protected emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } +} diff --git a/src/main/agent-hooks/server/server-tab-cleanup.ts b/src/main/agent-hooks/server/server-tab-cleanup.ts new file mode 100644 index 00000000000..4abacfc81d0 --- /dev/null +++ b/src/main/agent-hooks/server/server-tab-cleanup.ts @@ -0,0 +1,122 @@ +import { clearPaneCacheState } from '../../../shared/agent-hook-listener/listener-state' +import { paneCacheKeyMatchesTab } from './server-status-identity' +import { AgentHookServerCleanup } from './server-cleanup' + +export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup { + /** Drop every status/cache claim attributable to a closed tab prefix. */ + dropStatusEntriesByTabPrefix(tabId: string): void { + this.markTabClosedForAgentStatus(tabId) + const paneKeysToClear = new Set() + for (const key of this.state.lastStatusByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key) + } + } + for (const key of this.state.lastPromptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.lastToolByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.antigravityCompletedTranscriptByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const key of this.state.ampCompletedCacheKeys) { + if (paneCacheKeyMatchesTab(key, tabId)) { + paneKeysToClear.add(key.split('\0', 1)[0] ?? key) + } + } + for (const paneKey of this.runtimeObservedStatusPaneKeys) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const paneKey of this.promptSentDedupeByPaneKey.keys()) { + if (paneCacheKeyMatchesTab(paneKey, tabId)) { + paneKeysToClear.add(paneKey) + } + } + for (const commitment of this.hydratedAuthorityCommitments) { + if (paneCacheKeyMatchesTab(commitment.paneKey, tabId)) { + paneKeysToClear.add(commitment.paneKey) + } + } + let aliasChanged = false + for (const [legacyPaneKey, entry] of this.legacyPaneKeyAliases) { + if (paneCacheKeyMatchesTab(entry.stablePaneKey, tabId)) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeysToClear.add(legacyPaneKey) + paneKeysToClear.add(entry.stablePaneKey) + this.markPaneClosedForAgentStatus(legacyPaneKey) + this.markPaneClosedForAgentStatus(entry.stablePaneKey) + aliasChanged = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeysToClear) + let statusChanged = false + for (const paneKey of paneKeysToClear) { + if (this.state.lastStatusByPaneKey.has(paneKey)) { + statusChanged = true + } + this.clearAssistantMessageRetry(paneKey) + this.clearCodexSubagentPoll(paneKey) + clearPaneCacheState(this.state, paneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(paneKey) + this.runtimeObservedStatusPaneKeys.delete(paneKey) + this.currentAuthorityObservations.delete(paneKey) + this.promptSentDedupeByPaneKey.delete(paneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(paneKey) + } + if (aliasChanged) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (statusChanged || authorityChanged) { + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + } + } + + clearPaneState(paneKey: string): void { + const resolvedPaneKey = this.resolvePaneKeyAlias(paneKey) + const paneKeys = new Set([paneKey, resolvedPaneKey]) + // Why: only persist when a status entry was actually evicted; dropping prompt/tool caches doesn't change the file. + const hadStatus = this.state.lastStatusByPaneKey.has(resolvedPaneKey) + this.clearAssistantMessageRetry(resolvedPaneKey) + this.clearCodexSubagentPoll(resolvedPaneKey) + clearPaneCacheState(this.state, resolvedPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey) + this.currentAuthorityObservations.delete(resolvedPaneKey) + this.promptSentDedupeByPaneKey.delete(resolvedPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(resolvedPaneKey) + let clearedAlias = false + for (const [legacyPaneKey, alias] of this.legacyPaneKeyAliases) { + if (alias.stablePaneKey === resolvedPaneKey) { + this.legacyPaneKeyAliases.delete(legacyPaneKey) + paneKeys.add(legacyPaneKey) + paneKeys.add(alias.stablePaneKey) + clearPaneCacheState(this.state, legacyPaneKey) + this.activeHookTurnCompletedAtByPaneKey.delete(legacyPaneKey) + this.currentAuthorityObservations.delete(legacyPaneKey) + this.promptSentDedupeByPaneKey.delete(legacyPaneKey) + this.restartedStatusLaunchTokenHashByPaneKey.delete(legacyPaneKey) + clearedAlias = true + } + } + const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys) + if (clearedAlias) { + this.notifyPaneKeyAliasPersistenceListener() + } + if (hadStatus || authorityChanged) { + this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) + } + } +} diff --git a/src/main/agent-hooks/server/server-transport-rules.ts b/src/main/agent-hooks/server/server-transport-rules.ts new file mode 100644 index 00000000000..2f1aacc370e --- /dev/null +++ b/src/main/agent-hooks/server/server-transport-rules.ts @@ -0,0 +1,13 @@ +import { track } from '../../telemetry/client' + +/** Keep unattributed hook deliveries visible in telemetry without rejecting the request. */ +export function trackEmptyPaneKeyHook(body: unknown): void { + if (typeof body !== 'object' || body === null) { + return + } + const paneKey = (body as Record).paneKey + if (typeof paneKey === 'string' && paneKey.trim().length > 0) { + return + } + track('agent_hook_unattributed', { reason: 'empty_pane_key' }) +} diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts new file mode 100644 index 00000000000..913bcd7067e --- /dev/null +++ b/src/main/agent-hooks/server/server-types.ts @@ -0,0 +1,113 @@ +import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event' +import type { + AgentStatusClearIpcPayload, + AgentStatusState +} from '../../../shared/agent-status-types' +import type { AgentStatusObservation } from '../../../shared/agent-status-observation' +import type { AgentKind } from '../../../shared/telemetry-events' +import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' + +// Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). +export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { + receivedAt: number + stateStartedAt: number + /** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */ + observation?: AgentStatusObservation + /** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */ + restoredUnconfirmed?: true + /** User-hidden resume identity retained solely for destructive liveness checks. */ + retainedForLiveness?: true + /** Persisted proof that a lead boundary was held working only by child agents. */ + claudeLeadBoundaryChildOnly?: true +} + +export type PersistedAgentHookEventPayload = Omit< + EnrichedAgentHookEventPayload, + | 'claudeRunningNonAgentTask' + | 'launchToken' + | 'promptInteractionKey' + | 'restoredUnconfirmed' + // Why: revision counters are in-memory and the authority id is regenerated per process, so + // a stored observation could only rehydrate as a stale ordering claim from a dead authority. + | 'observation' +> & { + launchTokenHash?: string +} + +export type PersistedAgentHookAuthorityCommitment = { + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +} + +export type AgentHookStatusChangeEntry = { + state: AgentStatusState + receivedAt: number + observedInCurrentRuntime: boolean +} + +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + +export type AgentHookAuthorityEvidence = Readonly<{ + paneKey: string + launchTokenHash: string + connectionId: string | null + tabId?: string + worktreeId?: string + observedAt: number +}> + +export type AgentHookAuthorityAttestation = Readonly<{ + paneKey: string + source: 'current_hook' | 'hydrated_commitment' +}> + +export type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +export type ProviderSessionChangeListener = ( + providerSessions: AgentHookProviderSessionIdentity[] +) => void +export type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void +export type StatusDropListener = (paneKey: string) => void +export type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void + +export type PaneKeyAliasEntry = { + stablePaneKey: string + ptyId: string | null + updatedAt: number + authorityVerified: boolean +} +export type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry } +/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */ +export type RetiredPaneFence = { + paneKeys: readonly string[] + aliases: readonly RetiredPaneAlias[] +} + +export type LastStatusFile = { + version: number + entries: Record + authorityCommitments?: Record +} + +export type AgentPromptSentDedupeEntry = { + agentKind: AgentKind + promptHash: string + promptInteractionKey?: string +} + +export type NormalizedLocalHook = { + event: AgentHookEventPayload | null + onAccepted?: () => void +} + +export type ServerStatusLineListener = ((event: ClaudeStatusLineRateLimits) => void) | null +export type ServerAgentStatusListener = ((payload: EnrichedAgentHookEventPayload) => void) | null diff --git a/src/main/browser/agent-browser-bridge-capture-commands.ts b/src/main/browser/agent-browser-bridge-capture-commands.ts new file mode 100644 index 00000000000..5719bfbc7b6 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-capture-commands.ts @@ -0,0 +1,185 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { BrowserScreenshotResult, BrowserEvalResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { captureFullPageScreenshot } from './cdp-screenshot' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeUtilityCommands } from './agent-browser-bridge-utility-commands' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' + +export abstract class AgentBrowserBridgeCaptureCommands extends AgentBrowserBridgeUtilityCommands { + async screenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) + }, + { ensureVisible: false } + ) + } + + async fullPageScreenshot( + format?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName, target) => { + return this.captureFullPageScreenshotCommand( + sessionName, + target.webContentsId, + 500, + format === 'jpeg' ? 'jpeg' : 'png' + ) + }, + { ensureVisible: false } + ) + } + + private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { + const parsed = raw as { path?: string } | undefined + if (!parsed?.path) { + throw new BrowserError('browser_error', 'Screenshot returned no file path') + } + if (!existsSync(parsed.path)) { + throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) + } + const data = readFileSync(parsed.path).toString('base64') + return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult + } + + private async captureScreenshotCommand( + sessionName: string, + commandArgs: string[], + settleMs: number, + format?: string + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. + await new Promise((r) => setTimeout(r, settleMs)) + const raw = await this.execAgentBrowser(sessionName, commandArgs) + return this.readScreenshotFromResult(raw, format) + } finally { + restore() + } + }) + } + + private async captureFullPageScreenshotCommand( + sessionName: string, + webContentsId: number, + settleMs: number, + format: 'png' | 'jpeg' + ): Promise { + return this.withSerializedScreenshotAccess(async () => { + const session = this.sessions.get(sessionName) + const restore = session + ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) + : () => {} + try { + // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. + await new Promise((r) => setTimeout(r, settleMs)) + const wc = this.getWebContents(webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + return await captureFullPageScreenshot(wc, format) + } catch (error) { + throw new BrowserError('browser_error', (error as Error).message) + } finally { + restore() + } + }) + } + + private async withSerializedScreenshotAccess(execute: () => Promise): Promise { + const previousTurn = this.screenshotTurn.catch(() => {}) + let releaseTurn!: () => void + this.screenshotTurn = new Promise((resolve) => { + releaseTurn = resolve + }) + await previousTurn + try { + return await execute() + } finally { + releaseTurn() + } + } + + async evaluate( + expression: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + let releaseDebugger = (): void => {} + try { + releaseDebugger = acquireElectronDebugger(wc).release + const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true + })) as { + result: { value?: unknown; description?: string } + exceptionDetails?: { text: string; exception?: { description?: string } } + } + if (exceptionDetails) { + throw new BrowserError( + 'browser_eval_error', + exceptionDetails.exception?.description ?? exceptionDetails.text + ) + } + + const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (currentTarget.webContentsId !== target.webContentsId) { + throw new BrowserError( + 'browser_tab_changed', + `Browser page ${target.browserPageId} changed while evaluating; retry the command` + ) + } + return { + result: + result.value !== undefined + ? typeof result.value === 'object' && result.value !== null + ? JSON.stringify(result.value) + : String(result.value) + : (result.description ?? ''), + origin: wc.getURL() + } + } catch (error) { + if (error instanceof BrowserError) { + throw error + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + throw new BrowserError( + 'browser_error', + `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } finally { + releaseDebugger() + } + }, + { ensureSession: false } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-core-commands.ts b/src/main/browser/agent-browser-bridge-core-commands.ts new file mode 100644 index 00000000000..aac82f77487 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-core-commands.ts @@ -0,0 +1,186 @@ +import type { + BrowserSnapshotResult, + BrowserClickResult, + BrowserGotoResult, + BrowserFillResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { focusedValueSetExpression } from './agent-browser-bridge-input' +import { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + EMBEDDED_NAVIGATION_TIMEOUT_MS +} from './agent-browser-bridge-types' +import { + isAbortedNavigationError, + waitForAbortedNavigationReplacement +} from './agent-browser-bridge-process' +import { AgentBrowserBridgeQueue } from './agent-browser-bridge-queue' + +export abstract class AgentBrowserBridgeCoreCommands extends AgentBrowserBridgeQueue { + async snapshot(worktreeId?: string, browserPageId?: string): Promise { + // Why: snapshot creates fresh refs so it must bypass the stale-ref guard + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'snapshot' + ])) as BrowserSnapshotResult + return { + ...result, + browserPageId: target.browserPageId + } + }) + } + + async click( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult + }) + } + + async dblclick( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult + }) + } + + async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.requireTargetWebContents(target) + const navigationUrl = normalizeBrowserNavigationUrl(url) + if (!navigationUrl) { + throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) + } + const navigationState: { preventUnloadEvent: Electron.Event | null } = { + preventUnloadEvent: null + } + const onWillPreventUnload = (event: Electron.Event): void => { + navigationState.preventUnloadEvent = event + } + wc.on('will-prevent-unload', onWillPreventUnload) + let navigationAborted = false + const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS + let navigationTimeout: ReturnType | null = null + try { + await Promise.race([ + wc.loadURL(navigationUrl), + new Promise((_resolve, reject) => { + navigationTimeout = setTimeout( + () => + reject( + new Error( + `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + EMBEDDED_NAVIGATION_TIMEOUT_MS + ) + navigationTimeout.unref?.() + }) + ]) + } catch (error) { + if (navigationTimeout) { + clearTimeout(navigationTimeout) + navigationTimeout = null + } + if (!this.getWebContents(target.webContentsId)) { + throw this.createPageUnavailableError( + `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + ) + } + // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. + if ( + !isAbortedNavigationError(error) || + (navigationState.preventUnloadEvent !== null && + !navigationState.preventUnloadEvent.defaultPrevented) + ) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` + ) + } + navigationAborted = true + // Why: a superseding navigation rejects the first load before its replacement has landed. + await waitForAbortedNavigationReplacement( + wc, + target.browserPageId, + Math.max(0, navigationDeadline - Date.now()) + ) + } finally { + wc.removeListener('will-prevent-unload', onWillPreventUnload) + if (navigationTimeout) { + clearTimeout(navigationTimeout) + } + } + + // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. + const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) + const loadError = navigationAborted + ? this.browserManager.getBrowserPageLoadError(target.browserPageId) + : null + if (loadError) { + throw new BrowserError( + 'browser_error', + `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` + ) + } + return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } + }, + { ensureSession: false } + ) + } + + async fill( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(value) + // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + await this.execAgentBrowser(sessionName, ['focus', element]) + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify('')) + ]) + for (const chunk of iterateBrowserTextInsertionChunks( + value, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(chunk), { append: true }) + ]) + } + await this.execAgentBrowser(sessionName, [ + 'eval', + focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) + ]) + return { filled: element } as BrowserFillResult + } + + await this.fillExplicitContentEditable(sessionName, element, value) + return { filled: element } as BrowserFillResult + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-execution.ts b/src/main/browser/agent-browser-bridge-execution.ts new file mode 100644 index 00000000000..65f64b6fb08 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-execution.ts @@ -0,0 +1,263 @@ +import { execFile } from 'node:child_process' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { + focusedRichTextEditExpression, + isExplicitContentEditableResult +} from './agent-browser-bridge-input' +import { + isTabClosedTransportError, + pageUnavailableMessageForSession +} from './agent-browser-bridge-process' +import { translateResult } from './agent-browser-bridge-result' +import { AgentBrowserBridgeTabs } from './agent-browser-bridge-tabs' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { + STALE_SESSION_CLOSE_TIMEOUT_MS, + type AgentBrowserExecOptions, + type SessionState, + type ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeExecution extends AgentBrowserBridgeTabs { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected abstract runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise + + protected requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) + } + return wc + } + + /** + * Notice that the daemon retired itself between two commands. + * + * A replacement daemon still serves the page (every call reasserts `--cdp`) + * but carries none of the session's network routes, so without this the + * interception the caller configured is silently gone (#16367). + */ + protected reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { + if ( + this.agentBrowserIdleTimeoutMs === null || + Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs + ) { + return + } + session.initialized = false + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + } + + protected assertCommandAdmission(): void { + if (this.shutdownStarted) { + throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') + } + } + + protected async execAgentBrowser( + sessionName: string, + commandArgs: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + const session = this.sessions.get(sessionName) + if (!session) { + // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. + throw this.createPageUnavailableError(sessionName) + } + + // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. + if (!this.getWebContents(session.webContentsId)) { + await this.destroySession(sessionName) + throw this.createPageUnavailableError(sessionName) + } + + this.reinitializeIfDaemonIdledOut(sessionName, session) + session.lastCommandAt = Date.now() + + const args = ['--session', sessionName] + const managesInterceptRoutes = + commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') + + const needsInit = !session.initialized + // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. + args.push('--cdp', String(session.proxy.getPort())) + + // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. + for (const commandArg of commandArgs) { + args.push(commandArg) + } + args.push('--json') + + const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) + const translated = translateResult(stdout) + + if (!translated.ok) { + throw this.createCommandError( + sessionName, + translated.error.message, + translated.error.code, + session.webContentsId + ) + } + + // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. + if (needsInit) { + session.initialized = true + + // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. + const pendingPatterns = managesInterceptRoutes + ? undefined + : this.pendingInterceptRestore.get(sessionName) + if (pendingPatterns && pendingPatterns.length > 0) { + this.pendingInterceptRestore.delete(sessionName) + try { + const urlPattern = pendingPatterns[0] ?? '**/*' + await this.runAgentBrowserRaw(sessionName, [ + '--session', + sessionName, + '--cdp', + String(session.proxy.getPort()), + 'network', + 'route', + urlPattern, + '--json' + ]) + session.activeInterceptPatterns = pendingPatterns + } catch { + // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. + } + } + } + + return translated.result + } + + protected async isExplicitContentEditableTarget( + sessionName: string, + element: string + ): Promise { + const result = await this.execAgentBrowser(sessionName, [ + 'get', + 'attr', + element, + 'contenteditable' + ]) + return isExplicitContentEditableResult(result) + } + + protected async fillExplicitContentEditable( + sessionName: string, + element: string, + value: string + ): Promise { + await this.execAgentBrowser(sessionName, ['focus', element]) + // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. + await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { + stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) + }) + } + + protected createPageUnavailableError(sessionName: string): BrowserError { + return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) + } + + protected closeStaleAgentBrowserSession(sessionName: string): Promise { + return new Promise((resolve, reject) => { + let child: ReturnType | null = null + let settled = false + + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + if (error) { + reject(error) + } else { + resolve() + } + } + + // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. + const timeout = setTimeout(() => { + child?.kill() + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` + ) + ) + }, STALE_SESSION_CLOSE_TIMEOUT_MS) + + try { + child = execFile( + this.agentBrowserBin, + ['--session', sessionName, 'close'], + // Why windowsHide: agent-browser is console-subsystem and Orca's main + // process owns no console, so each spawn gets a fresh visible conhost + // that takes foreground -- keystrokes typed into a terminal at that + // moment land in the black box (#14543). + { + env: this.agentBrowserEnv, + timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, + windowsHide: true + }, + (error) => + finish( + error + ? new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error.message}` + ) + : undefined + ) + ) + } catch (error) { + finish( + new BrowserError( + 'browser_owner_unavailable', + `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` + ) + ) + } + }) + } + + protected createCommandError( + sessionName: string, + message: string, + fallbackCode: string, + webContentsId?: number + ): BrowserError { + // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. + if ( + fallbackCode === 'browser_error' && + isTabClosedTransportError(message) && + this.isSessionTargetClosed(sessionName, webContentsId) + ) { + return this.createPageUnavailableError(sessionName) + } + return new BrowserError(fallbackCode, message) + } + + protected isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { + const session = this.sessions.get(sessionName) + if (!session) { + return true + } + const targetWebContentsId = webContentsId ?? session.webContentsId + return !this.getWebContents(targetWebContentsId) + } +} diff --git a/src/main/browser/agent-browser-bridge-input-commands.ts b/src/main/browser/agent-browser-bridge-input-commands.ts new file mode 100644 index 00000000000..7eef4a1ddf3 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input-commands.ts @@ -0,0 +1,124 @@ +import type { + BrowserTypeResult, + BrowserSelectResult, + BrowserScrollResult +} from '../../shared/runtime-types' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' +import { AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCoreCommands } from './agent-browser-bridge-core-commands' + +export abstract class AgentBrowserBridgeInputCommands extends AgentBrowserBridgeCoreCommands { + async type( + input: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(input) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + for (const chunk of iterateBrowserTextInsertionChunks( + input, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) + } + return { typed: true } as BrowserTypeResult + }, + { requireScopedTarget: true } + ) + } + + async select( + element: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'select', + element, + value + ])) as BrowserSelectResult + }) + } + + async scroll( + direction: string, + amount?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['scroll', direction] + if (amount != null) { + args.push(String(amount)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult + }) + } + + async scrollIntoView( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) + }) + } + + async get( + what: string, + selector?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['get', what] + if (selector) { + args.push(selector) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async is( + what: string, + selector: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['is', what, selector]) + }) + } + + // ── Keyboard commands ── + + async keyboardInsertText( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text) + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + let result: unknown = { inserted: true } + for (const chunk of iterateBrowserTextInsertionChunks( + text, + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + )) { + result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) + } + return result + }, + { requireScopedTarget: true } + ) + } +} diff --git a/src/main/browser/agent-browser-bridge-input.ts b/src/main/browser/agent-browser-bridge-input.ts new file mode 100644 index 00000000000..cc4869b90bf --- /dev/null +++ b/src/main/browser/agent-browser-bridge-input.ts @@ -0,0 +1,67 @@ +export function focusedValueSetExpression( + valueExpression: string, + options?: { append?: boolean; dispatchEvents?: boolean } +): string { + const nextValue = options?.append + ? ["String(target.value ?? '') + ", valueExpression].join('') + : valueExpression + const dispatchEvents = options?.dispatchEvents + ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" + : '' + return [ + '(() => { const el = document.activeElement; if (el) {', + // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. + " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", + " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", + ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', + ' let target = el;', + " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", + " const controls = target.getAttribute('aria-controls');", + ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', + ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', + ' }', + " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", + ' const nextValue = ', + nextValue, + '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', + dispatchEvents, + ' } })()' + ].join('') +} + +// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. +export function focusedRichTextEditExpression( + valueExpression: string, + options?: { selectAll?: boolean } +): string { + const selectAll = options?.selectAll ? 'true' : 'false' + return [ + '(() => {', + ' const target = document.activeElement;', + ' const value = ', + valueExpression, + ';', + ` const selectAll = ${selectAll};`, + " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", + " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", + ' if (selectAll) {', + " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", + ' const selection = window.getSelection();', + " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", + ' selection.selectAllChildren(target);', + ' }', + " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", + ' let edited = false;', + ' try {', + ' edited = document.execCommand(editCommand, false, value) === true;', + ' } catch { edited = false; }', + " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", + ' })()' + ].join('') +} + +export function isExplicitContentEditableResult(result: unknown): boolean { + const value = + result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined + return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) +} diff --git a/src/main/browser/agent-browser-bridge-interaction-commands.ts b/src/main/browser/agent-browser-bridge-interaction-commands.ts new file mode 100644 index 00000000000..51854878c0b --- /dev/null +++ b/src/main/browser/agent-browser-bridge-interaction-commands.ts @@ -0,0 +1,192 @@ +import type { + BrowserHoverResult, + BrowserDragResult, + BrowserUploadResult, + BrowserWaitResult, + BrowserCheckResult, + BrowserFocusResult, + BrowserClearResult, + BrowserSelectAllResult, + BrowserKeypressResult, + BrowserPdfResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { WAIT_PROCESS_TIMEOUT_GRACE_MS } from './agent-browser-bridge-types' +import { AgentBrowserBridgeCaptureCommands } from './agent-browser-bridge-capture-commands' + +export abstract class AgentBrowserBridgeInteractionCommands extends AgentBrowserBridgeCaptureCommands { + async hover( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult + }) + } + + async drag( + from: string, + to: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult + }) + } + + async upload( + element: string, + filePaths: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'upload', + element, + ...filePaths + ])) as BrowserUploadResult + }) + } + + async wait( + options?: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + }, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['wait'] + const hasCondition = + !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn + if (options?.selector) { + args.push(options.selector) + } else if (options?.timeout != null && !hasCondition) { + args.push(String(options.timeout)) + } + if (options?.text) { + args.push('--text', options.text) + } + if (options?.url) { + args.push('--url', options.url) + } + if (options?.load) { + args.push('--load', options.load) + } + if (options?.fn) { + args.push('--fn', options.fn) + } + const normalizedState = options?.state === 'visible' ? undefined : options?.state + if (normalizedState) { + args.push('--state', normalizedState) + } + // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. + return (await this.execAgentBrowser(sessionName, args, { + timeoutMs: + options?.timeout != null && hasCondition + ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS + : undefined, + timeoutError: + options?.timeout != null && hasCondition + ? new BrowserError( + 'browser_timeout', + `Timed out waiting for browser condition after ${options.timeout}ms.` + ) + : undefined + })) as BrowserWaitResult + }) + } + + async check( + element: string, + checked: boolean, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = checked ? ['check', element] : ['uncheck', element] + return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult + }) + } + + async focus( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult + }) + } + + async clear( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (sessionName) => { + if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { + // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. + await this.execAgentBrowser(sessionName, ['fill', element, '']) + return { cleared: element } + } + + await this.fillExplicitContentEditable(sessionName, element, '') + return { cleared: element } + }, + { requireScopedTarget: true } + ) + } + + async selectAll( + element: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser has no select-all command — implement as focus + Ctrl+A + await this.execAgentBrowser(sessionName, ['focus', element]) + return (await this.execAgentBrowser(sessionName, [ + 'press', + 'Control+a' + ])) as BrowserSelectAllResult + }) + } + + async keypress( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult + }) + } + + async pdf(worktreeId?: string, browserPageId?: string): Promise { + // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + const buffer = await wc.printToPDF({ + printBackground: true, + preferCSSPageSize: true + }) + return { data: buffer.toString('base64') } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-lifecycle.ts b/src/main/browser/agent-browser-bridge-lifecycle.ts new file mode 100644 index 00000000000..c3ec6b170e9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-lifecycle.ts @@ -0,0 +1,266 @@ +import { CdpWsProxy } from './cdp-ws-proxy' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeRawProcess } from './agent-browser-bridge-raw-process' +import type { AgentBrowserCleanupOptions } from './agent-browser-bridge-types' +import { AGENT_BROWSER_CLEANUP_TIMEOUT_MS } from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeLifecycle extends AgentBrowserBridgeRawProcess { + async onTabClosed(webContentsId: number): Promise { + const browserPageId = this.resolveTabIdSafe(webContentsId) + const owningWorktreeId = browserPageId + ? this.browserManager.getWorktreeIdForTab(browserPageId) + : undefined + let nextWorktreeActiveWebContentsId: number | null = null + if ( + owningWorktreeId && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId + ) { + nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( + owningWorktreeId, + webContentsId + ) + } + if (this.activeWebContentsId === webContentsId) { + this.activeWebContentsId = nextWorktreeActiveWebContentsId + } + if (browserPageId) { + await this.onPageClosed(browserPageId) + } + this.options.onTabsChanged?.(owningWorktreeId) + } + + /** + * Retire a page's daemon by page id. + * + * The headless offscreen backend owns pages by id and unregisters the guest + * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it + * has to say which page closed (#16367). + */ + async onPageClosed(browserPageId: string): Promise { + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + await this.destroySession(sessionName) + this.pendingInterceptRestore.delete(sessionName) + } + + async onProcessSwap( + browserPageId: string, + newWebContentsId: number, + previousWebContentsId?: number + ): Promise { + // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` + const session = this.sessions.get(sessionName) + const oldWebContentsId = previousWebContentsId ?? session?.webContentsId + const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) + // Why: save intercept patterns before destroy so the new session can restore them after init. + if (session && session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + await this.destroySession(sessionName) + if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { + this.activeWebContentsId = newWebContentsId + } + if ( + owningWorktreeId && + oldWebContentsId != null && + this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId + ) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + } + protected async ensureSession( + sessionName: string, + browserPageId: string, + webContentsId: number + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + } + this.assertCommandAdmission() + + if (this.sessions.has(sessionName)) { + return + } + + // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. + const pending = this.pendingSessionCreation.get(sessionName) + if (pending) { + await pending + this.assertCommandAdmission() + return + } + + const createSession = async (): Promise => { + const wc = this.getWebContents(webContentsId) + if (!wc) { + // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. + await this.closeStaleAgentBrowserSession(sessionName) + + const proxy = new CdpWsProxy(wc) + const cdpEndpoint = await proxy.start() + + this.sessions.set(sessionName, { + proxy, + cdpEndpoint, + initialized: false, + consecutiveTimeouts: 0, + activeInterceptPatterns: [], + activeCapture: false, + lastCommandAt: Date.now(), + webContentsId, + activeProcess: null + }) + } + + const promise = createSession() + this.pendingSessionCreation.set(sessionName, promise) + try { + await promise + } finally { + this.pendingSessionCreation.delete(sessionName) + } + } + + protected async restartSessionForTarget( + sessionName: string, + browserPageId: string, + webContentsId: number, + options: { recreate: boolean } = { recreate: true } + ): Promise { + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + await pendingCreation.catch(() => {}) + } + + const session = this.sessions.get(sessionName) + if (session) { + if (session.activeInterceptPatterns.length > 0) { + this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) + } + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + if (session.activeProcess) { + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { + timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS + }) + } catch { + // Session may already be dead. + } + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + if (options.recreate) { + await this.ensureSession(sessionName, browserPageId, webContentsId) + } + } + + protected async destroySession( + sessionName: string, + options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } + ): Promise { + const pendingDestruction = this.pendingSessionDestruction.get(sessionName) + if (pendingDestruction) { + await pendingDestruction + return + } + + const pendingCreation = this.pendingSessionCreation.get(sessionName) + if (pendingCreation) { + // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. + try { + await pendingCreation + } catch { + // Creation failures are handled by the original caller; teardown still rejects queued work below. + } + } + + const session = this.sessions.get(sessionName) + if (!session) { + this.rejectQueuedCommandsForClosedSession(sessionName) + return + } + + this.sessions.delete(sessionName) + this.pendingSessionCreation.delete(sessionName) + + // Why: queued commands would hang forever if we just delete the queue — drain and reject them. + this.rejectQueuedCommandsForClosedSession(sessionName) + + if (session.activeProcess) { + // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. + this.cancelledProcesses.add(session.activeProcess) + try { + session.activeProcess.kill() + } catch { + // Process may already be exiting. + } + session.activeProcess = null + } + + const destroy = (async (): Promise => { + try { + // Why: each tab has its own named session — close without --session leaves this tab's daemon running. + // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. + await this.runAgentBrowserRaw( + sessionName, + ['--session', sessionName, 'close'], + options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } + ) + } catch { + // Session may already be dead + } + + await session.proxy.stop() + })() + this.pendingSessionDestruction.set(sessionName, destroy) + try { + await destroy + } finally { + this.pendingSessionDestruction.delete(sessionName) + } + } + + protected rejectQueuedCommandsForClosedSession(sessionName: string): void { + const queue = this.commandQueues.get(sessionName) + this.commandQueues.delete(sessionName) + this.processingQueues.delete(sessionName) + if (queue) { + const err = new BrowserError( + 'browser_tab_closed', + 'Tab was closed while commands were queued' + ) + for (const cmd of queue) { + cmd.reject(err) + } + queue.length = 0 + } + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse-commands.ts b/src/main/browser/agent-browser-bridge-mouse-commands.ts new file mode 100644 index 00000000000..36697978748 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse-commands.ts @@ -0,0 +1,206 @@ +import type { BrowserMouseModifier } from './agent-browser-bridge-types' +import { BrowserError } from './cdp-bridge' +import { + normalizeCdpMouseButton, + cdpMouseButtonMask, + cdpMouseModifierMask, + resolveMobileTouchClickPoint +} from './agent-browser-bridge-mouse' +import { acquireElectronDebugger } from './electron-debugger-lease' +import { AgentBrowserBridgeInputCommands } from './agent-browser-bridge-input-commands' + +export abstract class AgentBrowserBridgeMouseCommands extends AgentBrowserBridgeInputCommands { + // ── Mouse commands ── + + async mouseMove( + x: number, + y: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) + }) + } + + async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'down'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseClick( + x: number, + y: number, + button?: string, + worktreeId?: string, + browserPageId?: string, + radius?: number, + modifiers?: BrowserMouseModifier[] + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + browserPageId, + async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc || wc.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${target.browserPageId} is no longer available` + ) + } + const cdpButton = normalizeCdpMouseButton(button) + const buttons = cdpMouseButtonMask(cdpButton) + const cdpModifiers = cdpMouseModifierMask(modifiers) + const lease = acquireElectronDebugger(wc) + try { + wc.focus() + const point = + cdpButton === 'left' + ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. + await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) + : { x, y, adjusted: false, handled: false } + // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. + // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. + if (!point.handled) { + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: point.x, + y: point.y, + button: cdpButton, + buttons, + modifiers: cdpModifiers, + clickCount: 1 + }) + await wc.debugger.sendCommand('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: point.x, + y: point.y, + button: cdpButton, + buttons: 0, + modifiers: cdpModifiers, + clickCount: 1 + }) + } + return { + clicked: { + x: point.x, + y: point.y, + button: cdpButton, + adjusted: point.adjusted, + handled: point.handled + } + } + } finally { + lease.release() + } + }, + { ensureSession: false } + ) + } + + async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'up'] + if (button) { + args.push(button) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async mouseWheel( + dy: number, + dx?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['mouse', 'wheel', String(dy)] + if (dx != null) { + args.push(String(dx)) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Find (semantic locators) ── + + async find( + locator: string, + value: string, + action: string, + text?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['find', locator, value, action] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + // ── Set commands ── + + async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'device', name]) + }) + } + + async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'offline'] + if (state) { + args.push(state) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async setHeaders( + headersJson: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) + }) + } + + async setCredentials( + user: string, + pass: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) + }) + } + + async setMedia( + colorScheme?: string, + reducedMotion?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['set', 'media'] + if (colorScheme) { + args.push(colorScheme) + } + if (reducedMotion) { + args.push(reducedMotion) + } + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-mouse.ts b/src/main/browser/agent-browser-bridge-mouse.ts new file mode 100644 index 00000000000..db2ba55d150 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-mouse.ts @@ -0,0 +1,195 @@ +import type { WebContents } from 'electron' +import type { BrowserMouseModifier } from './agent-browser-bridge-types' + +type CdpMouseButton = 'left' | 'middle' | 'right' + +type BrowserClickPoint = { + x: number + y: number + adjusted: boolean + handled: boolean +} + +export function normalizeCdpMouseButton(button?: string): CdpMouseButton { + return button === 'middle' || button === 'right' ? button : 'left' +} + +export function cdpMouseButtonMask(button: CdpMouseButton): number { + if (button === 'right') { + return 2 + } + if (button === 'middle') { + return 4 + } + return 1 +} + +export function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { + if (!modifiers || modifiers.length === 0) { + return 0 + } + let mask = 0 + for (const modifier of modifiers) { + if (modifier === 'alt') { + mask |= 1 + } else if (modifier === 'ctrl') { + mask |= 2 + } else if (modifier === 'cmd') { + mask |= 4 + } else if (modifier === 'shift') { + mask |= 8 + } + } + return mask +} + +export function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { + const point = value && typeof value === 'object' ? (value as Record) : null + const x = point?.x + const y = point?.y + if ( + typeof x !== 'number' || + !Number.isFinite(x) || + typeof y !== 'number' || + !Number.isFinite(y) + ) { + return fallback + } + return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } +} + +export function mobileTouchClickExpression( + x: number, + y: number, + radius: number, + allowDomActivation: boolean +): string { + return `(() => { + const inputX = ${JSON.stringify(x)}; + const inputY = ${JSON.stringify(y)}; + const radius = ${JSON.stringify(radius)}; + const allowDomActivation = ${JSON.stringify(allowDomActivation)}; + const selector = [ + 'a[href]', + 'button', + 'input', + 'textarea', + 'select', + 'summary', + 'label', + '[role="button"]', + '[role="link"]', + '[role="menuitem"]', + '[role="tab"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[onclick]', + '[tabindex]:not([tabindex="-1"])' + ].join(','); + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + const isUsable = (el) => { + const rect = el.getBoundingClientRect(); + const style = window.getComputedStyle(el); + return rect.width > 0 && rect.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.pointerEvents !== 'none'; + }; + const dispatchClick = (target, clickX, clickY) => { + try { + if (typeof target.focus === 'function') { + target.focus({ preventScroll: true }); + } + } catch { + try { target.focus(); } catch {} + } + if (typeof target.click === 'function') { + target.click(); + return true; + } + const init = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: clickX, + clientY: clickY, + screenX: clickX, + screenY: clickY, + button: 0, + buttons: 1 + }; + try { + if (typeof PointerEvent === 'function') { + target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); + target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); + } + } catch {} + target.dispatchEvent(new MouseEvent('mousedown', init)); + target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); + target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); + return true; + }; + const clickableFor = (el) => { + for (let node = el; node && node.nodeType === 1; node = node.parentElement) { + if (node.matches(selector)) return node; + if (window.getComputedStyle(node).cursor === 'pointer') return node; + } + return null; + }; + const offsets = [[0, 0]]; + for (const distance of [radius * 0.45, radius, radius * 1.35]) { + for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, + Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { + offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); + } + } + let best = null; + for (const [dx, dy] of offsets) { + const px = inputX + dx; + const py = inputY + dy; + if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; + for (const el of document.elementsFromPoint(px, py)) { + const target = clickableFor(el); + if (!target || !isUsable(target)) continue; + const rect = target.getBoundingClientRect(); + const clickX = clamp(inputX, rect.left + 1, rect.right - 1); + const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); + const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; + if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; + break; + } + } + if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { + return { x: best.x, y: best.y, adjusted: true, handled: true }; + } + if (best) { + return { x: best.x, y: best.y, adjusted: true, handled: false }; + } + return { x: inputX, y: inputY, adjusted: false, handled: false }; + })()` +} + +export async function resolveMobileTouchClickPoint( + dbg: WebContents['debugger'], + x: number, + y: number, + radius: number | undefined, + allowDomActivation: boolean +): Promise { + const fallback = { x, y, adjusted: false, handled: false } + if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { + return fallback + } + try { + const result = await dbg.sendCommand('Runtime.evaluate', { + expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), + returnByValue: true, + silent: true + }) + const raw = result && typeof result === 'object' ? (result as Record) : null + const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null + return readClickPoint((evaluated as Record | null)?.value, fallback) + } catch { + return fallback + } +} diff --git a/src/main/browser/agent-browser-bridge-process.ts b/src/main/browser/agent-browser-bridge-process.ts new file mode 100644 index 00000000000..363721099d9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-process.ts @@ -0,0 +1,183 @@ +import { app } from 'electron' +import { existsSync, accessSync, chmodSync, constants } from 'node:fs' +import { join } from 'node:path' +import { platform, arch } from 'node:os' +import type { WebContents } from 'electron' +import { BrowserError } from './cdp-bridge' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import { EMBEDDED_NAVIGATION_TIMEOUT_MS } from './agent-browser-bridge-types' + +export function agentBrowserNativeName(): string { + const ext = process.platform === 'win32' ? '.exe' : '' + return `agent-browser-${platform()}-${arch()}${ext}` +} + +export function resolveAgentBrowserBinary(): string { + // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. + const bundledResourcesPath = + process.resourcesPath ?? + (process.platform === 'darwin' + ? join(app.getPath('exe'), '..', '..', 'Resources') + : join(app.getPath('exe'), '..', 'resources')) + const bundled = join(bundledResourcesPath, agentBrowserNativeName()) + if (existsSync(bundled)) { + return bundled + } + + // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. + const nmBin = join( + app.getAppPath(), + 'node_modules', + 'agent-browser', + 'bin', + agentBrowserNativeName() + ) + if (existsSync(nmBin)) { + if (process.platform !== 'win32') { + try { + accessSync(nmBin, constants.X_OK) + } catch { + chmodSync(nmBin, 0o755) + } + } + return nmBin + } + + // Last resort: assume it's on PATH + return 'agent-browser' +} + +// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. +export function parseShellArgs(input: string): string[] { + const args: string[] = [] + let current = '' + let inDouble = false + let inSingle = false + + for (let i = 0; i < input.length; i++) { + const ch = input[i] + if (ch === '"' && !inSingle) { + inDouble = !inDouble + } else if (ch === "'" && !inDouble) { + inSingle = !inSingle + } else if (ch === ' ' && !inDouble && !inSingle) { + if (current) { + args.push(current) + current = '' + } + } else { + current += ch + } + } + if (current) { + args.push(current) + } + return args +} + +export function stripAgentBrowserTargetArgs(args: string[]): string[] { + const stripped: string[] = [] + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === '--cdp' || arg === '--session') { + index++ + continue + } + if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { + continue + } + stripped.push(arg) + } + return stripped +} + +// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. +export function classifyErrorCode(message: string): string { + if (/unknown ref|ref not found|element not found: @e/i.test(message)) { + return 'browser_stale_ref' + } + return 'browser_error' +} + +export function isAbortedNavigationError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + const { code, errno } = error as { code?: unknown; errno?: unknown } + return code === 'ERR_ABORTED' || errno === -3 +} + +export function isWebContentsLoading(wc: WebContents): boolean { + try { + return wc.isLoading() + } catch { + // Why: destruction races are resolved against the authoritative page registration after the wait. + return false + } +} + +export function waitForAbortedNavigationReplacement( + wc: WebContents, + browserPageId: string, + timeoutMs: number +): Promise { + if (!isWebContentsLoading(wc)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + let settled = false + let timeout: ReturnType | null = null + const finish = (error?: BrowserError): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-stop-loading', onDidStopLoading) + wc.removeListener('destroyed', onDestroyed) + if (timeout) { + clearTimeout(timeout) + } + if (error) { + reject(error) + } else { + resolve() + } + } + const onDidStopLoading = (): void => finish() + const onDestroyed = (): void => finish() + + wc.on('did-stop-loading', onDidStopLoading) + wc.on('destroyed', onDestroyed) + timeout = setTimeout( + () => + finish( + new BrowserError( + 'browser_error', + `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` + ) + ), + timeoutMs + ) + timeout.unref?.() + + // Why: the replacement can finish between loadURL rejecting and listener attachment. + if (!isWebContentsLoading(wc)) { + finish() + } + }) +} + +export function isTabClosedTransportError(message: string): boolean { + return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( + message + ) +} + +export function pageUnavailableMessageForSession(sessionName: string): string { + const prefix = ORCA_TAB_SESSION_PREFIX + const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null + return browserPageId + ? `Browser page ${browserPageId} is no longer available` + : 'Browser tab is no longer available' +} diff --git a/src/main/browser/agent-browser-bridge-queue.ts b/src/main/browser/agent-browser-bridge-queue.ts new file mode 100644 index 00000000000..74cdb50c14a --- /dev/null +++ b/src/main/browser/agent-browser-bridge-queue.ts @@ -0,0 +1,174 @@ +import type { BrowserTabSwitchResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { AgentBrowserBridgeShutdown } from './agent-browser-bridge-shutdown' +import { ORCA_TAB_SESSION_PREFIX } from './agent-browser-orphan-sweep' +import type { + EnqueueTargetedCommandOptions, + ResolvedBrowserCommandTarget +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeQueue extends AgentBrowserBridgeShutdown { + // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. + async tabSwitch( + index: number | undefined, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueCommand(worktreeId, async () => { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. + const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) + let switchedIndex = index ?? -1 + let resolvedPageId = browserPageId + if (resolvedPageId) { + switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) + } + if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { + const targetLabel = + resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` + throw new BrowserError( + 'browser_tab_not_found', + `${targetLabel} out of range (0-${liveEntries.length - 1})` + ) + } + const [tabId, wcId] = liveEntries[switchedIndex] + this.activeWebContentsId = wcId + // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. + const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) + // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. + if (owningWorktreeId) { + this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) + } + this.options.onTabsChanged?.(owningWorktreeId ?? undefined) + return { switched: switchedIndex, browserPageId: tabId } + }) + } + // ── Internal ── + + protected async enqueueCommand( + worktreeId: string | undefined, + execute: (sessionName: string) => Promise + ): Promise { + return this.enqueueTargetedCommand( + worktreeId, + undefined, + async (sessionName) => execute(sessionName), + { ensureVisible: false } + ) + } + + protected async enqueueTargetedCommand( + worktreeId: string | undefined, + browserPageId: string | undefined, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions = {} + ): Promise { + this.assertCommandAdmission() + const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) + const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` + + if (options.ensureSession !== false) { + await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) + } + this.assertCommandAdmission() + + return new Promise((resolve, reject) => { + let queue = this.commandQueues.get(sessionName) + if (!queue) { + queue = [] + this.commandQueues.set(sessionName, queue) + } + queue.push({ + execute: (() => + this.executeWithVisibleTarget( + sessionName, + worktreeId, + target, + execute, + options + )) as () => Promise, + resolve: resolve as (value: unknown) => void, + reject + }) + this.processQueue(sessionName) + }) + } + + protected async executeWithVisibleTarget( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, + options: EnqueueTargetedCommandOptions + ): Promise { + if (options.ensureVisible === false) { + return execute(sessionName, target) + } + + // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. + const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) + try { + const visibleTarget = await this.refreshTargetAfterAutomationVisibility( + sessionName, + worktreeId, + target, + options + ) + return await execute(sessionName, visibleTarget) + } finally { + restore() + } + } + + protected async refreshTargetAfterAutomationVisibility( + sessionName: string, + worktreeId: string | undefined, + target: ResolvedBrowserCommandTarget, + options: EnqueueTargetedCommandOptions + ): Promise { + const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) + if (visibleTarget.webContentsId === target.webContentsId) { + return visibleTarget + } + + if (this.activeWebContentsId === target.webContentsId) { + this.activeWebContentsId = visibleTarget.webContentsId + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { + this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) + } + + // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. + await this.restartSessionForTarget( + sessionName, + visibleTarget.browserPageId, + visibleTarget.webContentsId, + { recreate: options.ensureSession !== false } + ) + + return visibleTarget + } + + protected async processQueue(sessionName: string): Promise { + if (this.processingQueues.has(sessionName)) { + return + } + this.processingQueues.add(sessionName) + + const queue = this.commandQueues.get(sessionName) + while (queue && queue.length > 0) { + const cmd = queue.shift()! + try { + const result = await cmd.execute() + cmd.resolve(result) + } catch (error) { + cmd.reject(error) + } + } + + if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { + this.commandQueues.delete(sessionName) + } + this.processingQueues.delete(sessionName) + } +} diff --git a/src/main/browser/agent-browser-bridge-raw-process.ts b/src/main/browser/agent-browser-bridge-raw-process.ts new file mode 100644 index 00000000000..bdc5aadbbd9 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-raw-process.ts @@ -0,0 +1,108 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { BrowserError } from './cdp-bridge' +import { classifyErrorCode } from './agent-browser-bridge-process' +import { AgentBrowserBridgeExecution } from './agent-browser-bridge-execution' +import { + CONSECUTIVE_TIMEOUT_LIMIT, + EXEC_TIMEOUT_MS, + type AgentBrowserExecOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeRawProcess extends AgentBrowserBridgeExecution { + protected abstract destroySession( + sessionName: string, + options?: { closeTimeoutMs?: number } + ): Promise + + protected runAgentBrowserRaw( + sessionName: string, + args: string[], + execOptions?: AgentBrowserExecOptions + ): Promise { + return new Promise((resolve, reject) => { + const session = this.sessions.get(sessionName) + let child: ChildProcess | null = null + child = execFile( + this.agentBrowserBin, + args, + // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). + { + timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, + maxBuffer: 50 * 1024 * 1024, + // Why windowsHide: see the stale-session close above -- every + // agent-browser invocation would otherwise flash a console (#14543). + windowsHide: true, + env: execOptions?.envOverrides + ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } + : this.agentBrowserEnv + }, + (error, stdout, stderr) => { + if (session && session.activeProcess === child) { + session.activeProcess = null + } + if (child && this.cancelledProcesses.has(child)) { + this.cancelledProcesses.delete(child) + reject( + new BrowserError('browser_tab_closed', 'Tab was closed while command was running') + ) + return + } + + const liveSession = this.sessions.get(sessionName) + + if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { + if (execOptions?.timeoutError) { + reject(execOptions.timeoutError) + return + } + if (liveSession) { + liveSession.consecutiveTimeouts++ + if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { + // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate + this.destroySession(sessionName) + } + } + reject(new BrowserError('browser_error', 'Browser command timed out')) + return + } + + if (liveSession) { + liveSession.consecutiveTimeouts = 0 + } + + if (error) { + // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. + if (stdout) { + try { + const parsed = JSON.parse(stdout) + if (parsed.error) { + const code = classifyErrorCode(parsed.error) + reject( + this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) + ) + return + } + } catch { + // stdout not valid JSON — fall through to stderr/error.message + } + } + const message = stderr || error.message + const code = classifyErrorCode(message) + reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) + return + } + + resolve(stdout) + } + ) + if (session) { + session.activeProcess = child + } + if (execOptions?.stdinText !== undefined && child?.stdin) { + // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. + child.stdin.on('error', () => {}) + child.stdin.end(execOptions.stdinText) + } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-result.ts b/src/main/browser/agent-browser-bridge-result.ts new file mode 100644 index 00000000000..9366389c2fd --- /dev/null +++ b/src/main/browser/agent-browser-bridge-result.ts @@ -0,0 +1,29 @@ +import { classifyErrorCode } from './agent-browser-bridge-process' + +export function translateResult( + stdout: string +): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { + let parsed: { success?: boolean; data?: unknown; error?: string } + try { + parsed = JSON.parse(stdout) + } catch { + return { + ok: false, + error: { + code: 'browser_error', + message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` + } + } + } + if (parsed.success) { + return { ok: true, result: parsed.data } + } + const message = parsed.error ?? 'Unknown browser error' + return { + ok: false, + error: { + code: classifyErrorCode(message), + message + } + } +} diff --git a/src/main/browser/agent-browser-bridge-shutdown.ts b/src/main/browser/agent-browser-bridge-shutdown.ts new file mode 100644 index 00000000000..4b53cc74df8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-shutdown.ts @@ -0,0 +1,40 @@ +import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' +import { sweepOrphanedAgentBrowserSessions } from './agent-browser-orphan-sweep' +import { AgentBrowserBridgeLifecycle } from './agent-browser-bridge-lifecycle' +import { + AGENT_BROWSER_CLEANUP_CONCURRENCY, + type AgentBrowserCleanupOptions +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeShutdown extends AgentBrowserBridgeLifecycle { + // ── Session lifecycle ── + + // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with + // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. + async sweepOrphanedSessions(): Promise { + return sweepOrphanedAgentBrowserSessions({ + binaryPath: this.agentBrowserBin, + env: this.agentBrowserEnv, + ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, + isSessionLive: (sessionName) => + this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) + }) + } + + async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { + this.shutdownStarted = true + // Why the union: a session still being created has already spawned its daemon but is not in + // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). + const sessionNames = new Set([ + ...this.sessions.keys(), + ...this.pendingSessionCreation.keys(), + ...this.pendingSessionDestruction.keys() + ]) + await mapSettledWithConcurrency( + [...sessionNames], + AGENT_BROWSER_CLEANUP_CONCURRENCY, + (sessionName) => this.destroySession(sessionName, options) + ) + this.pendingInterceptRestore.clear() + } +} diff --git a/src/main/browser/agent-browser-bridge-state-commands.ts b/src/main/browser/agent-browser-bridge-state-commands.ts new file mode 100644 index 00000000000..4e03055da57 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state-commands.ts @@ -0,0 +1,266 @@ +import type { + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserCookieDeleteResult, + BrowserCookie, + BrowserViewportResult, + BrowserGeolocationResult, + BrowserInterceptEnableResult, + BrowserInterceptDisableResult, + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserConsoleResult, + BrowserNetworkLogResult +} from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import { parseShellArgs, stripAgentBrowserTargetArgs } from './agent-browser-bridge-process' +import { AgentBrowserBridgeInteractionCommands } from './agent-browser-bridge-interaction-commands' + +export abstract class AgentBrowserBridgeStateCommands extends AgentBrowserBridgeInteractionCommands { + // ── Cookie commands ── + + async cookieGet( + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'cookies', + 'get' + ])) as BrowserCookieGetResult + }) + } + + async cookieSet( + cookie: Partial, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] + if (cookie.domain) { + args.push('--domain', cookie.domain) + } + if (cookie.path) { + args.push('--path', cookie.path) + } + if (cookie.secure) { + args.push('--secure') + } + if (cookie.httpOnly) { + args.push('--httpOnly') + } + if (cookie.sameSite) { + args.push('--sameSite', cookie.sameSite) + } + if (cookie.expires != null) { + args.push('--expires', String(cookie.expires)) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult + }) + } + + async cookieDelete( + name?: string, + domain?: string, + _url?: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['cookies', 'clear'] + if (name) { + args.push('--name', name) + } + if (domain) { + args.push('--domain', domain) + } + return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult + }) + } + + // ── Viewport / emulation commands ── + + async setViewport( + width: number, + height: number, + scale = 1, + mobile = false, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') + } + const dbg = wc.debugger + if (!dbg.isAttached()) { + throw new BrowserError('browser_error', 'Debugger not attached') + } + + // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width, + height, + deviceScaleFactor: scale, + mobile + }) + // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. + await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( + () => {} + ) + + return { + width, + height, + deviceScaleFactor: scale, + mobile + } + }) + } + + async setGeolocation( + lat: number, + lon: number, + _accuracy?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'set', + 'geo', + String(lat), + String(lon) + ])) as BrowserGeolocationResult + }) + } + + // ── Network interception commands ── + + async interceptEnable( + patterns?: string[], + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: agent-browser uses "network route " to intercept. Route each pattern individually. + const urlPattern = patterns?.[0] ?? '**/*' + const args = ['network', 'route', urlPattern] + const result = (await this.execAgentBrowser( + sessionName, + args + )) as BrowserInterceptEnableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = patterns ?? ['*'] + } + return result + }) + } + + async interceptDisable( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'unroute' + ])) as BrowserInterceptDisableResult + const session = this.sessions.get(sessionName) + if (session) { + this.pendingInterceptRestore.delete(sessionName) + session.activeInterceptPatterns = [] + } + return result + }) + } + + async interceptList( + worktreeId?: string, + browserPageId?: string + ): Promise<{ requests: unknown[] }> { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { + requests: unknown[] + } + }) + } + + // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. + + // ── Capture commands ── + + async captureStart( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'start' + ])) as BrowserCaptureStartResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = true + } + return result + }) + } + + async captureStop( + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const result = (await this.execAgentBrowser(sessionName, [ + 'network', + 'har', + 'stop' + ])) as BrowserCaptureStopResult + const session = this.sessions.get(sessionName) + if (session) { + session.activeCapture = false + } + return result + }) + } + + async consoleLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult + }) + } + + async networkLog( + _limit?: number, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, [ + 'network', + 'requests' + ])) as BrowserNetworkLogResult + }) + } + + // ── Generic passthrough ── + + async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. + const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) + return await this.execAgentBrowser(sessionName, args) + }) + } +} diff --git a/src/main/browser/agent-browser-bridge-state.ts b/src/main/browser/agent-browser-bridge-state.ts new file mode 100644 index 00000000000..c23cd8e5ac5 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-state.ts @@ -0,0 +1,64 @@ +import { app } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { BrowserManager } from './browser-manager' +import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' +import { resolveAgentBrowserBinary } from './agent-browser-bridge-process' +import type { + AgentBrowserBridgeOptions, + QueuedCommand, + SessionState +} from './agent-browser-bridge-types' + +export abstract class AgentBrowserBridgeState { + // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. + protected readonly activeWebContentsPerWorktree = new Map() + protected activeWebContentsId: number | null = null + protected readonly sessions = new Map() + protected readonly commandQueues = new Map() + protected readonly processingQueues = new Set() + // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. + protected screenshotTurn: Promise = Promise.resolve() + protected readonly agentBrowserBin: string + protected readonly agentBrowserEnv: NodeJS.ProcessEnv + protected readonly ownsAgentBrowserSocketDirectory: boolean + // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. + protected readonly agentBrowserIdleTimeoutMs: number | null + // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. + protected readonly pendingInterceptRestore = new Map() + // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. + protected readonly pendingSessionCreation = new Map>() + // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. + protected readonly pendingSessionDestruction = new Map>() + protected readonly cancelledProcesses = new WeakSet() + protected shutdownStarted = false + + constructor( + protected readonly browserManager: BrowserManager, + protected readonly options: AgentBrowserBridgeOptions = {} + ) { + this.agentBrowserBin = resolveAgentBrowserBinary() + const processEnvironment = createAgentBrowserProcessEnvironment({ + inheritedEnv: process.env, + platform: process.platform, + userDataPath: app.getPath('userData') + }) + this.agentBrowserEnv = processEnvironment.env + this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory + const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) + this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null + } + + protected resolveTabIdSafe(webContentsId: number): string | null { + return this.browserManager.getTabIdForWebContentsId(webContentsId) + } + + protected getWebContents(webContentsId: number): Electron.WebContents | null { + try { + const { webContents } = require('electron') + const target = webContents.fromId(webContentsId) + return target && !target.isDestroyed() ? target : null + } catch { + return null + } + } +} diff --git a/src/main/browser/agent-browser-bridge-tabs.ts b/src/main/browser/agent-browser-bridge-tabs.ts new file mode 100644 index 00000000000..0af381f6d96 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-tabs.ts @@ -0,0 +1,232 @@ +import type { BrowserTabInfo, BrowserTabListResult } from '../../shared/runtime-types' +import { BrowserError } from './cdp-bridge' +import type { ResolvedBrowserCommandTarget } from './agent-browser-bridge-types' +import { AgentBrowserBridgeState } from './agent-browser-bridge-state' + +export abstract class AgentBrowserBridgeTabs extends AgentBrowserBridgeState { + // ── Tab tracking ── + + setActiveTab(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + + protected selectFallbackActiveWebContents( + worktreeId: string, + excludedWebContentsId?: number + ): number | null { + for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { + if (wcId === excludedWebContentsId) { + continue + } + if (this.getWebContents(wcId)) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + return wcId + } + } + this.activeWebContentsPerWorktree.delete(worktreeId) + return null + } + + getActiveWebContentsId(): number | null { + return this.activeWebContentsId + } + + getPageInfo( + worktreeId?: string, + browserPageId?: string + ): { browserPageId: string; url: string; title: string } | null { + try { + const target = this.resolveCommandTarget(worktreeId, browserPageId) + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + return null + } + return { + browserPageId: target.browserPageId, + url: wc.getURL() ?? '', + title: wc.getTitle() ?? '' + } + } catch { + return null + } + } + onTabChanged(webContentsId: number, worktreeId?: string): void { + this.activeWebContentsId = webContentsId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) + } + this.options.onTabsChanged?.(worktreeId) + } + getRegisteredTabs(worktreeId?: string): Map { + const all = this.browserManager.getWebContentsIdByTabId() + if (!worktreeId) { + return all + } + + const filtered = new Map() + for (const [tabId, wcId] of all) { + if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { + filtered.set(tabId, wcId) + } + } + return filtered + } + + // ── Tab management ── + + tabList(worktreeId?: string): BrowserTabListResult { + const tabs = this.getRegisteredTabs(worktreeId) + // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. + let activeWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + const result: BrowserTabInfo[] = [] + let index = 0 + let firstLiveWcId: number | null = null + for (const [tabId, wcId] of tabs) { + const wc = this.getWebContents(wcId) + if (!wc) { + this.browserManager.unregisterGuest(tabId) + continue + } + if (firstLiveWcId === null) { + firstLiveWcId = wcId + } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) + result.push({ + browserPageId: tabId, + index: index++, + // Why: failed WebContents report chrome-error://, not the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', + title: wc.getTitle() ?? '', + active: wcId === activeWcId, + loadError, + certificateFailure + }) + } + // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. + if (activeWcId == null && firstLiveWcId !== null) { + activeWcId = firstLiveWcId + if (result.length > 0) { + result[0].active = true + } + } + return { tabs: result } + } + getActivePageId(worktreeId?: string, browserPageId?: string): string | null { + try { + return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId + } catch { + return null + } + } + + protected resolveCommandTarget( + worktreeId?: string, + browserPageId?: string, + requireScopedTarget = false + ): ResolvedBrowserCommandTarget { + if (!browserPageId) { + return requireScopedTarget + ? this.resolveScopedActiveTab(worktreeId) + : this.resolveActiveTab(worktreeId) + } + + const tabs = this.getRegisteredTabs(worktreeId) + const webContentsId = tabs.get(browserPageId) + if (webContentsId == null) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + + if (!this.getWebContents(webContentsId)) { + this.browserManager.unregisterGuest(browserPageId) + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} is no longer available` + ) + } + + return { browserPageId, webContentsId } + } + + protected resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + const tabs = this.getRegisteredTabs(worktreeId) + + if (tabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + + // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. + const preferredWcId = + (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId + + if (preferredWcId != null) { + for (const [tabId, wcId] of tabs) { + if (wcId === preferredWcId && this.getWebContents(wcId)) { + return { browserPageId: tabId, webContentsId: wcId } + } + if (wcId === preferredWcId) { + this.browserManager.unregisterGuest(tabId) + if (this.activeWebContentsId === wcId) { + this.activeWebContentsId = null + } + if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { + this.activeWebContentsPerWorktree.delete(worktreeId) + } + } + } + } + + // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. + for (const [tabId, wcId] of tabs) { + if (this.getWebContents(wcId)) { + this.activeWebContentsId = wcId + if (worktreeId) { + this.activeWebContentsPerWorktree.set(worktreeId, wcId) + } + return { browserPageId: tabId, webContentsId: wcId } + } + this.browserManager.unregisterGuest(tabId) + } + + throw new BrowserError( + 'browser_no_tab', + 'No live browser tab available — all registered tabs have been destroyed' + ) + } + + // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. + protected resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { + if (worktreeId) { + return this.resolveActiveTab(worktreeId) + } + + const worktreesWithLiveTabs = new Set() + for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { + if (this.getWebContents(wcId)) { + worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) + } + } + + if (worktreesWithLiveTabs.size === 0) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + if (worktreesWithLiveTabs.size > 1) { + throw new BrowserError( + 'browser_target_ambiguous', + 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' + ) + } + + const [onlyWorktreeId] = worktreesWithLiveTabs + return this.resolveActiveTab(onlyWorktreeId) + } +} diff --git a/src/main/browser/agent-browser-bridge-types.ts b/src/main/browser/agent-browser-bridge-types.ts new file mode 100644 index 00000000000..a89c9fcbca8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-types.ts @@ -0,0 +1,65 @@ +import type { ChildProcess } from 'node:child_process' +import type { CdpWsProxy } from './cdp-ws-proxy' +import type { BrowserError } from './cdp-bridge' + +// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. +export const EXEC_TIMEOUT_MS = 90_000 +export const CONSECUTIVE_TIMEOUT_LIMIT = 3 +export const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 +export const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 +// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. +export const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 +export const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 +export const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 +export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 +export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES + +export type SessionState = { + proxy: CdpWsProxy + cdpEndpoint: string + initialized: boolean + consecutiveTimeouts: number + // Why: track active interception patterns so they can be re-enabled after session restart + activeInterceptPatterns: string[] + activeCapture: boolean + // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. + lastCommandAt: number + // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. + webContentsId: number + activeProcess: ChildProcess | null +} + +export type QueuedCommand = { + execute: () => Promise + resolve: (value: unknown) => void + reject: (reason: unknown) => void +} + +export type ResolvedBrowserCommandTarget = { + browserPageId: string + webContentsId: number +} + +export type AgentBrowserCleanupOptions = { + closeTimeoutMs?: number +} + +export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + +export type AgentBrowserExecOptions = { + envOverrides?: NodeJS.ProcessEnv + timeoutMs?: number + timeoutError?: BrowserError + stdinText?: string +} + +export type EnqueueTargetedCommandOptions = { + ensureSession?: boolean + ensureVisible?: boolean + // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). + requireScopedTarget?: boolean +} + +export type AgentBrowserBridgeOptions = { + onTabsChanged?: (worktreeId?: string) => void +} diff --git a/src/main/browser/agent-browser-bridge-utility-commands.ts b/src/main/browser/agent-browser-bridge-utility-commands.ts new file mode 100644 index 00000000000..f3697168cd8 --- /dev/null +++ b/src/main/browser/agent-browser-bridge-utility-commands.ts @@ -0,0 +1,175 @@ +import { BrowserError } from './cdp-bridge' +import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' +import { AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES } from './agent-browser-bridge-types' +import type { BrowserBackResult, BrowserReloadResult } from '../../shared/runtime-types' +import { AgentBrowserBridgeMouseCommands } from './agent-browser-bridge-mouse-commands' + +export abstract class AgentBrowserBridgeUtilityCommands extends AgentBrowserBridgeMouseCommands { + // ── Clipboard commands ── + + async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) + }) + } + + async clipboardWrite( + text: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + await assertClipboardTextWriteWithinLimitWithYield(text, { + maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES + }) + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) + }) + } + + // ── Dialog commands ── + + async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + const args = ['dialog', 'accept'] + if (text) { + args.push(text) + } + return await this.execAgentBrowser(sessionName, args) + }) + } + + async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) + }) + } + + // ── Storage commands ── + + async storageLocalGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) + }) + } + + async storageLocalSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) + }) + } + + async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) + }) + } + + async storageSessionGet( + key: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) + }) + } + + async storageSessionSet( + key: string, + value: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) + }) + } + + async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) + }) + } + + // ── Download command ── + + async download( + selector: string, + path: string, + worktreeId?: string, + browserPageId?: string + ): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['download', selector, path]) + }) + } + + // ── Highlight command ── + + async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return await this.execAgentBrowser(sessionName, ['highlight', selector]) + }) + } + + async back(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult + }) + } + + async forward(worktreeId?: string, browserPageId?: string): Promise { + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { + return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult + }) + } + + async reload(worktreeId?: string, browserPageId?: string): Promise { + // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. + return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { + const wc = this.getWebContents(target.webContentsId) + if (!wc) { + throw new BrowserError('browser_no_tab', 'Tab is no longer available') + } + wc.reload() + await new Promise((resolve) => { + let settled = false + let fallbackTimer: ReturnType | null = null + + const finish = (): void => { + if (settled) { + return + } + settled = true + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + if (fallbackTimer) { + clearTimeout(fallbackTimer) + fallbackTimer = null + } + resolve() + } + const onFinish = (): void => finish() + const onFail = (): void => finish() + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. + fallbackTimer = setTimeout(finish, 10_000) + if (typeof fallbackTimer.unref === 'function') { + fallbackTimer.unref() + } + }) + return { url: wc.getURL(), title: wc.getTitle() } + }) + } +} diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 01f77b45451..aeeb64385a3 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -1,2884 +1,20 @@ -/* eslint-disable max-lines */ -import { execFile, type ChildProcess } from 'node:child_process' -import { existsSync, accessSync, chmodSync, readFileSync, constants } from 'node:fs' -import { join } from 'node:path' -import { platform, arch } from 'node:os' -import { app, type WebContents } from 'electron' -import { CdpWsProxy } from './cdp-ws-proxy' -import { captureFullPageScreenshot } from './cdp-screenshot' -import { acquireElectronDebugger } from './electron-debugger-lease' -import type { BrowserManager } from './browser-manager' -import { BrowserError } from './cdp-bridge' -import type { - BrowserTabInfo, - BrowserTabListResult, - BrowserTabSwitchResult, - BrowserSnapshotResult, - BrowserClickResult, - BrowserGotoResult, - BrowserFillResult, - BrowserTypeResult, - BrowserSelectResult, - BrowserScrollResult, - BrowserBackResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserEvalResult, - BrowserHoverResult, - BrowserDragResult, - BrowserUploadResult, - BrowserWaitResult, - BrowserCheckResult, - BrowserFocusResult, - BrowserClearResult, - BrowserSelectAllResult, - BrowserKeypressResult, - BrowserPdfResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserCookieDeleteResult, - BrowserViewportResult, - BrowserGeolocationResult, - BrowserInterceptEnableResult, - BrowserInterceptDisableResult, - BrowserConsoleResult, - BrowserNetworkLogResult, - BrowserCaptureStartResult, - BrowserCaptureStopResult, - BrowserCookie -} from '../../shared/runtime-types' -import { assertClipboardTextWriteWithinLimitWithYield } from '../../shared/clipboard-text' -import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' -import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency' -import { iterateBrowserTextInsertionChunks } from './browser-text-insertion' -import { createAgentBrowserProcessEnvironment } from './agent-browser-process-environment' -import { - ORCA_TAB_SESSION_PREFIX, - sweepOrphanedAgentBrowserSessions -} from './agent-browser-orphan-sweep' - -// Why: must exceed agent-browser's internal timeouts (goto 30s, wait 60s) so the bridge never kills a command before its own timeout fires. -const EXEC_TIMEOUT_MS = 90_000 -const CONSECUTIVE_TIMEOUT_LIMIT = 3 -const WAIT_PROCESS_TIMEOUT_GRACE_MS = 1_000 -const STALE_SESSION_CLOSE_TIMEOUT_MS = 3_000 -// Why separate from EXEC_TIMEOUT_MS: a close is a member of the 20s will-quit barrier and must finish well inside it. -const AGENT_BROWSER_CLEANUP_TIMEOUT_MS = 5_000 -const AGENT_BROWSER_CLEANUP_CONCURRENCY = 4 -const EMBEDDED_NAVIGATION_TIMEOUT_MS = 30_000 -export const AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES = 8 * 1024 -export const AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES = AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - -type SessionState = { - proxy: CdpWsProxy - cdpEndpoint: string - initialized: boolean - consecutiveTimeouts: number - // Why: track active interception patterns so they can be re-enabled after session restart - activeInterceptPatterns: string[] - activeCapture: boolean - // Why: the daemon retires itself once idle; the gap since the last command is how the bridge notices. - lastCommandAt: number - // Why: verify the tab is alive at execution time, not just enqueue time — queue delay can destroy it in between. - webContentsId: number - activeProcess: ChildProcess | null -} - -type QueuedCommand = { - execute: () => Promise - resolve: (value: unknown) => void - reject: (reason: unknown) => void -} - -type ResolvedBrowserCommandTarget = { - browserPageId: string - webContentsId: number -} - -type AgentBrowserCleanupOptions = { - closeTimeoutMs?: number -} - -export type BrowserMouseModifier = 'cmd' | 'ctrl' | 'alt' | 'shift' - -function focusedValueSetExpression( - valueExpression: string, - options?: { append?: boolean; dispatchEvents?: boolean } -): string { - const nextValue = options?.append - ? ["String(target.value ?? '') + ", valueExpression].join('') - : valueExpression - const dispatchEvents = options?.dispatchEvents - ? " target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true }));" - : '' - return [ - '(() => { const el = document.activeElement; if (el) {', - // Why: ARIA spinbutton wrappers can hold focus while a contained or controlled input owns the value. - " const editableSelector = \"input:not([type='hidden']):not([type='button']):not([type='checkbox']):not([type='radio']):not([type='file']):not([type='image']):not([type='reset']):not([type='submit']), textarea\";", - " const isEditable = (node) => !!node && (node.matches?.(editableSelector) ?? (node.tagName === 'TEXTAREA' || (node.tagName === 'INPUT' && !/^(hidden|button|checkbox|radio|file|image|reset|submit)$/i.test(node.getAttribute?.('type') ?? ''))));", - ' const findEditable = (root) => root?.querySelector?.(editableSelector) ?? null;', - ' let target = el;', - " if (!isEditable(target) && target.getAttribute?.('role') === 'spinbutton') {", - " const controls = target.getAttribute('aria-controls');", - ' if (controls) { for (const id of controls.split(/\\s+/)) { if (!id) continue; const controlled = document.getElementById(id); if (isEditable(controlled)) { target = controlled; break; } const descendant = findEditable(controlled); if (descendant) { target = descendant; break; } } }', - ' if (target === el) { const descendant = findEditable(target); if (descendant) target = descendant; }', - ' }', - " const nativeSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;", - ' const nextValue = ', - nextValue, - '; if (nativeSetter) { nativeSetter.call(target, nextValue); } else { target.value = nextValue; }', - dispatchEvents, - ' } })()' - ].join('') -} - -// Why: rich editors reconcile only real browser edit transactions; a direct-DOM fallback can leave their model stale. -function focusedRichTextEditExpression( - valueExpression: string, - options?: { selectAll?: boolean } -): string { - const selectAll = options?.selectAll ? 'true' : 'false' - return [ - '(() => {', - ' const target = document.activeElement;', - ' const value = ', - valueExpression, - ';', - ` const selectAll = ${selectAll};`, - " const isEditable = target?.isContentEditable === true || /^(|true|plaintext-only)$/i.test(target?.getAttribute?.('contenteditable') ?? 'false');", - " if (!target || target === document.body || !isEditable) { throw new Error('Focused rich-text target is unavailable'); }", - ' if (selectAll) {', - " if (typeof window.getSelection !== 'function') { throw new Error('Rich-text selection is unavailable'); }", - ' const selection = window.getSelection();', - " if (!selection) { throw new Error('Rich-text selection is unavailable'); }", - ' selection.selectAllChildren(target);', - ' }', - " const editCommand = selectAll && value.length === 0 ? 'delete' : 'insertText';", - ' let edited = false;', - ' try {', - ' edited = document.execCommand(editCommand, false, value) === true;', - ' } catch { edited = false; }', - " if (!edited) { throw new Error('Browser rich-text editing command failed'); }", - ' })()' - ].join('') -} - -function isExplicitContentEditableResult(result: unknown): boolean { - const value = - result && typeof result === 'object' ? (result as { value?: unknown }).value : undefined - return typeof value === 'string' && /^(|true|plaintext-only)$/i.test(value) -} - -type AgentBrowserExecOptions = { - envOverrides?: NodeJS.ProcessEnv - timeoutMs?: number - timeoutError?: BrowserError - stdinText?: string -} - -type EnqueueTargetedCommandOptions = { - ensureSession?: boolean - ensureVisible?: boolean - // Why: text-mutating commands must never fall back to the global tab (may be a worktree the user is viewing). - requireScopedTarget?: boolean -} - -type AgentBrowserBridgeOptions = { - onTabsChanged?: (worktreeId?: string) => void -} - -function agentBrowserNativeName(): string { - const ext = process.platform === 'win32' ? '.exe' : '' - return `agent-browser-${platform()}-${arch()}${ext}` -} - -function resolveAgentBrowserBinary(): string { - // Why: use Electron's resourcesPath (not hand-rolled ../resources) so packaged macOS case-sensitive builds resolve the binary. - const bundledResourcesPath = - process.resourcesPath ?? - (process.platform === 'darwin' - ? join(app.getPath('exe'), '..', '..', 'Resources') - : join(app.getPath('exe'), '..', 'resources')) - const bundled = join(bundledResourcesPath, agentBrowserNativeName()) - if (existsSync(bundled)) { - return bundled - } - - // Why: dev mode — resolve from node_modules via app.getAppPath(); __dirname is unreliable after electron-vite bundling. - const nmBin = join( - app.getAppPath(), - 'node_modules', - 'agent-browser', - 'bin', - agentBrowserNativeName() - ) - if (existsSync(nmBin)) { - if (process.platform !== 'win32') { - try { - accessSync(nmBin, constants.X_OK) - } catch { - chmodSync(nmBin, 0o755) - } - } - return nmBin - } - - // Last resort: assume it's on PATH - return 'agent-browser' -} - -// Why: exec commands arrive as one string; split on whitespace but respect quotes so quoted args stay intact. -function parseShellArgs(input: string): string[] { - const args: string[] = [] - let current = '' - let inDouble = false - let inSingle = false - - for (let i = 0; i < input.length; i++) { - const ch = input[i] - if (ch === '"' && !inSingle) { - inDouble = !inDouble - } else if (ch === "'" && !inDouble) { - inSingle = !inSingle - } else if (ch === ' ' && !inDouble && !inSingle) { - if (current) { - args.push(current) - current = '' - } - } else { - current += ch - } - } - if (current) { - args.push(current) - } - return args -} - -function stripAgentBrowserTargetArgs(args: string[]): string[] { - const stripped: string[] = [] - for (let index = 0; index < args.length; index++) { - const arg = args[index] - if (arg === '--cdp' || arg === '--session') { - index++ - continue - } - if (arg.startsWith('--cdp=') || arg.startsWith('--session=')) { - continue - } - stripped.push(arg) - } - return stripped -} - -// Why: agent-browser returns generic errors for stale/unknown refs; map to a specific code so agents can detect and re-snapshot. -function classifyErrorCode(message: string): string { - if (/unknown ref|ref not found|element not found: @e/i.test(message)) { - return 'browser_stale_ref' - } - return 'browser_error' -} - -function isAbortedNavigationError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - const { code, errno } = error as { code?: unknown; errno?: unknown } - return code === 'ERR_ABORTED' || errno === -3 -} - -function isWebContentsLoading(wc: WebContents): boolean { - try { - return wc.isLoading() - } catch { - // Why: destruction races are resolved against the authoritative page registration after the wait. - return false - } -} - -function waitForAbortedNavigationReplacement( - wc: WebContents, - browserPageId: string, - timeoutMs: number -): Promise { - if (!isWebContentsLoading(wc)) { - return Promise.resolve() - } - - return new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType | null = null - const finish = (error?: BrowserError): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-stop-loading', onDidStopLoading) - wc.removeListener('destroyed', onDestroyed) - if (timeout) { - clearTimeout(timeout) - } - if (error) { - reject(error) - } else { - resolve() - } - } - const onDidStopLoading = (): void => finish() - const onDestroyed = (): void => finish() - - wc.on('did-stop-loading', onDidStopLoading) - wc.on('destroyed', onDestroyed) - timeout = setTimeout( - () => - finish( - new BrowserError( - 'browser_error', - `Failed to navigate browser page ${browserPageId}: Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - timeoutMs - ) - timeout.unref?.() - - // Why: the replacement can finish between loadURL rejecting and listener attachment. - if (!isWebContentsLoading(wc)) { - finish() - } - }) -} - -function isTabClosedTransportError(message: string): boolean { - return /session destroyed while command|session destroyed while commands|connection refused|cdp discovery methods failed|websocket connect failed/i.test( - message - ) -} - -function pageUnavailableMessageForSession(sessionName: string): string { - const prefix = ORCA_TAB_SESSION_PREFIX - const browserPageId = sessionName.startsWith(prefix) ? sessionName.slice(prefix.length) : null - return browserPageId - ? `Browser page ${browserPageId} is no longer available` - : 'Browser tab is no longer available' -} - -type CdpMouseButton = 'left' | 'middle' | 'right' - -type BrowserClickPoint = { - x: number - y: number - adjusted: boolean - handled: boolean -} - -function normalizeCdpMouseButton(button?: string): CdpMouseButton { - return button === 'middle' || button === 'right' ? button : 'left' -} - -function cdpMouseButtonMask(button: CdpMouseButton): number { - if (button === 'right') { - return 2 - } - if (button === 'middle') { - return 4 - } - return 1 -} - -function cdpMouseModifierMask(modifiers: BrowserMouseModifier[] | undefined): number { - if (!modifiers || modifiers.length === 0) { - return 0 - } - let mask = 0 - for (const modifier of modifiers) { - if (modifier === 'alt') { - mask |= 1 - } else if (modifier === 'ctrl') { - mask |= 2 - } else if (modifier === 'cmd') { - mask |= 4 - } else if (modifier === 'shift') { - mask |= 8 - } - } - return mask -} - -function readClickPoint(value: unknown, fallback: BrowserClickPoint): BrowserClickPoint { - const point = value && typeof value === 'object' ? (value as Record) : null - const x = point?.x - const y = point?.y - if ( - typeof x !== 'number' || - !Number.isFinite(x) || - typeof y !== 'number' || - !Number.isFinite(y) - ) { - return fallback - } - return { x, y, adjusted: point?.adjusted === true, handled: point?.handled === true } -} - -function mobileTouchClickExpression( - x: number, - y: number, - radius: number, - allowDomActivation: boolean -): string { - return `(() => { - const inputX = ${JSON.stringify(x)}; - const inputY = ${JSON.stringify(y)}; - const radius = ${JSON.stringify(radius)}; - const allowDomActivation = ${JSON.stringify(allowDomActivation)}; - const selector = [ - 'a[href]', - 'button', - 'input', - 'textarea', - 'select', - 'summary', - 'label', - '[role="button"]', - '[role="link"]', - '[role="menuitem"]', - '[role="tab"]', - '[role="checkbox"]', - '[role="radio"]', - '[role="switch"]', - '[onclick]', - '[tabindex]:not([tabindex="-1"])' - ].join(','); - const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); - const isUsable = (el) => { - const rect = el.getBoundingClientRect(); - const style = window.getComputedStyle(el); - return rect.width > 0 && rect.height > 0 && style.display !== 'none' && - style.visibility !== 'hidden' && style.pointerEvents !== 'none'; - }; - const dispatchClick = (target, clickX, clickY) => { - try { - if (typeof target.focus === 'function') { - target.focus({ preventScroll: true }); - } - } catch { - try { target.focus(); } catch {} - } - if (typeof target.click === 'function') { - target.click(); - return true; - } - const init = { - bubbles: true, - cancelable: true, - composed: true, - view: window, - clientX: clickX, - clientY: clickY, - screenX: clickX, - screenY: clickY, - button: 0, - buttons: 1 - }; - try { - if (typeof PointerEvent === 'function') { - target.dispatchEvent(new PointerEvent('pointerdown', { ...init, pointerType: 'touch', pointerId: 1 })); - target.dispatchEvent(new PointerEvent('pointerup', { ...init, buttons: 0, pointerType: 'touch', pointerId: 1 })); - } - } catch {} - target.dispatchEvent(new MouseEvent('mousedown', init)); - target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 })); - target.dispatchEvent(new MouseEvent('click', { ...init, buttons: 0 })); - return true; - }; - const clickableFor = (el) => { - for (let node = el; node && node.nodeType === 1; node = node.parentElement) { - if (node.matches(selector)) return node; - if (window.getComputedStyle(node).cursor === 'pointer') return node; - } - return null; - }; - const offsets = [[0, 0]]; - for (const distance of [radius * 0.45, radius, radius * 1.35]) { - for (const angle of [0, Math.PI / 4, Math.PI / 2, Math.PI * 3 / 4, Math.PI, - Math.PI * 5 / 4, Math.PI * 3 / 2, Math.PI * 7 / 4]) { - offsets.push([Math.cos(angle) * distance, Math.sin(angle) * distance]); - } - } - let best = null; - for (const [dx, dy] of offsets) { - const px = inputX + dx; - const py = inputY + dy; - if (px < 0 || py < 0 || px > window.innerWidth || py > window.innerHeight) continue; - for (const el of document.elementsFromPoint(px, py)) { - const target = clickableFor(el); - if (!target || !isUsable(target)) continue; - const rect = target.getBoundingClientRect(); - const clickX = clamp(inputX, rect.left + 1, rect.right - 1); - const clickY = clamp(inputY, rect.top + 1, rect.bottom - 1); - const score = Math.hypot(clickX - inputX, clickY - inputY) + Math.hypot(dx, dy) * 0.25; - if (!best || score < best.score) best = { score, x: clickX, y: clickY, target }; - break; - } - } - if (best && allowDomActivation && dispatchClick(best.target, best.x, best.y)) { - return { x: best.x, y: best.y, adjusted: true, handled: true }; - } - if (best) { - return { x: best.x, y: best.y, adjusted: true, handled: false }; - } - return { x: inputX, y: inputY, adjusted: false, handled: false }; - })()` -} - -async function resolveMobileTouchClickPoint( - dbg: WebContents['debugger'], - x: number, - y: number, - radius: number | undefined, - allowDomActivation: boolean -): Promise { - const fallback = { x, y, adjusted: false, handled: false } - if (typeof radius !== 'number' || !Number.isFinite(radius) || radius <= 0) { - return fallback - } - try { - const result = await dbg.sendCommand('Runtime.evaluate', { - expression: mobileTouchClickExpression(x, y, radius, allowDomActivation), - returnByValue: true, - silent: true - }) - const raw = result && typeof result === 'object' ? (result as Record) : null - const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null - return readClickPoint((evaluated as Record | null)?.value, fallback) - } catch { - return fallback - } -} - -function translateResult( - stdout: string -): { ok: true; result: unknown } | { ok: false; error: { code: string; message: string } } { - let parsed: { success?: boolean; data?: unknown; error?: string } - try { - parsed = JSON.parse(stdout) - } catch { - return { - ok: false, - error: { - code: 'browser_error', - message: `Unexpected output from agent-browser: ${stdout.slice(0, 1000)}` - } - } - } - if (parsed.success) { - return { ok: true, result: parsed.data } - } - const message = parsed.error ?? 'Unknown browser error' - return { - ok: false, - error: { - code: classifyErrorCode(message), - message - } - } -} - -export class AgentBrowserBridge { - // Why: per-worktree active tab so one worktree's tab switch can't affect another's command targeting. - private readonly activeWebContentsPerWorktree = new Map() - private activeWebContentsId: number | null = null - private readonly sessions = new Map() - private readonly commandQueues = new Map() - private readonly processingQueues = new Set() - // Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other. - private screenshotTurn: Promise = Promise.resolve() - private readonly agentBrowserBin: string - private readonly agentBrowserEnv: NodeJS.ProcessEnv - private readonly ownsAgentBrowserSocketDirectory: boolean - // Why: null when nothing bounds the daemon, so the bridge never guesses that one was replaced. - private readonly agentBrowserIdleTimeoutMs: number | null - // Why: stash intercept patterns from a swap-destroyed session, keyed by name, so the next session restores them. - private readonly pendingInterceptRestore = new Map() - // Why: promise-lock so two concurrent ensureSession calls don't both create the session entry. - private readonly pendingSessionCreation = new Map>() - // Why: `agent-browser close` is async, keyed by session name — recreating before it finishes lets the old teardown close the new session. - private readonly pendingSessionDestruction = new Map>() - private readonly cancelledProcesses = new WeakSet() - private shutdownStarted = false - - constructor( - private readonly browserManager: BrowserManager, - private readonly options: AgentBrowserBridgeOptions = {} - ) { - this.agentBrowserBin = resolveAgentBrowserBinary() - const processEnvironment = createAgentBrowserProcessEnvironment({ - inheritedEnv: process.env, - platform: process.platform, - userDataPath: app.getPath('userData') - }) - this.agentBrowserEnv = processEnvironment.env - this.ownsAgentBrowserSocketDirectory = processEnvironment.ownsSocketDirectory - const idleTimeoutMs = Number(this.agentBrowserEnv.AGENT_BROWSER_IDLE_TIMEOUT_MS) - this.agentBrowserIdleTimeoutMs = idleTimeoutMs > 0 ? idleTimeoutMs : null - } - - // ── Tab tracking ── - - setActiveTab(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - private selectFallbackActiveWebContents( - worktreeId: string, - excludedWebContentsId?: number - ): number | null { - for (const [, wcId] of this.getRegisteredTabs(worktreeId)) { - if (wcId === excludedWebContentsId) { - continue - } - if (this.getWebContents(wcId)) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - return wcId - } - } - this.activeWebContentsPerWorktree.delete(worktreeId) - return null - } - - getActiveWebContentsId(): number | null { - return this.activeWebContentsId - } - - getPageInfo( - worktreeId?: string, - browserPageId?: string - ): { browserPageId: string; url: string; title: string } | null { - try { - const target = this.resolveCommandTarget(worktreeId, browserPageId) - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - return null - } - return { - browserPageId: target.browserPageId, - url: wc.getURL() ?? '', - title: wc.getTitle() ?? '' - } - } catch { - return null - } - } - - onTabChanged(webContentsId: number, worktreeId?: string): void { - this.activeWebContentsId = webContentsId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, webContentsId) - } - this.options.onTabsChanged?.(worktreeId) - } - - async onTabClosed(webContentsId: number): Promise { - const browserPageId = this.resolveTabIdSafe(webContentsId) - const owningWorktreeId = browserPageId - ? this.browserManager.getWorktreeIdForTab(browserPageId) - : undefined - let nextWorktreeActiveWebContentsId: number | null = null - if ( - owningWorktreeId && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === webContentsId - ) { - nextWorktreeActiveWebContentsId = this.selectFallbackActiveWebContents( - owningWorktreeId, - webContentsId - ) - } - if (this.activeWebContentsId === webContentsId) { - this.activeWebContentsId = nextWorktreeActiveWebContentsId - } - if (browserPageId) { - await this.onPageClosed(browserPageId) - } - this.options.onTabsChanged?.(owningWorktreeId) - } - - /** - * Retire a page's daemon by page id. - * - * The headless offscreen backend owns pages by id and unregisters the guest - * itself, so `onTabClosed`'s webContentsId lookup can never resolve one — it - * has to say which page closed (#16367). - */ - async onPageClosed(browserPageId: string): Promise { - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - await this.destroySession(sessionName) - this.pendingInterceptRestore.delete(sessionName) - } - - async onProcessSwap( - browserPageId: string, - newWebContentsId: number, - previousWebContentsId?: number - ): Promise { - // Why: an Electron process swap keeps browserPageId but gives a new webContentsId — destroy the session so the next command recreates it. - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}` - const session = this.sessions.get(sessionName) - const oldWebContentsId = previousWebContentsId ?? session?.webContentsId - const owningWorktreeId = this.browserManager.getWorktreeIdForTab(browserPageId) - // Why: save intercept patterns before destroy so the new session can restore them after init. - if (session && session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - await this.destroySession(sessionName) - if (oldWebContentsId != null && this.activeWebContentsId === oldWebContentsId) { - this.activeWebContentsId = newWebContentsId - } - if ( - owningWorktreeId && - oldWebContentsId != null && - this.activeWebContentsPerWorktree.get(owningWorktreeId) === oldWebContentsId - ) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, newWebContentsId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - } - - // ── Worktree-scoped tab queries ── - - getRegisteredTabs(worktreeId?: string): Map { - const all = this.browserManager.getWebContentsIdByTabId() - if (!worktreeId) { - return all - } - - const filtered = new Map() - for (const [tabId, wcId] of all) { - if (this.browserManager.getWorktreeIdForTab(tabId) === worktreeId) { - filtered.set(tabId, wcId) - } - } - return filtered - } - - // ── Tab management ── - - tabList(worktreeId?: string): BrowserTabListResult { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: use the per-worktree active tab so listing matches command routing, but read-only — discovery must not mutate active-tab state. - let activeWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - const result: BrowserTabInfo[] = [] - let index = 0 - let firstLiveWcId: number | null = null - for (const [tabId, wcId] of tabs) { - const wc = this.getWebContents(wcId) - if (!wc) { - this.browserManager.unregisterGuest(tabId) - continue - } - if (firstLiveWcId === null) { - firstLiveWcId = wcId - } - const loadError = this.browserManager.getBrowserPageLoadError(tabId) - const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) - result.push({ - browserPageId: tabId, - index: index++, - // Why: failed WebContents report chrome-error://, not the address the user asked to load. - url: loadError?.validatedUrl ?? wc.getURL() ?? '', - title: wc.getTitle() ?? '', - active: wcId === activeWcId, - loadError, - certificateFailure - }) - } - // Why: with no active tab yet, show the first live tab as active without mutating state — keeps `tab list` side-effect free. - if (activeWcId == null && firstLiveWcId !== null) { - activeWcId = firstLiveWcId - if (result.length > 0) { - result[0].active = true - } - } - return { tabs: result } - } - - // Why: route tab switch through the command queue so it can't race in-flight commands targeting the old tab. - async tabSwitch( - index: number | undefined, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueCommand(worktreeId, async () => { - const tabs = this.getRegisteredTabs(worktreeId) - // Why: queue delay can change the tab list before execution — recompute against live webContents so no vanished index is activated. - const liveEntries = [...tabs.entries()].filter(([, wcId]) => this.getWebContents(wcId)) - let switchedIndex = index ?? -1 - let resolvedPageId = browserPageId - if (resolvedPageId) { - switchedIndex = liveEntries.findIndex(([tabId]) => tabId === resolvedPageId) - } - if (switchedIndex < 0 || switchedIndex >= liveEntries.length) { - const targetLabel = - resolvedPageId != null ? `Browser page ${resolvedPageId}` : `Tab index ${index}` - throw new BrowserError( - 'browser_tab_not_found', - `${targetLabel} out of range (0-${liveEntries.length - 1})` - ) - } - const [tabId, wcId] = liveEntries[switchedIndex] - this.activeWebContentsId = wcId - // Why: resolveActiveTab prefers the per-worktree map, so update it or later commands keep routing to the old tab. - const owningWorktreeId = worktreeId ?? this.browserManager.getWorktreeIdForTab(tabId) - // Why: `tab switch --page` may omit --worktree, so still update the owning worktree's active slot for later scoped commands. - if (owningWorktreeId) { - this.activeWebContentsPerWorktree.set(owningWorktreeId, wcId) - } - this.options.onTabsChanged?.(owningWorktreeId ?? undefined) - return { switched: switchedIndex, browserPageId: tabId } - }) - } - - // ── Core commands (typed) ── - - async snapshot(worktreeId?: string, browserPageId?: string): Promise { - // Why: snapshot creates fresh refs so it must bypass the stale-ref guard - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName, target) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'snapshot' - ])) as BrowserSnapshotResult - return { - ...result, - browserPageId: target.browserPageId - } - }) - } - - async click( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['click', element])) as BrowserClickResult - }) - } - - async dblclick( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['dblclick', element])) as BrowserClickResult - }) - } - - async goto(url: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - const navigationUrl = normalizeBrowserNavigationUrl(url) - if (!navigationUrl) { - throw new BrowserError('invalid_argument', `Unsupported browser URL: ${url}`) - } - const navigationState: { preventUnloadEvent: Electron.Event | null } = { - preventUnloadEvent: null - } - const onWillPreventUnload = (event: Electron.Event): void => { - navigationState.preventUnloadEvent = event - } - wc.on('will-prevent-unload', onWillPreventUnload) - let navigationAborted = false - const navigationDeadline = Date.now() + EMBEDDED_NAVIGATION_TIMEOUT_MS - let navigationTimeout: ReturnType | null = null - try { - await Promise.race([ - wc.loadURL(navigationUrl), - new Promise((_resolve, reject) => { - navigationTimeout = setTimeout( - () => - reject( - new Error( - `Browser navigation timed out after ${EMBEDDED_NAVIGATION_TIMEOUT_MS}ms` - ) - ), - EMBEDDED_NAVIGATION_TIMEOUT_MS - ) - navigationTimeout.unref?.() - }) - ]) - } catch (error) { - if (navigationTimeout) { - clearTimeout(navigationTimeout) - navigationTimeout = null - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - // Why: ERR_ABORTED also covers a page vetoing unload; that navigation did not succeed. - if ( - !isAbortedNavigationError(error) || - (navigationState.preventUnloadEvent !== null && - !navigationState.preventUnloadEvent.defaultPrevented) - ) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } - navigationAborted = true - // Why: a superseding navigation rejects the first load before its replacement has landed. - await waitForAbortedNavigationReplacement( - wc, - target.browserPageId, - Math.max(0, navigationDeadline - Date.now()) - ) - } finally { - wc.removeListener('will-prevent-unload', onWillPreventUnload) - if (navigationTimeout) { - clearTimeout(navigationTimeout) - } - } - - // Why: cross-process navigation can replace the guest while retaining the same authoritative page id. - const navigatedTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - const navigatedWebContents = this.requireTargetWebContents(navigatedTarget) - const loadError = navigationAborted - ? this.browserManager.getBrowserPageLoadError(target.browserPageId) - : null - if (loadError) { - throw new BrowserError( - 'browser_error', - `Failed to navigate browser page ${target.browserPageId}: ${loadError.description} (${loadError.code})` - ) - } - return { url: navigatedWebContents.getURL(), title: navigatedWebContents.getTitle() } - }, - { ensureSession: false } - ) - } - - async fill( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(value) - // Why: agent-browser's CDP text insertion loses focus in Electron guests; edit through the browser's input pipeline instead. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - await this.execAgentBrowser(sessionName, ['focus', element]) - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify('')) - ]) - for (const chunk of iterateBrowserTextInsertionChunks( - value, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(chunk), { append: true }) - ]) - } - await this.execAgentBrowser(sessionName, [ - 'eval', - focusedValueSetExpression(JSON.stringify(''), { append: true, dispatchEvents: true }) - ]) - return { filled: element } as BrowserFillResult - } - - await this.fillExplicitContentEditable(sessionName, element, value) - return { filled: element } as BrowserFillResult - }, - { requireScopedTarget: true } - ) - } - - async type( - input: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(input) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - for (const chunk of iterateBrowserTextInsertionChunks( - input, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - await this.execAgentBrowser(sessionName, ['keyboard', 'type', chunk]) - } - return { typed: true } as BrowserTypeResult - }, - { requireScopedTarget: true } - ) - } - - async select( - element: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'select', - element, - value - ])) as BrowserSelectResult - }) - } - - async scroll( - direction: string, - amount?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['scroll', direction] - if (amount != null) { - args.push(String(amount)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserScrollResult - }) - } - - async scrollIntoView( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['scrollintoview', element]) - }) - } - - async get( - what: string, - selector?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['get', what] - if (selector) { - args.push(selector) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async is( - what: string, - selector: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['is', what, selector]) - }) - } - - // ── Keyboard commands ── - - async keyboardInsertText( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text) - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - let result: unknown = { inserted: true } - for (const chunk of iterateBrowserTextInsertionChunks( - text, - AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES - )) { - result = await this.execAgentBrowser(sessionName, ['keyboard', 'inserttext', chunk]) - } - return result - }, - { requireScopedTarget: true } - ) - } - - // ── Mouse commands ── - - async mouseMove( - x: number, - y: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['mouse', 'move', String(x), String(y)]) - }) - } - - async mouseDown(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'down'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseClick( - x: number, - y: number, - button?: string, - worktreeId?: string, - browserPageId?: string, - radius?: number, - modifiers?: BrowserMouseModifier[] - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${target.browserPageId} is no longer available` - ) - } - const cdpButton = normalizeCdpMouseButton(button) - const buttons = cdpMouseButtonMask(cdpButton) - const cdpModifiers = cdpMouseModifierMask(modifiers) - const lease = acquireElectronDebugger(wc) - try { - wc.focus() - const point = - cdpButton === 'left' - ? // Why: DOM activation can't carry Cmd/Ctrl/Alt/Shift, so modifier clicks use the adjusted point and let CDP dispatch the event. - await resolveMobileTouchClickPoint(wc.debugger, x, y, radius, cdpModifiers === 0) - : { x, y, adjusted: false, handled: false } - // Why: land the tap as one atomic op — separate move/down/up CLI calls visibly hover and can miss small controls. - // Why: mobile-emulated BrowserViews can ignore CDP mouse clicks, so the runtime may already have activated DOM controls. - if (!point.handled) { - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mousePressed', - x: point.x, - y: point.y, - button: cdpButton, - buttons, - modifiers: cdpModifiers, - clickCount: 1 - }) - await wc.debugger.sendCommand('Input.dispatchMouseEvent', { - type: 'mouseReleased', - x: point.x, - y: point.y, - button: cdpButton, - buttons: 0, - modifiers: cdpModifiers, - clickCount: 1 - }) - } - return { - clicked: { - x: point.x, - y: point.y, - button: cdpButton, - adjusted: point.adjusted, - handled: point.handled - } - } - } finally { - lease.release() - } - }, - { ensureSession: false } - ) - } - - async mouseUp(button?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'up'] - if (button) { - args.push(button) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async mouseWheel( - dy: number, - dx?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['mouse', 'wheel', String(dy)] - if (dx != null) { - args.push(String(dx)) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Find (semantic locators) ── - - async find( - locator: string, - value: string, - action: string, - text?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['find', locator, value, action] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Set commands ── - - async setDevice(name: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'device', name]) - }) - } - - async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'offline'] - if (state) { - args.push(state) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async setHeaders( - headersJson: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'headers', headersJson]) - }) - } - - async setCredentials( - user: string, - pass: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['set', 'credentials', user, pass]) - }) - } - - async setMedia( - colorScheme?: string, - reducedMotion?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['set', 'media'] - if (colorScheme) { - args.push(colorScheme) - } - if (reducedMotion) { - args.push(reducedMotion) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Clipboard commands ── - - async clipboardRead(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'read']) - }) - } - - async clipboardWrite( - text: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - await assertClipboardTextWriteWithinLimitWithYield(text, { - maxBytes: AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES - }) - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['clipboard', 'write', text]) - }) - } - - // ── Dialog commands ── - - async dialogAccept(text?: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['dialog', 'accept'] - if (text) { - args.push(text) - } - return await this.execAgentBrowser(sessionName, args) - }) - } - - async dialogDismiss(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['dialog', 'dismiss']) - }) - } - - // ── Storage commands ── - - async storageLocalGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'get', key]) - }) - } - - async storageLocalSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value]) - }) - } - - async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear']) - }) - } - - async storageSessionGet( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'get', key]) - }) - } - - async storageSessionSet( - key: string, - value: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value]) - }) - } - - async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['storage', 'session', 'clear']) - }) - } - - // ── Download command ── - - async download( - selector: string, - path: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['download', selector, path]) - }) - } - - // ── Highlight command ── - - async highlight(selector: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return await this.execAgentBrowser(sessionName, ['highlight', selector]) - }) - } - - async back(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult - }) - } - - async forward(worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult - }) - } - - async reload(worktreeId?: string, browserPageId?: string): Promise { - // Why: reload can trigger an Electron process swap that destroys the session mid-command — reload via webContents directly instead. - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - wc.reload() - await new Promise((resolve) => { - let settled = false - let fallbackTimer: ReturnType | null = null - - const finish = (): void => { - if (settled) { - return - } - settled = true - wc.removeListener('did-finish-load', onFinish) - wc.removeListener('did-fail-load', onFail) - if (fallbackTimer) { - clearTimeout(fallbackTimer) - fallbackTimer = null - } - resolve() - } - const onFinish = (): void => finish() - const onFail = (): void => finish() - - wc.on('did-finish-load', onFinish) - wc.on('did-fail-load', onFail) - // Why: clear the fallback timer on load; otherwise each reload leaks the webContents + listeners until the 10s timeout. - fallbackTimer = setTimeout(finish, 10_000) - if (typeof fallbackTimer.unref === 'function') { - fallbackTimer.unref() - } - }) - return { url: wc.getURL(), title: wc.getTitle() } - }) - } - - async screenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - // Why: agent-browser writes the screenshot to a temp file and returns its path; read it and return base64. - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - return this.captureScreenshotCommand(sessionName, ['screenshot'], 300, format) - }, - { ensureVisible: false } - ) - } - - async fullPageScreenshot( - format?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName, target) => { - return this.captureFullPageScreenshotCommand( - sessionName, - target.webContentsId, - 500, - format === 'jpeg' ? 'jpeg' : 'png' - ) - }, - { ensureVisible: false } - ) - } - - private readScreenshotFromResult(raw: unknown, format?: string): BrowserScreenshotResult { - const parsed = raw as { path?: string } | undefined - if (!parsed?.path) { - throw new BrowserError('browser_error', 'Screenshot returned no file path') - } - if (!existsSync(parsed.path)) { - throw new BrowserError('browser_error', `Screenshot file not found: ${parsed.path}`) - } - const data = readFileSync(parsed.path).toString('base64') - return { data, format: format === 'jpeg' ? 'jpeg' : 'png' } as BrowserScreenshotResult - } - - private async captureScreenshotCommand( - sessionName: string, - commandArgs: string[], - settleMs: number, - format?: string - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: let the compositor settle to a painted frame after the lease, inside the screenshot lock so another tab can't change lease state first. - await new Promise((r) => setTimeout(r, settleMs)) - const raw = await this.execAgentBrowser(sessionName, commandArgs) - return this.readScreenshotFromResult(raw, format) - } finally { - restore() - } - }) - } - - private async captureFullPageScreenshotCommand( - sessionName: string, - webContentsId: number, - settleMs: number, - format: 'png' | 'jpeg' - ): Promise { - return this.withSerializedScreenshotAccess(async () => { - const session = this.sessions.get(sessionName) - const restore = session - ? await this.browserManager.acquireAutomationVisibility(session.webContentsId) - : () => {} - try { - // Why: the guest compositor needs a beat to paint a fresh frame after becoming paintable, or CDP captures a stale surface. - await new Promise((r) => setTimeout(r, settleMs)) - const wc = this.getWebContents(webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - return await captureFullPageScreenshot(wc, format) - } catch (error) { - throw new BrowserError('browser_error', (error as Error).message) - } finally { - restore() - } - }) - } - - private async withSerializedScreenshotAccess(execute: () => Promise): Promise { - const previousTurn = this.screenshotTurn.catch(() => {}) - let releaseTurn!: () => void - this.screenshotTurn = new Promise((resolve) => { - releaseTurn = resolve - }) - await previousTurn - try { - return await execute() - } finally { - releaseTurn() - } - } - - async evaluate( - expression: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (_sessionName, target) => { - const wc = this.requireTargetWebContents(target) - let releaseDebugger = (): void => {} - try { - releaseDebugger = acquireElectronDebugger(wc).release - const { result, exceptionDetails } = (await wc.debugger.sendCommand('Runtime.evaluate', { - expression, - returnByValue: true, - awaitPromise: true - })) as { - result: { value?: unknown; description?: string } - exceptionDetails?: { text: string; exception?: { description?: string } } - } - if (exceptionDetails) { - throw new BrowserError( - 'browser_eval_error', - exceptionDetails.exception?.description ?? exceptionDetails.text - ) - } - - const currentTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (currentTarget.webContentsId !== target.webContentsId) { - throw new BrowserError( - 'browser_tab_changed', - `Browser page ${target.browserPageId} changed while evaluating; retry the command` - ) - } - return { - result: - result.value !== undefined - ? typeof result.value === 'object' && result.value !== null - ? JSON.stringify(result.value) - : String(result.value) - : (result.description ?? ''), - origin: wc.getURL() - } - } catch (error) { - if (error instanceof BrowserError) { - throw error - } - if (!this.getWebContents(target.webContentsId)) { - throw this.createPageUnavailableError( - `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - ) - } - throw new BrowserError( - 'browser_error', - `Failed to evaluate in browser page ${target.browserPageId}: ${error instanceof Error ? error.message : String(error)}` - ) - } finally { - releaseDebugger() - } - }, - { ensureSession: false } - ) - } - - async hover( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['hover', element])) as BrowserHoverResult - }) - } - - async drag( - from: string, - to: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['drag', from, to])) as BrowserDragResult - }) - } - - async upload( - element: string, - filePaths: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'upload', - element, - ...filePaths - ])) as BrowserUploadResult - }) - } - - async wait( - options?: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - }, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['wait'] - const hasCondition = - !!options?.selector || !!options?.text || !!options?.url || !!options?.load || !!options?.fn - if (options?.selector) { - args.push(options.selector) - } else if (options?.timeout != null && !hasCondition) { - args.push(String(options.timeout)) - } - if (options?.text) { - args.push('--text', options.text) - } - if (options?.url) { - args.push('--url', options.url) - } - if (options?.load) { - args.push('--load', options.load) - } - if (options?.fn) { - args.push('--fn', options.fn) - } - const normalizedState = options?.state === 'visible' ? undefined : options?.state - if (normalizedState) { - args.push('--state', normalizedState) - } - // Why: agent-browser's selector wait lacks a per-command timeout — enforce it here so a missing selector fails as browser_timeout, not a hang. - return (await this.execAgentBrowser(sessionName, args, { - timeoutMs: - options?.timeout != null && hasCondition - ? options.timeout + WAIT_PROCESS_TIMEOUT_GRACE_MS - : undefined, - timeoutError: - options?.timeout != null && hasCondition - ? new BrowserError( - 'browser_timeout', - `Timed out waiting for browser condition after ${options.timeout}ms.` - ) - : undefined - })) as BrowserWaitResult - }) - } - - async check( - element: string, - checked: boolean, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = checked ? ['check', element] : ['uncheck', element] - return (await this.execAgentBrowser(sessionName, args)) as BrowserCheckResult - }) - } - - async focus( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['focus', element])) as BrowserFocusResult - }) - } - - async clear( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - browserPageId, - async (sessionName) => { - if (!(await this.isExplicitContentEditableTarget(sessionName, element))) { - // Why: agent-browser resolves the ref directly, preserving iframe/shadow-root/unfocusable semantics for ordinary fields. - await this.execAgentBrowser(sessionName, ['fill', element, '']) - return { cleared: element } - } - - await this.fillExplicitContentEditable(sessionName, element, '') - return { cleared: element } - }, - { requireScopedTarget: true } - ) - } - - async selectAll( - element: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser has no select-all command — implement as focus + Ctrl+A - await this.execAgentBrowser(sessionName, ['focus', element]) - return (await this.execAgentBrowser(sessionName, [ - 'press', - 'Control+a' - ])) as BrowserSelectAllResult - }) - } - - async keypress( - key: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult - }) - } - - async pdf(worktreeId?: string, browserPageId?: string): Promise { - // Why: agent-browser's CDP printToPDF hangs in Electron webviews — use the native webContents.printToPDF(). - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_no_tab', 'Tab is no longer available') - } - const buffer = await wc.printToPDF({ - printBackground: true, - preferCSSPageSize: true - }) - return { data: buffer.toString('base64') } - }) - } - - // ── Cookie commands ── - - async cookieGet( - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'cookies', - 'get' - ])) as BrowserCookieGetResult - }) - } - - async cookieSet( - cookie: Partial, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'set', cookie.name ?? '', cookie.value ?? ''] - if (cookie.domain) { - args.push('--domain', cookie.domain) - } - if (cookie.path) { - args.push('--path', cookie.path) - } - if (cookie.secure) { - args.push('--secure') - } - if (cookie.httpOnly) { - args.push('--httpOnly') - } - if (cookie.sameSite) { - args.push('--sameSite', cookie.sameSite) - } - if (cookie.expires != null) { - args.push('--expires', String(cookie.expires)) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieSetResult - }) - } - - async cookieDelete( - name?: string, - domain?: string, - _url?: string, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const args = ['cookies', 'clear'] - if (name) { - args.push('--name', name) - } - if (domain) { - args.push('--domain', domain) - } - return (await this.execAgentBrowser(sessionName, args)) as BrowserCookieDeleteResult - }) - } - - // ── Viewport / emulation commands ── - - async setViewport( - width: number, - height: number, - scale = 1, - mobile = false, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (_sessionName, target) => { - const wc = this.getWebContents(target.webContentsId) - if (!wc) { - throw new BrowserError('browser_tab_not_found', 'Tab is no longer available') - } - const dbg = wc.debugger - if (!dbg.isAttached()) { - throw new BrowserError('browser_error', 'Debugger not attached') - } - - // Why: agent-browser's `set viewport` has no `mobile` flag, so apply the emulation directly via CDP to honor Orca's --mobile. - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width, - height, - deviceScaleFactor: scale, - mobile - }) - // Why: BrowserView's compositor can keep the old host size after a metrics-only resize, cropping remote screencast clients. - await Promise.resolve(dbg.sendCommand('Emulation.setVisibleSize', { width, height })).catch( - () => {} - ) - - return { - width, - height, - deviceScaleFactor: scale, - mobile - } - }) - } - - async setGeolocation( - lat: number, - lon: number, - _accuracy?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'set', - 'geo', - String(lat), - String(lon) - ])) as BrowserGeolocationResult - }) - } - - // ── Network interception commands ── - - async interceptEnable( - patterns?: string[], - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: agent-browser uses "network route " to intercept. Route each pattern individually. - const urlPattern = patterns?.[0] ?? '**/*' - const args = ['network', 'route', urlPattern] - const result = (await this.execAgentBrowser( - sessionName, - args - )) as BrowserInterceptEnableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = patterns ?? ['*'] - } - return result - }) - } - - async interceptDisable( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'unroute' - ])) as BrowserInterceptDisableResult - const session = this.sessions.get(sessionName) - if (session) { - this.pendingInterceptRestore.delete(sessionName) - session.activeInterceptPatterns = [] - } - return result - }) - } - - async interceptList( - worktreeId?: string, - browserPageId?: string - ): Promise<{ requests: unknown[] }> { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['network', 'requests'])) as { - requests: unknown[] - } - }) - } - - // TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions, not just URL-pattern routing. - - // ── Capture commands ── - - async captureStart( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'start' - ])) as BrowserCaptureStartResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = true - } - return result - }) - } - - async captureStop( - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - const result = (await this.execAgentBrowser(sessionName, [ - 'network', - 'har', - 'stop' - ])) as BrowserCaptureStopResult - const session = this.sessions.get(sessionName) - if (session) { - session.activeCapture = false - } - return result - }) - } - - async consoleLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult - }) - } - - async networkLog( - _limit?: number, - worktreeId?: string, - browserPageId?: string - ): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - return (await this.execAgentBrowser(sessionName, [ - 'network', - 'requests' - ])) as BrowserNetworkLogResult - }) - } - - // ── Generic passthrough ── - - async exec(command: string, worktreeId?: string, browserPageId?: string): Promise { - return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => { - // Why: strip target/session flags from passthrough so a caller can't override Orca's selected page or CDP proxy. - const args = stripAgentBrowserTargetArgs(parseShellArgs(command.trim())) - return await this.execAgentBrowser(sessionName, args) - }) - } - - // ── Session lifecycle ── - - // Why: a previous run that crashed or was SIGKILL'd left one daemon per open tab with - // nobody holding its name — closeStaleAgentBrowserSession only resets a name being reused. - async sweepOrphanedSessions(): Promise { - return sweepOrphanedAgentBrowserSessions({ - binaryPath: this.agentBrowserBin, - env: this.agentBrowserEnv, - ownsSocketDirectory: this.ownsAgentBrowserSocketDirectory, - isSessionLive: (sessionName) => - this.sessions.has(sessionName) || this.pendingSessionCreation.has(sessionName) - }) - } - - async destroyAllSessions(options?: AgentBrowserCleanupOptions): Promise { - this.shutdownStarted = true - // Why the union: a session still being created has already spawned its daemon but is not in - // `sessions` yet, so closing only `sessions` lets that daemon outlive the quit (#16367). - const sessionNames = new Set([ - ...this.sessions.keys(), - ...this.pendingSessionCreation.keys(), - ...this.pendingSessionDestruction.keys() - ]) - await mapSettledWithConcurrency( - [...sessionNames], - AGENT_BROWSER_CLEANUP_CONCURRENCY, - (sessionName) => this.destroySession(sessionName, options) - ) - this.pendingInterceptRestore.clear() - } - - // ── Internal ── - - private async enqueueCommand( - worktreeId: string | undefined, - execute: (sessionName: string) => Promise - ): Promise { - return this.enqueueTargetedCommand( - worktreeId, - undefined, - async (sessionName) => execute(sessionName), - { ensureVisible: false } - ) - } - - private async enqueueTargetedCommand( - worktreeId: string | undefined, - browserPageId: string | undefined, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions = {} - ): Promise { - this.assertCommandAdmission() - const target = this.resolveCommandTarget(worktreeId, browserPageId, options.requireScopedTarget) - const sessionName = `${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}` - - if (options.ensureSession !== false) { - await this.ensureSession(sessionName, target.browserPageId, target.webContentsId) - } - this.assertCommandAdmission() - - return new Promise((resolve, reject) => { - let queue = this.commandQueues.get(sessionName) - if (!queue) { - queue = [] - this.commandQueues.set(sessionName, queue) - } - queue.push({ - execute: (() => - this.executeWithVisibleTarget( - sessionName, - worktreeId, - target, - execute, - options - )) as () => Promise, - resolve: resolve as (value: unknown) => void, - reject - }) - this.processQueue(sessionName) - }) - } - - private async executeWithVisibleTarget( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise, - options: EnqueueTargetedCommandOptions - ): Promise { - if (options.ensureVisible === false) { - return execute(sessionName, target) - } - - // Why: inactive panes are display:none; the automation lease makes only this target paintable without selecting it. - const restore = await this.browserManager.acquireAutomationVisibility(target.webContentsId) - try { - const visibleTarget = await this.refreshTargetAfterAutomationVisibility( - sessionName, - worktreeId, - target, - options - ) - return await execute(sessionName, visibleTarget) - } finally { - restore() - } - } - - private async refreshTargetAfterAutomationVisibility( - sessionName: string, - worktreeId: string | undefined, - target: ResolvedBrowserCommandTarget, - options: EnqueueTargetedCommandOptions - ): Promise { - const visibleTarget = this.resolveCommandTarget(worktreeId, target.browserPageId) - if (visibleTarget.webContentsId === target.webContentsId) { - return visibleTarget - } - - if (this.activeWebContentsId === target.webContentsId) { - this.activeWebContentsId = visibleTarget.webContentsId - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === target.webContentsId) { - this.activeWebContentsPerWorktree.set(worktreeId, visibleTarget.webContentsId) - } - - // Why: making a parked webview paintable can re-register the page with a new guest webContents; tear down the stale session. - await this.restartSessionForTarget( - sessionName, - visibleTarget.browserPageId, - visibleTarget.webContentsId, - { recreate: options.ensureSession !== false } - ) - - return visibleTarget - } - - private async processQueue(sessionName: string): Promise { - if (this.processingQueues.has(sessionName)) { - return - } - this.processingQueues.add(sessionName) - - const queue = this.commandQueues.get(sessionName) - while (queue && queue.length > 0) { - const cmd = queue.shift()! - try { - const result = await cmd.execute() - cmd.resolve(result) - } catch (error) { - cmd.reject(error) - } - } - - if (queue && queue.length === 0 && this.commandQueues.get(sessionName) === queue) { - this.commandQueues.delete(sessionName) - } - this.processingQueues.delete(sessionName) - } - - getActivePageId(worktreeId?: string, browserPageId?: string): string | null { - try { - return this.resolveCommandTarget(worktreeId, browserPageId).browserPageId - } catch { - return null - } - } - - private resolveCommandTarget( - worktreeId?: string, - browserPageId?: string, - requireScopedTarget = false - ): ResolvedBrowserCommandTarget { - if (!browserPageId) { - return requireScopedTarget - ? this.resolveScopedActiveTab(worktreeId) - : this.resolveActiveTab(worktreeId) - } - - const tabs = this.getRegisteredTabs(worktreeId) - const webContentsId = tabs.get(browserPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - - if (!this.getWebContents(webContentsId)) { - this.browserManager.unregisterGuest(browserPageId) - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - return { browserPageId, webContentsId } - } - - private resolveActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - const tabs = this.getRegisteredTabs(worktreeId) - - if (tabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - - // Why: prefer per-worktree active tab to avoid cross-worktree interference; fall back to global for callers without worktreeId. - const preferredWcId = - (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId)) ?? this.activeWebContentsId - - if (preferredWcId != null) { - for (const [tabId, wcId] of tabs) { - if (wcId === preferredWcId && this.getWebContents(wcId)) { - return { browserPageId: tabId, webContentsId: wcId } - } - if (wcId === preferredWcId) { - this.browserManager.unregisterGuest(tabId) - if (this.activeWebContentsId === wcId) { - this.activeWebContentsId = null - } - if (worktreeId && this.activeWebContentsPerWorktree.get(worktreeId) === wcId) { - this.activeWebContentsPerWorktree.delete(worktreeId) - } - } - } - } - - // Why: persisted state can leave ghost tabs (dead webContents); skip them and activate the first live tab for consistency. - for (const [tabId, wcId] of tabs) { - if (this.getWebContents(wcId)) { - this.activeWebContentsId = wcId - if (worktreeId) { - this.activeWebContentsPerWorktree.set(worktreeId, wcId) - } - return { browserPageId: tabId, webContentsId: wcId } - } - this.browserManager.unregisterGuest(tabId) - } - - throw new BrowserError( - 'browser_no_tab', - 'No live browser tab available — all registered tabs have been destroyed' - ) - } - - // Why: don't fall back to the global tab for text mutation — it could inject into another worktree's foreground webview and steal focus. - private resolveScopedActiveTab(worktreeId?: string): ResolvedBrowserCommandTarget { - if (worktreeId) { - return this.resolveActiveTab(worktreeId) - } - - const worktreesWithLiveTabs = new Set() - for (const [tabId, wcId] of this.getRegisteredTabs(undefined)) { - if (this.getWebContents(wcId)) { - worktreesWithLiveTabs.add(this.browserManager.getWorktreeIdForTab(tabId)) - } - } - - if (worktreesWithLiveTabs.size === 0) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - if (worktreesWithLiveTabs.size > 1) { - throw new BrowserError( - 'browser_target_ambiguous', - 'Multiple worktrees have browser tabs open; pass --worktree to target text insertion safely' - ) - } - - const [onlyWorktreeId] = worktreesWithLiveTabs - return this.resolveActiveTab(onlyWorktreeId) - } - - private async ensureSession( - sessionName: string, - browserPageId: string, - webContentsId: number - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - } - this.assertCommandAdmission() - - if (this.sessions.has(sessionName)) { - return - } - - // Why: without this lock, two concurrent calls both create proxies and the second leaks the first's server/debugger. - const pending = this.pendingSessionCreation.get(sessionName) - if (pending) { - await pending - this.assertCommandAdmission() - return - } - - const createSession = async (): Promise => { - const wc = this.getWebContents(webContentsId) - if (!wc) { - // Why: the webview can be destroyed between target resolution and session creation — keep the same closed-tab error shape. - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} is no longer available` - ) - } - - // Why: the daemon persists sessions (incl. CDP port) across restarts; close the stale one first or it ignores --cdp and hits the dead port. - await this.closeStaleAgentBrowserSession(sessionName) - - const proxy = new CdpWsProxy(wc) - const cdpEndpoint = await proxy.start() - - this.sessions.set(sessionName, { - proxy, - cdpEndpoint, - initialized: false, - consecutiveTimeouts: 0, - activeInterceptPatterns: [], - activeCapture: false, - lastCommandAt: Date.now(), - webContentsId, - activeProcess: null - }) - } - - const promise = createSession() - this.pendingSessionCreation.set(sessionName, promise) - try { - await promise - } finally { - this.pendingSessionCreation.delete(sessionName) - } - } - - private async restartSessionForTarget( - sessionName: string, - browserPageId: string, - webContentsId: number, - options: { recreate: boolean } = { recreate: true } - ): Promise { - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - await pendingCreation.catch(() => {}) - } - - const session = this.sessions.get(sessionName) - if (session) { - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - if (session.activeProcess) { - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - await this.runAgentBrowserRaw(sessionName, ['--session', sessionName, 'close'], { - timeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS - }) - } catch { - // Session may already be dead. - } - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - if (options.recreate) { - await this.ensureSession(sessionName, browserPageId, webContentsId) - } - } - - private async destroySession( - sessionName: string, - options: AgentBrowserCleanupOptions = { closeTimeoutMs: AGENT_BROWSER_CLEANUP_TIMEOUT_MS } - ): Promise { - const pendingDestruction = this.pendingSessionDestruction.get(sessionName) - if (pendingDestruction) { - await pendingDestruction - return - } - - const pendingCreation = this.pendingSessionCreation.get(sessionName) - if (pendingCreation) { - // Why: tab close can race session creation before sessions.set(); await it so no late proxy survives the close. - try { - await pendingCreation - } catch { - // Creation failures are handled by the original caller; teardown still rejects queued work below. - } - } - - const session = this.sessions.get(sessionName) - if (!session) { - this.rejectQueuedCommandsForClosedSession(sessionName) - return - } - - this.sessions.delete(sessionName) - this.pendingSessionCreation.delete(sessionName) - - // Why: queued commands would hang forever if we just delete the queue — drain and reject them. - this.rejectQueuedCommandsForClosedSession(sessionName) - - if (session.activeProcess) { - // Why: rejecting the queue isn't enough for an in-flight command — kill the process so callers don't wait out the exec timeout. - this.cancelledProcesses.add(session.activeProcess) - try { - session.activeProcess.kill() - } catch { - // Process may already be exiting. - } - session.activeProcess = null - } - - const destroy = (async (): Promise => { - try { - // Why: each tab has its own named session — close without --session leaves this tab's daemon running. - // Why bounded: this runs inside the 20s will-quit barrier, so it cannot inherit the 90s exec timeout. - await this.runAgentBrowserRaw( - sessionName, - ['--session', sessionName, 'close'], - options.closeTimeoutMs === undefined ? undefined : { timeoutMs: options.closeTimeoutMs } - ) - } catch { - // Session may already be dead - } - - await session.proxy.stop() - })() - this.pendingSessionDestruction.set(sessionName, destroy) - try { - await destroy - } finally { - this.pendingSessionDestruction.delete(sessionName) - } - } - - private rejectQueuedCommandsForClosedSession(sessionName: string): void { - const queue = this.commandQueues.get(sessionName) - this.commandQueues.delete(sessionName) - this.processingQueues.delete(sessionName) - if (queue) { - const err = new BrowserError( - 'browser_tab_closed', - 'Tab was closed while commands were queued' - ) - for (const cmd of queue) { - cmd.reject(err) - } - queue.length = 0 - } - } - - /** - * Notice that the daemon retired itself between two commands. - * - * A replacement daemon still serves the page (every call reasserts `--cdp`) - * but carries none of the session's network routes, so without this the - * interception the caller configured is silently gone (#16367). - */ - private reinitializeIfDaemonIdledOut(sessionName: string, session: SessionState): void { - if ( - this.agentBrowserIdleTimeoutMs === null || - Date.now() - session.lastCommandAt < this.agentBrowserIdleTimeoutMs - ) { - return - } - session.initialized = false - if (session.activeInterceptPatterns.length > 0) { - this.pendingInterceptRestore.set(sessionName, [...session.activeInterceptPatterns]) - } - } - - private assertCommandAdmission(): void { - if (this.shutdownStarted) { - throw new BrowserError('browser_owner_unavailable', 'Browser runtime is shutting down') - } - } - - private async execAgentBrowser( - sessionName: string, - commandArgs: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - const session = this.sessions.get(sessionName) - if (!session) { - // Why: a queued command can run after a concurrent close deleted the session — surface a tab-lifecycle error, not an opaque failure. - throw this.createPageUnavailableError(sessionName) - } - - // Why: the webContents can be destroyed during queue delay — check here to avoid cryptic Electron debugger errors. - if (!this.getWebContents(session.webContentsId)) { - await this.destroySession(sessionName) - throw this.createPageUnavailableError(sessionName) - } - - this.reinitializeIfDaemonIdledOut(sessionName, session) - session.lastCommandAt = Date.now() - - const args = ['--session', sessionName] - const managesInterceptRoutes = - commandArgs[0] === 'network' && (commandArgs[1] === 'route' || commandArgs[1] === 'unroute') - - const needsInit = !session.initialized - // Why: a restarted named daemon auto-launches Chrome unless every invocation reasserts Orca's CDP owner. - args.push('--cdp', String(session.proxy.getPort())) - - // Why: exec passthrough can produce a large argv; spreading into push risks V8 argument limits. - for (const commandArg of commandArgs) { - args.push(commandArg) - } - args.push('--json') - - const stdout = await this.runAgentBrowserRaw(sessionName, args, execOptions) - const translated = translateResult(stdout) - - if (!translated.ok) { - throw this.createCommandError( - sessionName, - translated.error.message, - translated.error.code, - session.webContentsId - ) - } - - // Why: mark initialized only after success, so a failed first --cdp connection retries with --cdp. - if (needsInit) { - session.initialized = true - - // Why: a process swap loses intercept patterns — restore them now unless the caller's first command reconfigured routing. - const pendingPatterns = managesInterceptRoutes - ? undefined - : this.pendingInterceptRestore.get(sessionName) - if (pendingPatterns && pendingPatterns.length > 0) { - this.pendingInterceptRestore.delete(sessionName) - try { - const urlPattern = pendingPatterns[0] ?? '**/*' - await this.runAgentBrowserRaw(sessionName, [ - '--session', - sessionName, - '--cdp', - String(session.proxy.getPort()), - 'network', - 'route', - urlPattern, - '--json' - ]) - session.activeInterceptPatterns = pendingPatterns - } catch { - // Why: intercept restore is best-effort — don't fail the user's command if the new page can't support it. - } - } - } - - return translated.result - } - - private async isExplicitContentEditableTarget( - sessionName: string, - element: string - ): Promise { - const result = await this.execAgentBrowser(sessionName, [ - 'get', - 'attr', - element, - 'contenteditable' - ]) - return isExplicitContentEditableResult(result) - } - - private async fillExplicitContentEditable( - sessionName: string, - element: string, - value: string - ): Promise { - await this.execAgentBrowser(sessionName, ['focus', element]) - // Why: stdin avoids argv limits and keeps replacement atomic; chunked edits can move focus and split a fill across controls. - await this.execAgentBrowser(sessionName, ['eval', '--stdin'], { - stdinText: focusedRichTextEditExpression(JSON.stringify(value), { selectAll: true }) - }) - } - - private createPageUnavailableError(sessionName: string): BrowserError { - return new BrowserError('browser_tab_not_found', pageUnavailableMessageForSession(sessionName)) - } - - private closeStaleAgentBrowserSession(sessionName: string): Promise { - return new Promise((resolve, reject) => { - let child: ReturnType | null = null - let settled = false - - const finish = (error?: Error): void => { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (error) { - reject(error) - } else { - resolve() - } - } - - // Why: proceeding after an unverified close can reuse a daemon that owns an unrelated browser. - const timeout = setTimeout(() => { - child?.kill() - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}; retry after agent-browser exits` - ) - ) - }, STALE_SESSION_CLOSE_TIMEOUT_MS) - - try { - child = execFile( - this.agentBrowserBin, - ['--session', sessionName, 'close'], - // Why windowsHide: agent-browser is console-subsystem and Orca's main - // process owns no console, so each spawn gets a fresh visible conhost - // that takes foreground -- keystrokes typed into a terminal at that - // moment land in the black box (#14543). - { - env: this.agentBrowserEnv, - timeout: STALE_SESSION_CLOSE_TIMEOUT_MS, - windowsHide: true - }, - (error) => - finish( - error - ? new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error.message}` - ) - : undefined - ) - ) - } catch (error) { - finish( - new BrowserError( - 'browser_owner_unavailable', - `Could not reset stale helper session ${sessionName}: ${error instanceof Error ? error.message : String(error)}` - ) - ) - } - }) - } - - private createCommandError( - sessionName: string, - message: string, - fallbackCode: string, - webContentsId?: number - ): BrowserError { - // Why: CDP "connection refused" can also mean a real proxy failure — only map to closed-page when the target is confirmed gone. - if ( - fallbackCode === 'browser_error' && - isTabClosedTransportError(message) && - this.isSessionTargetClosed(sessionName, webContentsId) - ) { - return this.createPageUnavailableError(sessionName) - } - return new BrowserError(fallbackCode, message) - } - - private isSessionTargetClosed(sessionName: string, webContentsId?: number): boolean { - const session = this.sessions.get(sessionName) - if (!session) { - return true - } - const targetWebContentsId = webContentsId ?? session.webContentsId - return !this.getWebContents(targetWebContentsId) - } - - private runAgentBrowserRaw( - sessionName: string, - args: string[], - execOptions?: AgentBrowserExecOptions - ): Promise { - return new Promise((resolve, reject) => { - const session = this.sessions.get(sessionName) - let child: ChildProcess | null = null - child = execFile( - this.agentBrowserBin, - args, - // Why: screenshots return large base64 that exceeds Node's default 1MB maxBuffer (ENOBUFS). - { - timeout: execOptions?.timeoutMs ?? EXEC_TIMEOUT_MS, - maxBuffer: 50 * 1024 * 1024, - // Why windowsHide: see the stale-session close above -- every - // agent-browser invocation would otherwise flash a console (#14543). - windowsHide: true, - env: execOptions?.envOverrides - ? { ...this.agentBrowserEnv, ...execOptions.envOverrides } - : this.agentBrowserEnv - }, - (error, stdout, stderr) => { - if (session && session.activeProcess === child) { - session.activeProcess = null - } - if (child && this.cancelledProcesses.has(child)) { - this.cancelledProcesses.delete(child) - reject( - new BrowserError('browser_tab_closed', 'Tab was closed while command was running') - ) - return - } - - const liveSession = this.sessions.get(sessionName) - - if (error && (error as NodeJS.ErrnoException & { killed?: boolean }).killed) { - if (execOptions?.timeoutError) { - reject(execOptions.timeoutError) - return - } - if (liveSession) { - liveSession.consecutiveTimeouts++ - if (liveSession.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) { - // Why: 3 consecutive timeouts means the daemon is likely stuck — destroy and recreate - this.destroySession(sessionName) - } - } - reject(new BrowserError('browser_error', 'Browser command timed out')) - return - } - - if (liveSession) { - liveSession.consecutiveTimeouts = 0 - } - - if (error) { - // Why: agent-browser exits non-zero on failure but still writes structured JSON to stdout — parse it for the real error. - if (stdout) { - try { - const parsed = JSON.parse(stdout) - if (parsed.error) { - const code = classifyErrorCode(parsed.error) - reject( - this.createCommandError(sessionName, parsed.error, code, session?.webContentsId) - ) - return - } - } catch { - // stdout not valid JSON — fall through to stderr/error.message - } - } - const message = stderr || error.message - const code = classifyErrorCode(message) - reject(this.createCommandError(sessionName, message, code, session?.webContentsId)) - return - } - - resolve(stdout) - } - ) - if (session) { - session.activeProcess = child - } - if (execOptions?.stdinText !== undefined && child?.stdin) { - // Why: eval --stdin keeps paste-sized scripts out of argv on every platform. - child.stdin.on('error', () => {}) - child.stdin.end(execOptions.stdinText) - } - }) - } - - private resolveTabIdSafe(webContentsId: number): string | null { - return this.browserManager.getTabIdForWebContentsId(webContentsId) - } - - private requireTargetWebContents(target: ResolvedBrowserCommandTarget): WebContents { - const wc = this.getWebContents(target.webContentsId) - if (!wc || wc.isDestroyed()) { - throw this.createPageUnavailableError(`${ORCA_TAB_SESSION_PREFIX}${target.browserPageId}`) - } - return wc - } - - private getWebContents(webContentsId: number): Electron.WebContents | null { - try { - const { webContents } = require('electron') - const target = webContents.fromId(webContentsId) - return target && !target.isDestroyed() ? target : null - } catch { - return null - } - } -} +import { AgentBrowserBridgeStateCommands } from './agent-browser-bridge-state-commands' + +export { + AGENT_BROWSER_TEXT_ARGUMENT_MAX_BYTES, + AGENT_BROWSER_CLIPBOARD_WRITE_MAX_BYTES +} from './agent-browser-bridge-types' +export type { + BrowserMouseModifier, + AgentBrowserCleanupOptions, + AgentBrowserBridgeOptions +} from './agent-browser-bridge-types' + +/** + * Routes automation commands to the browser guest registered for the selected tab. + * + * The implementation is layered by responsibility (tab targeting, command queue, + * session lifecycle, and command families) so each layer stays independently + * reviewable while preserving this public facade. + */ +export class AgentBrowserBridge extends AgentBrowserBridgeStateCommands {} diff --git a/src/main/browser/browser-cookie-chromium-finalize.ts b/src/main/browser/browser-cookie-chromium-finalize.ts new file mode 100644 index 00000000000..46203d0d151 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-finalize.ts @@ -0,0 +1,132 @@ +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { browserSessionRegistry } from './browser-session-registry' +import { removeTransplantableCookies } from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write' +import { deriveUrl } from './browser-cookie-validation' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function finalizeChromiumCookieImport( + context: ChromiumImportContext +): Promise { + if (context.decryptedCookies.length === 0) { + const zeroPathWarning = context.undecryptableWarning + context.closeStagingDb() + context.discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: context.sourceRows.length, + importedCookies: 0, + skippedCookies: + context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 + ? { partitionSkippedCookies: context.partitionSkipped } + : {}), + domains: [], + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) + } + } + } + + if (context.stagingDb) { + try { + context.stagingDb.exec('COMMIT') + context.closeStagingDb() + diag( + ` SQLite staging complete: ${context.imported} cookies, ${context.domainSet.size} domains` + ) + } catch (err) { + context.disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${context.imported} cookies will load in-memory only`) + } + + const cookieClearStore = openCookieClearStore(context.targetSession) + try { + await removeTransplantableCookies( + { + cookies: cookieClearStore, + snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), + restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities) + }, + context.nativePlan.skippedFamilies, + context.importScope + ) + diag( + ` cleared existing cookies for ${context.domainSet.size} imported domains before loading ${context.decryptedCookies.length} imported cookies` + ) + + const writable: SourceCookieToWrite[] = [] + for (const cookie of context.decryptedCookies) { + const url = deriveUrl(cookie.domain, cookie.secure) + if (!url) { + context.memoryFailed++ + continue + } + writable.push({ ...cookie, url }) + } + const phase = await writeImportedCookies(cookieClearStore, writable, { + stopOnFailure: false, + log: diag + }) + context.memoryLoaded = phase.importedCount + context.memoryFailed += phase.writeRejected + } finally { + cookieClearStore.dispose() + } + + diag( + ` memory load: ${context.memoryLoaded} OK, ${context.memoryFailed} failed, ${context.partitionSkipped} partition-unreadable` + ) + + let warning: BrowserCookieImportSummary['warning'] + if (context.memoryFailed > 0 && context.stagingAvailable) { + browserSessionRegistry.setPendingCookieImport( + context.targetPartition, + context.stagingCookiesPath + ) + diag( + ` staged at ${context.stagingCookiesPath} for ${context.memoryFailed} cookies that need restart` + ) + } else if (context.memoryFailed > 0) { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(` ${context.memoryFailed} cookies need a restart but staging is unavailable — skipped`) + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: context.memoryLoaded, + failedCookies: context.memoryFailed + } + } else { + browserSessionRegistry.clearPendingCookieImport(context.targetPartition) + context.discardStagingFile() + diag(' all cookies loaded in-memory — no restart needed') + } + + if (!warning && context.undecryptableWarning) { + warning = context.undecryptableWarning + } + + const summary: BrowserCookieImportSummary = { + totalCookies: context.sourceRows.length, + importedCookies: context.imported, + skippedCookies: context.skipped + context.integritySkipped + context.nonTransplantableSkipped, + ...(context.googleCookiesSkipped > 0 + ? { googleCookiesSkipped: context.googleCookiesSkipped } + : {}), + ...(context.partitionSkipped > 0 ? { partitionSkippedCookies: context.partitionSkipped } : {}), + domains: [...context.domainSet].sort(), + ...(warning ? { warning } : {}) + } + return { ok: true, profileId: '', summary } +} diff --git a/src/main/browser/browser-cookie-chromium-import.ts b/src/main/browser/browser-cookie-chromium-import.ts new file mode 100644 index 00000000000..e9ee2ebe386 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-import.ts @@ -0,0 +1,74 @@ +import { session } from 'electron' +import { existsSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { withCookieMutationLock } from './browser-cookie-import-clear' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import { prepareChromiumCookieImport } from './browser-cookie-chromium-prepare' +import { scanChromiumCookieRows } from './browser-cookie-chromium-scan' +import { finalizeChromiumCookieImport } from './browser-cookie-chromium-finalize' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +export async function importChromiumCookies( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions = {} +): Promise { + diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) + if (!existsSync(browser.cookiesPath)) { + diag(` cookies DB not found: ${browser.cookiesPath}`) + return { ok: false, reason: `${browser.label} cookies database not found.` } + } + + const targetSession = session.fromPartition(targetPartition) + return withCookieMutationLock(targetSession, async () => { + let context: ChromiumImportContext | null = null + try { + const preparation = await prepareChromiumCookieImport( + browser, + targetPartition, + options, + targetSession + ) + if ('result' in preparation) { + return preparation.result + } + context = preparation.context + const scanResult = scanChromiumCookieRows(context) + if (scanResult) { + return scanResult + } + return await finalizeChromiumCookieImport(context) + } catch (err) { + if (context) { + try { + context.sourceDb?.close() + } catch { + /* may already be closed */ + } + context.closeStagingDb() + context.discardStagingFile() + } + diag(` SQLite import failed: ${String(err)}`) + return { + ok: false, + reason: reasonWithDiagLog( + `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` + ) + } + } finally { + if (context) { + try { + context.sourceSnapshot.cleanup() + } catch (err) { + diag(` Chromium snapshot cleanup failed: ${String(err)}`) + } + } + } + }) +} diff --git a/src/main/browser/browser-cookie-chromium-prepare.ts b/src/main/browser/browser-cookie-chromium-prepare.ts new file mode 100644 index 00000000000..d9cf4d30ba3 --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-prepare.ts @@ -0,0 +1,289 @@ +import { app } from 'electron' +import { randomUUID } from 'node:crypto' +import { mkdirSync, unlinkSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain +} from './browser-cookie-import-policy' +import { createChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' +import { planImportWrites } from './browser-cookie-import-write' +import { readChromiumRowPartition } from './browser-cookie-source-partition' +import { diag } from './browser-cookie-import-diagnostics' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { ChromiumCookieColumnInfo } from './browser-cookie-sqlite' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' +import type { Session } from 'electron' +import { getEncryptionKey } from './browser-cookie-key' + +export type ChromiumImportPreparation = + | { context: ChromiumImportContext } + | { result: BrowserCookieImportResult } + +export async function prepareChromiumCookieImport( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions, + targetSession: Session +): Promise { + await targetSession.cookies.flushStore() + const partitionDir = targetSession.getStoragePath() + if (!partitionDir) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const partitionName = targetPartition.replace('persist:', '') + let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + // Why: initialize an unused profile so Chromium creates its Cookies database. + if (!liveCookiesPath) { + try { + await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) + await targetSession.cookies.remove('https://localhost', '__init') + await targetSession.cookies.flushStore() + } catch { + // ignore — flushStore still creates the file on supported Electron versions + } + liveCookiesPath = resolveChromiumCookiesPath(partitionDir) + } + if (!liveCookiesPath) { + return { + result: { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } + } + } + + const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') + const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') + const stagingCookiesPath = join( + stagingDir, + `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` + ) + let stagingAvailable = false + if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { + diag(` restart fallback unsupported for partition "${targetPartition}" — not staging cookies`) + } else { + try { + mkdirSync(stagingDir, { recursive: true }) + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } + } + + let sourceSnapshot: ReturnType + try { + // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. + sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) + } catch (err) { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + diag(` Chromium snapshot failed: ${String(err)}`) + return { + result: { + ok: false, + reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` + } + } + } + + let sourceDb: InstanceType | null = null + let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagingCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + + sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true }) + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + stagingDb.exec('PRAGMA journal_mode = DELETE') + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols = targetColumnInfo.map((row) => row.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${String(err)}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + discardStagingFile() + } + } + + const sourceColumns = new Set( + (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( + (column) => column.name + ) + ) + const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< + string, + unknown + >[] + sourceDb.close() + sourceDb = null + diag(` source has ${sourceRows.length} cookies`) + if (sourceRows.length === 0) { + closeStagingDb() + discardStagingFile() + return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } } + } + + const partitionCandidates = sourceRows.flatMap((sourceRow) => { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) + ? [] + : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] + }) + const nativePlan = planImportWrites(partitionCandidates) + const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) + const partitionBySourceRow = new Map( + partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) + ) + if (nativePlan.hasUnrepresentableSkip) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + } + + const needsSourceKey = sourceRows.some((sourceRow) => { + const encrypted = sourceRow.encrypted_value + if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) { + return false + } + return ( + !isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) && + !isNonTransplantableCookieDomain(sourceRow.host_key as string) + ) + }) + const sourceKey = needsSourceKey + ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) + : null + if (needsSourceKey && !sourceKey) { + closeStagingDb() + discardStagingFile() + return { + result: { + ok: false, + reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` + } + } + } + + let insertStmt: ChromiumImportContext['insertStmt'] = null + const context: ChromiumImportContext = { + browser, + targetPartition, + options, + targetSession, + stagingCookiesPath, + stagingAvailable, + sourceSnapshot, + sourceDb, + stagingDb, + targetColumnInfo, + colList, + placeholders, + sourceColumns, + sourceRows, + nativePlan, + plannedSourceRows, + partitionBySourceRow, + sourceKey, + imported: 0, + skipped: 0, + decryptFailed: 0, + appBoundFailed: 0, + keyringUnavailableFailed: 0, + integritySkipped: 0, + nonTransplantableSkipped: 0, + partitionSkipped: nativePlan.skips.length, + googleCookiesSkipped: 0, + memoryLoaded: 0, + memoryFailed: 0, + domainSet: new Set(), + decryptedCookies: [], + scanned: [], + sourceDomainValidity: new Map(), + insertStmt, + importScope: { + exact: new Set(), + ancestors: new Set(), + descendantRoots: new Set() + }, + closeStagingDb, + discardStagingFile, + disableStaging: (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + context.stagingAvailable = false + context.insertStmt = null + context.closeStagingDb() + context.discardStagingFile() + } + } satisfies ChromiumImportContext + + if (context.stagingDb && context.colList && context.placeholders) { + try { + context.insertStmt = context.stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})` + ) + context.stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + context.disableStaging(String(err)) + } + } else if (context.stagingAvailable) { + context.disableStaging('staged database exposed no cookies columns') + } + if (context.nativePlan.skippedFamilies.size > 0) { + context.disableStaging( + `${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` + ) + } + return { context } +} diff --git a/src/main/browser/browser-cookie-chromium-scan.ts b/src/main/browser/browser-cookie-chromium-scan.ts new file mode 100644 index 00000000000..dd7e061bebc --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-scan.ts @@ -0,0 +1,167 @@ +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + importedDomainScope +} from './browser-cookie-import-policy' +import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' +import { chromiumTimestampToUnix, buildChromiumCookieInsertParams } from './browser-cookie-sqlite' +import { chromiumSameSite } from './browser-cookie-validation' +import { + buildUndecryptableWarning, + cookieEncryptionVersion, + decryptCookieValueRaw +} from './browser-cookie-decryption' +import { diag } from './browser-cookie-import-diagnostics' +import type { ChromiumImportContext } from './browser-cookie-chromium-types' + +/** + * Decrypts and validates source rows without touching the target cookie jar. + * Keeping this pass separate makes the write scope derive from one complete plan. + */ +export function scanChromiumCookieRows( + context: ChromiumImportContext +): BrowserCookieImportResult | null { + const { sourceRows, sourceKey, plannedSourceRows, partitionBySourceRow, targetColumnInfo } = + context + + for (const sourceRow of sourceRows) { + const domain = sourceRow.host_key as string + const name = sourceRow.name as string + + if (isGoogleSourceBoundCookie(name, domain)) { + context.integritySkipped++ + continue + } + if (isNonTransplantableCookieDomain(domain)) { + context.nonTransplantableSkipped++ + continue + } + + const encRaw = sourceRow.encrypted_value + const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null + const plainRaw = sourceRow.value + let decryptedValue: Buffer + if (encBuf && encBuf.length > 0) { + const version = cookieEncryptionVersion(encBuf) + const appBoundIneligible = version === 'v20' + const keyringIneligible = + version === 'v11' && + sourceKey?.mode === 'aes-128-cbc' && + sourceKey.keyringUnavailable === true + const raw = + sourceKey && !appBoundIneligible && !keyringIneligible + ? decryptCookieValueRaw(encBuf, sourceKey) + : null + if (!raw) { + // Why: retain the prefix while it is available so diagnostics identify the failure cause. + context.decryptFailed++ + if (appBoundIneligible) { + context.appBoundFailed++ + } else if (keyringIneligible) { + context.keyringUnavailableFailed++ + } + context.skipped++ + continue + } + decryptedValue = raw + } else if (plainRaw instanceof Uint8Array) { + decryptedValue = Buffer.from(plainRaw) + } else if (typeof plainRaw === 'string') { + decryptedValue = Buffer.from(plainRaw, 'latin1') + } else { + decryptedValue = Buffer.alloc(0) + } + + let validDomain = context.sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + context.sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + context.skipped++ + continue + } + // Decryption failures are counted above; planned family omissions are counted once here. + if (!plannedSourceRows.has(sourceRow)) { + context.skipped++ + continue + } + + const path = sourceRow.path as string + const secure = sourceRow.is_secure === 1n + const httpOnly = sourceRow.is_httponly === 1n + const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) + const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) + const partition = partitionBySourceRow.get(sourceRow)! + const value = decryptedValue.toString('latin1') + context.scanned.push({ + entry: { + decryptedValue, + value, + domain, + name, + path, + secure, + httpOnly, + sameSite, + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition + }, + sourceRow + }) + } + + for (const { entry } of context.scanned) { + context.domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) + } + context.importScope = importedDomainScope([...context.domainSet]) + + if (context.stagingDb && context.insertStmt) { + try { + prepareStagedCookiesForImport(context.stagingDb, context.importScope) + } catch (err) { + context.disableStaging(String(err)) + } + } + + // EMIT: all downstream writes derive from the one scan, so no row can leak into the jar. + for (const { entry, sourceRow } of context.scanned) { + context.decryptedCookies.push(entry) + if (context.insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + entry.decryptedValue + ) + context.insertStmt.run(...params) + } catch (err) { + context.disableStaging(String(err)) + } + } + context.imported++ + } + + diag( + ` skipped ${context.integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${context.nonTransplantableSkipped} non-transplantable-domain cookies` + ) + context.googleCookiesSkipped = context.integritySkipped + context.nonTransplantableSkipped + context.undecryptableWarning = buildUndecryptableWarning({ + decryptFailed: context.decryptFailed, + appBoundFailed: context.appBoundFailed, + keyringUnavailableFailed: context.keyringUnavailableFailed + }) + + if (context.partitionSkipped > 0 && context.options.canReportPartitionSkippedCookies === false) { + context.closeStagingDb() + context.discardStagingFile() + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + return null +} diff --git a/src/main/browser/browser-cookie-chromium-types.ts b/src/main/browser/browser-cookie-chromium-types.ts new file mode 100644 index 00000000000..9bf792de01c --- /dev/null +++ b/src/main/browser/browser-cookie-chromium-types.ts @@ -0,0 +1,83 @@ +import type { Session } from 'electron' +import type { DatabaseSync } from 'node:sqlite' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import type { CookieImportOptions } from './browser-cookie-import-pipeline' +import type { + ImportedCookieFields, + ImportWritePhase, + SourceCookieToWrite +} from './browser-cookie-import-write' +import type { SourcePartitionRead } from './browser-cookie-source-partition' +import type { ImportedDomainScope } from './browser-cookie-import-policy' +import type { ChromiumCookieSnapshot } from './chromium-cookie-snapshot' +import type { ChromiumCookieColumnInfo, EncryptionKeyResult } from './browser-cookie-sqlite' + +export type ChromiumSourceRow = Record + +export type DecryptedCookie = Omit & { + decryptedValue: Buffer + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +export type ScannedChromiumCookie = { + entry: DecryptedCookie + sourceRow: ChromiumSourceRow +} + +export type ChromiumImportPlan = { + writes: { sourceRow: ChromiumSourceRow; domain: string; partition: SourcePartitionRead }[] + skips: unknown[] + skippedFamilies: Set + hasUnrepresentableSkip: boolean +} + +export type ChromiumImportContext = { + browser: DetectedBrowser + targetPartition: string + options: CookieImportOptions + targetSession: Session + stagingCookiesPath: string + stagingAvailable: boolean + sourceSnapshot: ChromiumCookieSnapshot + sourceDb: InstanceType | null + stagingDb: InstanceType | null + targetColumnInfo: ChromiumCookieColumnInfo[] | null + colList: string | null + placeholders: string | null + sourceColumns: Set + sourceRows: ChromiumSourceRow[] + nativePlan: ChromiumImportPlan + plannedSourceRows: Set + partitionBySourceRow: Map + sourceKey: EncryptionKeyResult | null + imported: number + skipped: number + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number + integritySkipped: number + nonTransplantableSkipped: number + partitionSkipped: number + googleCookiesSkipped: number + memoryLoaded: number + memoryFailed: number + domainSet: Set + decryptedCookies: DecryptedCookie[] + scanned: ScannedChromiumCookie[] + sourceDomainValidity: Map + insertStmt: ReturnType['prepare']> | null + importScope: ImportedDomainScope + closeStagingDb: () => void + discardStagingFile: () => void + disableStaging: (reason: string) => void + undecryptableWarning?: BrowserCookieImportSummary['warning'] + warning?: BrowserCookieImportSummary['warning'] + writePhase?: ImportWritePhase + writable?: SourceCookieToWrite[] + result?: BrowserCookieImportResult +} diff --git a/src/main/browser/browser-cookie-decryption.ts b/src/main/browser/browser-cookie-decryption.ts new file mode 100644 index 00000000000..04fb002f7f4 --- /dev/null +++ b/src/main/browser/browser-cookie-decryption.ts @@ -0,0 +1,126 @@ +import { createDecipheriv } from 'node:crypto' +import type { BrowserCookieImportSummary } from '../../shared/browser-workspace-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. +const CHROMIUM_COOKIE_HMAC_LEN = 32 + +function hasHmacPrefix(buf: Buffer): boolean { + if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { + return false + } + let nonPrintable = 0 + for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { + if (buf[i] < 0x20 || buf[i] > 0x7e) { + nonPrintable++ + } + } + return nonPrintable >= 8 +} + +function stripHmac(buf: Buffer): Buffer { + return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf +} + +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +export function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: a named cause must carry only its exact count; tied causes fall back to unknown. +export function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const unknownFailed = + counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed + const rankedCauses = [ + { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, + { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, + { reason: 'unknown' as const, count: unknownFailed } + ].sort((left, right) => right.count - left.count) + const [dominant, runnerUp] = rankedCauses + + if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } + } + + const otherFailedCookies = counts.decryptFailed - dominant.count + return { + code: 'cookies-undecryptable', + failedCookies: dominant.count, + reason: dominant.reason, + ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) + } +} + +export function decryptCookieValueRaw( + encryptedBuffer: Buffer, + keyResult: EncryptionKeyResult +): Buffer | null { + if (!encryptedBuffer || encryptedBuffer.length === 0) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + if (!/^v\d\d$/.test(version)) { + return null + } + + if (keyResult.mode === 'aes-256-gcm') { + return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) + } + + // AES-128-CBC (macOS and Linux) + const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined + if (!key) { + return null + } + + const ciphertext = encryptedBuffer.subarray(3) + if (!ciphertext.length) { + return null + } + + try { + const iv = Buffer.alloc(16, ' ') + const decipher = createDecipheriv('aes-128-cbc', key, iv) + decipher.setAutoPadding(true) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} + +function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { + // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] + if (payload.length < 12 + 16) { + return null + } + const nonce = payload.subarray(0, 12) + const authTag = payload.subarray(-16) + const ciphertext = payload.subarray(12, -16) + try { + const decipher = createDecipheriv('aes-256-gcm', key, nonce) + decipher.setAuthTag(authTag) + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + return stripHmac(decrypted) + } catch { + return null + } +} diff --git a/src/main/browser/browser-cookie-detection-types.ts b/src/main/browser/browser-cookie-detection-types.ts new file mode 100644 index 00000000000..bac46f6d750 --- /dev/null +++ b/src/main/browser/browser-cookie-detection-types.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type { BrowserSessionProfileSource } from '../../shared/browser-workspace-types' + +export type BrowserProfile = { + name: string + directory: string +} + +export type DetectedBrowser = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + cookiesPath: string + keychainService?: string + keychainAccount?: string + profiles: BrowserProfile[] + selectedProfile: string +} + +export type ChromiumBrowserDef = { + family: BrowserSessionProfileSource['browserFamily'] + label: string + keychainService: string + keychainAccount: string + // Per-platform data-dir roots, resolved at detection time via browserRootPath(). + macRoot?: string + winRoot?: string + linuxRoot?: string +} + +export const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ + { + family: 'chrome', + label: 'Google Chrome', + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + macRoot: 'Google/Chrome', + winRoot: 'Google/Chrome/User Data', + linuxRoot: 'google-chrome' + }, + { + family: 'edge', + label: 'Microsoft Edge', + keychainService: 'Microsoft Edge Safe Storage', + keychainAccount: 'Microsoft Edge', + macRoot: 'Microsoft Edge', + winRoot: 'Microsoft/Edge/User Data', + linuxRoot: 'microsoft-edge' + }, + { + family: 'arc', + label: 'Arc', + keychainService: 'Arc Safe Storage', + keychainAccount: 'Arc', + macRoot: 'Arc/User Data' + }, + { + family: 'chromium', + label: 'Brave', + keychainService: 'Brave Safe Storage', + keychainAccount: 'Brave', + macRoot: 'BraveSoftware/Brave-Browser', + winRoot: 'BraveSoftware/Brave-Browser/User Data', + linuxRoot: 'BraveSoftware/Brave-Browser' + }, + { + family: 'comet', + label: 'Comet', + keychainService: 'Comet Safe Storage', + keychainAccount: 'Comet', + macRoot: 'Comet', + winRoot: 'Comet/User Data' + // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 + }, + { + family: 'helium', + // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. + label: 'Helium', + keychainService: 'Helium Storage Key', + keychainAccount: 'Helium', + macRoot: 'net.imput.helium' + // winRoot/linuxRoot intentionally omitted — only the macOS install is verified + } +] + +export function browserRootPath(def: ChromiumBrowserDef): string | null { + if (process.platform === 'darwin') { + if (!def.macRoot) { + return null + } + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', def.macRoot) + } + if (process.platform === 'win32') { + if (!def.winRoot) { + return null + } + const localAppData = process.env.LOCALAPPDATA ?? '' + if (!localAppData) { + return null + } + return join(localAppData, def.winRoot) + } + // Linux + if (!def.linuxRoot) { + return null + } + const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') + return join(configHome, def.linuxRoot) +} + +export function isSafeBrowserProfileDirectory(directory: string): boolean { + return ( + directory.length > 0 && + directory !== '.' && + !directory.includes('\0') && + !directory.includes('/') && + !directory.includes('\\') && + !directory.includes('..') + ) +} + +// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. +export function discoverProfiles(browserRoot: string): BrowserProfile[] { + try { + const localStatePath = join(browserRoot, 'Local State') + if (!existsSync(localStatePath)) { + return [{ name: 'Default', directory: 'Default' }] + } + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const infoCache = localState?.profile?.info_cache + if (!infoCache || typeof infoCache !== 'object') { + return [{ name: 'Default', directory: 'Default' }] + } + const profiles: BrowserProfile[] = [] + for (const [dir, info] of Object.entries(infoCache)) { + // Why: Local State is external metadata, but profile dirs become path segments. + if (!isSafeBrowserProfileDirectory(dir)) { + continue + } + const profileName = (info as { name?: string })?.name ?? dir + profiles.push({ name: profileName, directory: dir }) + } + return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] + } catch { + return [{ name: 'Default', directory: 'Default' }] + } +} + +// --------------------------------------------------------------------------- +// Firefox detection +// --------------------------------------------------------------------------- + +export function firefoxProfilesRoot(): string | null { + if (process.platform === 'darwin') { + const home = process.env.HOME ?? '' + return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA ?? '' + return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null + } + const home = process.env.HOME ?? '' + return join(home, '.mozilla', 'firefox') +} + +export function discoverFirefoxProfiles(): BrowserProfile[] { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return [] + } + try { + if (!existsSync(profilesRoot)) { + return [] + } + const entries = readdirSync(profilesRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. + const sorted = entries.sort((a, b) => { + if (a.includes('default-release')) { + return -1 + } + if (b.includes('default-release')) { + return 1 + } + if (a.includes('default')) { + return -1 + } + if (b.includes('default')) { + return 1 + } + return 0 + }) + return sorted.map((dir) => { + const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir + return { name: label, directory: dir } + }) + } catch { + return [] + } +} + +export function detectFirefox(): DetectedBrowser | null { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const profiles = discoverFirefoxProfiles() + for (const profile of profiles) { + const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') + if (existsSync(cookiesPath)) { + return { + family: 'firefox', + label: 'Firefox', + cookiesPath, + profiles, + selectedProfile: profile.directory + } + } + } + return null +} diff --git a/src/main/browser/browser-cookie-detection.ts b/src/main/browser/browser-cookie-detection.ts new file mode 100644 index 00000000000..78c5d7c0ed8 --- /dev/null +++ b/src/main/browser/browser-cookie-detection.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { + CHROMIUM_BROWSERS, + browserRootPath, + discoverProfiles, + detectFirefox, + firefoxProfilesRoot, + isSafeBrowserProfileDirectory, + type DetectedBrowser +} from './browser-cookie-detection-types' + +// --------------------------------------------------------------------------- +// Safari detection +// --------------------------------------------------------------------------- + +export function detectSafari(): DetectedBrowser | null { + if (process.platform !== 'darwin') { + return null + } + const home = process.env.HOME ?? '' + const candidates = [ + join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), + join( + home, + 'Library', + 'Containers', + 'com.apple.Safari', + 'Data', + 'Library', + 'Cookies', + 'Cookies.binarycookies' + ) + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return { + family: 'safari', + label: 'Safari', + cookiesPath: candidate, + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } + } + } + return null +} + +export function detectInstalledBrowsers(): DetectedBrowser[] { + const detected: DetectedBrowser[] = [] + for (const browser of CHROMIUM_BROWSERS) { + const root = browserRootPath(browser) + if (!root) { + continue + } + const profiles = discoverProfiles(root) + // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. + for (const profile of profiles) { + const profileDir = join(root, profile.directory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (cookiesPath) { + detected.push({ + family: browser.family, + label: browser.label, + keychainService: browser.keychainService, + keychainAccount: browser.keychainAccount, + cookiesPath, + profiles, + selectedProfile: profile.directory + }) + break + } + } + } + + const firefox = detectFirefox() + if (firefox) { + detected.push(firefox) + } + + const safari = detectSafari() + if (safari) { + detected.push(safari) + } + + return detected +} + +export function selectBrowserProfile( + browser: DetectedBrowser, + profileDirectory: string +): DetectedBrowser | null { + if (!isSafeBrowserProfileDirectory(profileDirectory)) { + return null + } + if (browser.family === 'firefox') { + const profilesRoot = firefoxProfilesRoot() + if (!profilesRoot) { + return null + } + const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') + if (!existsSync(cookiesPath)) { + return null + } + return { ...browser, cookiesPath, selectedProfile: profileDirectory } + } + + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + const profileDir = join(root, profileDirectory) + const cookiesPath = resolveChromiumCookiesPath(profileDir) + if (!cookiesPath) { + return null + } + return { + ...browser, + cookiesPath, + selectedProfile: profileDirectory + } +} diff --git a/src/main/browser/browser-cookie-firefox-import.ts b/src/main/browser/browser-cookie-firefox-import.ts new file mode 100644 index 00000000000..60482b30e31 --- /dev/null +++ b/src/main/browser/browser-cookie-firefox-import.ts @@ -0,0 +1,138 @@ +import { DatabaseSync } from 'node:sqlite' +import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { readFirefoxRowPartition } from './browser-cookie-source-partition' +import { + importValidatedCookies, + cookieImportTarget, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { deriveUrl, firefoxSameSite, type ValidatedCookie } from './browser-cookie-validation' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Firefox import +// --------------------------------------------------------------------------- + +export async function importCookiesFromFirefox( + browser: DetectedBrowser, + targetPartition: string, + options: CookieImportOptions +): Promise { + diag(`importCookiesFromFirefox: partition="${targetPartition}"`) + + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) + const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') + + try { + copyFileSync(browser.cookiesPath, tmpCookiesPath) + for (const suffix of ['-wal', '-shm'] as const) { + const sidecar = browser.cookiesPath + suffix + if (existsSync(sidecar)) { + try { + copyFileSync(sidecar, tmpCookiesPath + suffix) + } catch { + /* best-effort */ + } + } + } + } catch { + rmSync(tmpDir, { recursive: true, force: true }) + return { + ok: false, + reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' + } + } + + try { + const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) + type FirefoxRow = Record & { + name: string + value: string + host: string + path: string + expiry: number + isSecure: number + isHttpOnly: number + sameSite: number + isPartitionedAttributeSet?: number + } + // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema + // without the server-declared partition flag predates that cookie identity. + const firefoxColumns = new Set( + (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( + (column) => column.name + ) + ) + const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') + ? ', isPartitionedAttributeSet' + : '' + const rows = db + .prepare( + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` + ) + .all() as FirefoxRow[] + db.close() + + diag(` Firefox source has ${rows.length} cookies`) + if (rows.length === 0) { + rmSync(tmpDir, { recursive: true, force: true }) + return { ok: false, reason: 'No cookies found in Firefox.' } + } + + const now = Math.floor(Date.now() / 1000) + const validated: ValidatedCookie[] = [] + for (const row of rows) { + if (!row.name || !row.host) { + continue + } + if (row.expiry > 0 && row.expiry < now) { + continue + } + + const domain = row.host + const secure = row.isSecure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + continue + } + + validated.push({ + url, + name: row.name, + value: row.value ?? '', + domain, + path: row.path || '/', + secure, + httpOnly: row.isHttpOnly === 1, + sameSite: firefoxSameSite(row.sameSite), + expirationDate: row.expiry > 0 ? row.expiry : undefined, + partition: readFirefoxRowPartition(row, firefoxColumns) + }) + } + + rmSync(tmpDir, { recursive: true, force: true }) + + if (validated.length === 0) { + return { ok: false, reason: 'No valid cookies found in Firefox.' } + } + + return importValidatedCookies( + validated, + rows.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains', + options + ) + } catch (err) { + rmSync(tmpDir, { recursive: true, force: true }) + diag(` Firefox import failed: ${String(err)}`) + return { + ok: false, + reason: 'Could not import cookies from Firefox. Try closing Firefox first.' + } + } +} diff --git a/src/main/browser/browser-cookie-import-diagnostics.ts b/src/main/browser/browser-cookie-import-diagnostics.ts new file mode 100644 index 00000000000..f80850fc58e --- /dev/null +++ b/src/main/browser/browser-cookie-import-diagnostics.ts @@ -0,0 +1,55 @@ +import { app } from 'electron' +import { appendFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. +let _diagLog: string | null = null +export function getDiagLogPath(): string { + if (!_diagLog) { + try { + _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') + } catch { + _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') + } + } + return _diagLog +} +export function reasonWithDiagLog(reason: string): string { + return `${reason} Details were written to ${getDiagLogPath()}.` +} +const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 +const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 + +// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. +export function summarizeCookieImportError(err: unknown): string { + const raw = err instanceof Error && err.message ? err.message : String(err) + let summary = '' + let previousWasWhitespace = false + const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) + for (let index = 0; index < scanLimit; index += 1) { + const code = raw.charCodeAt(index) + if (code === 32 || (code >= 9 && code <= 13)) { + if (summary.length > 0 && !previousWasWhitespace) { + summary += ' ' + } + previousWasWhitespace = true + continue + } + summary += raw.charAt(index) + if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { + return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) + } + previousWasWhitespace = false + } + return summary +} +export function diag(msg: string): void { + const line = `[${new Date().toISOString()}] ${msg}\n` + try { + appendFileSync(getDiagLogPath(), line) + } catch { + /* best-effort */ + } + console.log('[cookie-import]', msg) +} diff --git a/src/main/browser/browser-cookie-import-pipeline.ts b/src/main/browser/browser-cookie-import-pipeline.ts new file mode 100644 index 00000000000..c1d5deb769b --- /dev/null +++ b/src/main/browser/browser-cookie-import-pipeline.ts @@ -0,0 +1,300 @@ +import { dialog, session, type BrowserWindow } from 'electron' +import type { + BrowserCookieImportResult, + BrowserCookieImportSummary +} from '../../shared/browser-workspace-types' +import { + isGoogleSourceBoundCookie, + isNonTransplantableCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + type CookieImportMode, + type ReplacedImportedDomainCookies +} from './browser-cookie-import-policy' +import { + acquireCookieMutationLock, + type CookieClearStore, + type CookieImportWriteStore +} from './browser-cookie-import-clear' +import { openCookieClearStore } from './browser-cookie-clear-store' +import { + emptyImportWritePhase, + planImportWrites, + writeImportedCookies, + type ImportWritePhase +} from './browser-cookie-import-write' +import { readFile } from 'node:fs/promises' +import { + diag, + reasonWithDiagLog, + summarizeCookieImportError +} from './browser-cookie-import-diagnostics' +import { + validateCookieEntry, + type RawCookieEntry, + type ValidatedCookie +} from './browser-cookie-validation' + +// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so +// the partition-dropping write is not merely unused here — it cannot be reached. +export type CookieImportSessionStore = CookieClearStore & + CookieImportWriteStore & { dispose: () => void } + +export type CookieImportTarget = { + partition: string + // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the + // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME + // instance for one partition string, so carrying that instance here is what keeps this path's + // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. + mutationLockOwner: object + openWriteStore: () => CookieImportSessionStore +} + +export type CookieImportOptions = { + canReportPartitionSkippedCookies?: boolean +} + +export function cookieImportTarget(targetPartition: string): CookieImportTarget { + const targetSession = session.fromPartition(targetPartition) + return { + partition: targetPartition, + mutationLockOwner: targetSession, + openWriteStore: () => openCookieClearStore(targetSession) + } +} + +export async function importValidatedCookies( + cookies: ValidatedCookie[], + totalInput: number, + target: CookieImportTarget, + mode: CookieImportMode, + options: CookieImportOptions = {} +): Promise { + const targetPartition = target.partition + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const sourceBoundFiltered = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + // Why: dropping these before the replace scope is computed is what keeps the existing + // Google session intact — replaceCookiesForImportedDomains only clears domains we import. + const importableCookies = sourceBoundFiltered.filter( + (cookie) => !isNonTransplantableCookieDomain(cookie.domain) + ) + const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length + const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length + const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const invalidDomainSkipped = cookies.length - validDomainCookies.length + diag( + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` + ) + // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is + // the single value the write set AND the removal scope both derive from, so they cannot drift + // apart the way they did in bf6dc6fcba. + const plan = planImportWrites(importableCookies) + + // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and + // clearing a family we cannot protect is the P0. Refuse before touching anything. + if (plan.hasUnrepresentableSkip) { + return { + ok: false, + reason: + 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' + } + } + + // Why: an older remote client cannot surface this skip, so fail before opening the target jar. + if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { + return { + ok: false, + reason: + 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' + } + } + // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a + // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or + // totalCookies === importedCookies + skippedCookies silently stops holding. + const partitionSkipped = plan.skips.length + let skipped = totalInput - importableCookies.length + partitionSkipped + let phase: ImportWritePhase = emptyImportWritePhase() + // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only + // they carry partitionKey. cookies.set drops it silently, on the success path as well. + const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null + + if (cookieClearStore) { + // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. + // Releasing after the replace lets a second import interleave, so this run's rollback could + // remove cookies the newer import already wrote and reported as imported. Taken AFTER the + // store is opened on purpose — openWriteStore only builds the adapter, it attaches no + // debugger, so holding it while queued cannot deadlock against the holder. + const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) + let replaced: ReplacedImportedDomainCookies | null = null + try { + if (mode === 'replace-imported-domains') { + try { + // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact + // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain + // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's + // live session into the removal scope with nothing written back. plan.writes is already + // family-closed, and using the same array for both makes them impossible to diverge. + const replacementDomains = plan.writes.map((cookie) => cookie.domain) + replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) + diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } + + // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. + const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + phase = await writeImportedCookies( + cookieClearStore, + plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), + { stopOnFailure: replaced !== null, log: diag } + ) + // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable + // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned + // writes reach the writer, so the count comes from the plan and is added exactly once. + skipped += phase.writeRejected + + if (phase.failure && replaced) { + const rollbackFailures: unknown[] = [] + for (const cookie of phase.attemptedKeys.toReversed()) { + try { + await cookieClearStore.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty + // restore set would spin up a hidden BrowserWindow to put nothing back. + if (replaced.identities.length > 0) { + try { + await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) + } catch (err) { + rollbackFailures.push(err) + } + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') + } + } + } finally { + try { + cookieClearStore.dispose() + } finally { + releaseMutationLock() + } + } + } + + diag( + `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` + ) + + const summary: BrowserCookieImportSummary = { + totalCookies: totalInput, + importedCookies: phase.importedCount, + skippedCookies: skipped, + ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), + domains: [...phase.domains].sort() + } + + return { ok: true, profileId: '', summary } +} + +// --------------------------------------------------------------------------- +// Import from JSON file +// --------------------------------------------------------------------------- + +// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. +export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { + const opts = { + title: 'Import Cookies', + filters: [ + { name: 'Cookie Files', extensions: ['json'] }, + { name: 'All Files', extensions: ['*'] } + ], + properties: ['openFile' as const] + } + const result = parentWindow + ? await dialog.showOpenDialog(parentWindow, opts) + : await dialog.showOpenDialog(opts) + + if (result.canceled || result.filePaths.length === 0) { + return null + } + return result.filePaths[0] +} + +export async function importCookiesFromFile( + filePath: string, + targetPartition: string +): Promise { + let rawContent: string + try { + rawContent = await readFile(filePath, 'utf-8') + } catch { + return { ok: false, reason: 'Could not read the selected file.' } + } + + let parsed: unknown + try { + parsed = JSON.parse(rawContent) + } catch { + return { ok: false, reason: 'File is not valid JSON.' } + } + + if (!Array.isArray(parsed)) { + return { ok: false, reason: 'Expected a JSON array of cookie objects.' } + } + + if (parsed.length === 0) { + return { ok: false, reason: 'Cookie file is empty.' } + } + + const validated: ValidatedCookie[] = [] + let skipped = 0 + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + skipped++ + continue + } + const cookie = validateCookieEntry(entry as RawCookieEntry) + if (cookie) { + validated.push(cookie) + } else { + skipped++ + } + } + + if (validated.length === 0) { + return { + ok: false, + reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` + } + } + + return importValidatedCookies( + validated, + parsed.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) +} diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index f36efb3fc4f..52662b2366d 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -4,7 +4,7 @@ import type * as NodeFs from 'node:fs' const { appGetPathMock, copyFileSyncMock, - execFileSyncMock, + runProcessSyncMock, sessionFromPartitionMock, dialogShowOpenDialogMock, setPendingCookieImportMock, @@ -13,7 +13,7 @@ const { } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), - execFileSyncMock: vi.fn(), + runProcessSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), dialogShowOpenDialogMock: vi.fn(), setPendingCookieImportMock: vi.fn(), @@ -28,7 +28,11 @@ vi.mock('./browser-session-registry', () => ({ } })) -vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +// Why mock the chokepoint: command timeouts and hidden-console handling belong to +// runProcessSync, while this suite only needs to control the credential output. +vi.mock('../../shared/child-process/run-process', () => ({ + runProcessSync: runProcessSyncMock +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { @@ -478,8 +482,8 @@ describe('importCookiesFromBrowser Chromium', () => { copyFileSyncMock.mockClear() setPendingCookieImportMock.mockClear() clearPendingCookieImportMock.mockClear() - execFileSyncMock.mockReset() - execFileSyncMock.mockImplementation(() => { + runProcessSyncMock.mockReset() + runProcessSyncMock.mockImplementation(() => { throw new Error('OS credential commands are unavailable in this test') }) sessionFromPartitionMock.mockReset() @@ -514,12 +518,6 @@ describe('importCookiesFromBrowser Chromium', () => { ]).close() const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'defaults') { - return '120.0.6099.71\n' - } - throw new Error(`Unexpected command: ${command}`) - }) try { expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) => @@ -539,8 +537,7 @@ describe('importCookiesFromBrowser Chromium', () => { value: 'source-value' }) ) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) - expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false) + expect(runProcessSyncMock).not.toHaveBeenCalled() expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( true ) @@ -573,11 +570,12 @@ describe('importCookiesFromBrowser Chromium', () => { } ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() - execFileSyncMock.mockImplementation((command: string) => { - if (command === 'security') { - return `${password}\n` - } - throw new Error(`Unexpected command: ${command}`) + runProcessSyncMock.mockReturnValue({ + code: 0, + signal: null, + stdout: `${password}\n`, + stderr: '', + timedOut: false }) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') @@ -588,11 +586,11 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(execFileSyncMock).toHaveBeenCalledWith( - 'security', - expect.any(Array), - expect.any(Object) - ) + expect(runProcessSyncMock).toHaveBeenCalledWith({ + program: 'security', + args: ['find-generic-password', '-s', 'Chrome Safe Storage', '-a', 'Chrome', '-w'], + timeoutMs: 30_000 + }) expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'encrypted-value' }) ) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 752940c9c83..10929761731 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,968 +1,48 @@ -/* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' -import { execFileSync } from 'node:child_process' -import { runProcessSync } from '../../shared/child-process/run-process' -import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' -import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' +import type { BrowserWindow } from 'electron' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' import { - appendFileSync, - copyFileSync, - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - unlinkSync -} from 'node:fs' -import { readFile } from 'node:fs/promises' -import { DatabaseSync } from 'node:sqlite' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -// Why: write the diag log to userData, not world-readable /tmp, so only the current user can read it. -let _diagLog: string | null = null -function getDiagLogPath(): string { - if (!_diagLog) { - try { - _diagLog = join(app.getPath('userData'), 'cookie-import-diag.log') - } catch { - _diagLog = join(tmpdir(), 'orca-cookie-import-diag.log') - } - } - return _diagLog -} -function reasonWithDiagLog(reason: string): string { - return `${reason} Details were written to ${getDiagLogPath()}.` -} -const COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS = 180 -const COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS = 512 - -// Why: error messages can embed large pasted/file payloads; cap the scan since diagnostics only need a short preview. -export function summarizeCookieImportError(err: unknown): string { - const raw = err instanceof Error && err.message ? err.message : String(err) - let summary = '' - let previousWasWhitespace = false - const scanLimit = Math.min(raw.length, COOKIE_IMPORT_ERROR_SCAN_MAX_CHARS) - for (let index = 0; index < scanLimit; index += 1) { - const code = raw.charCodeAt(index) - if (code === 32 || (code >= 9 && code <= 13)) { - if (summary.length > 0 && !previousWasWhitespace) { - summary += ' ' - } - previousWasWhitespace = true - continue - } - summary += raw.charAt(index) - if (summary.length >= COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) { - return summary.slice(0, COOKIE_IMPORT_ERROR_SUMMARY_MAX_CHARS) - } - previousWasWhitespace = false - } - return summary -} -function diag(msg: string): void { - const line = `[${new Date().toISOString()}] ${msg}\n` - try { - appendFileSync(getDiagLogPath(), line) - } catch { - /* best-effort */ - } - console.log('[cookie-import]', msg) -} -import type { - BrowserCookieImportResult, - BrowserCookieImportSummary, - BrowserSessionProfileSource -} from '../../shared/browser-workspace-types' -import { browserSessionRegistry } from './browser-session-registry' -import { supportsPendingBrowserCookieImportReplay } from './browser-session-cookie-staging' + detectInstalledBrowsers as detectBrowsers, + selectBrowserProfile as selectProfile +} from './browser-cookie-detection' +import type { BrowserProfile, DetectedBrowser } from './browser-cookie-detection-types' import { - isGoogleSourceBoundCookie, - isNonTransplantableCookieDomain, - normalizeCookieDomain, - normalizeCookieImportDomain, - importedDomainScope, - replaceCookiesForImportedDomains, - type CookieImportMode, - type ReplacedImportedDomainCookies -} from './browser-cookie-import-policy' + pickCookieFile as pickFile, + importCookiesFromFile as importFile, + type CookieImportOptions +} from './browser-cookie-import-pipeline' +import { importChromiumCookies } from './browser-cookie-chromium-import' +import { importCookiesFromFirefox } from './browser-cookie-firefox-import' +import { importCookiesFromSafari } from './browser-cookie-safari-import' import { - acquireCookieMutationLock, - removeTransplantableCookies, - withCookieMutationLock, - type CookieClearStore, - type CookieImportWriteStore -} from './browser-cookie-import-clear' -import { openCookieClearStore } from './browser-cookie-clear-store' -import { - readChromiumRowPartition, - readFirefoxRowPartition, - readJsonCookiePartition, - type SourcePartitionRead -} from './browser-cookie-source-partition' -import { - emptyImportWritePhase, - writeImportedCookies, - type ImportedCookieFields, - type ImportWritePhase, - type SourceCookieToWrite, - planImportWrites -} from './browser-cookie-import-write' -import { - createChromiumCookieSnapshot, - type ChromiumCookieSnapshot -} from './chromium-cookie-snapshot' -import { resolveChromiumCookiesPath } from './chromium-cookie-path' -import { prepareStagedCookiesForImport } from './browser-cookie-staged-import' -import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' + buildChromiumCookieInsertParams as buildInsertParams, + type ChromiumCookieColumnInfo +} from './browser-cookie-sqlite' +import { isAppBoundEncryptedCookie as isAppBoundCookie } from './browser-cookie-decryption' +import { summarizeCookieImportError as summarizeError } from './browser-cookie-import-diagnostics' -// --------------------------------------------------------------------------- -// Browser detection -// --------------------------------------------------------------------------- - -export type BrowserProfile = { - name: string - directory: string -} - -export type DetectedBrowser = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - cookiesPath: string - keychainService?: string - keychainAccount?: string - profiles: BrowserProfile[] - selectedProfile: string -} - -type ChromiumBrowserDef = { - family: BrowserSessionProfileSource['browserFamily'] - label: string - keychainService: string - keychainAccount: string - // Per-platform data-dir roots, resolved at detection time via browserRootPath(). - macRoot?: string - winRoot?: string - linuxRoot?: string -} - -const CHROMIUM_BROWSERS: ChromiumBrowserDef[] = [ - { - family: 'chrome', - label: 'Google Chrome', - keychainService: 'Chrome Safe Storage', - keychainAccount: 'Chrome', - macRoot: 'Google/Chrome', - winRoot: 'Google/Chrome/User Data', - linuxRoot: 'google-chrome' - }, - { - family: 'edge', - label: 'Microsoft Edge', - keychainService: 'Microsoft Edge Safe Storage', - keychainAccount: 'Microsoft Edge', - macRoot: 'Microsoft Edge', - winRoot: 'Microsoft/Edge/User Data', - linuxRoot: 'microsoft-edge' - }, - { - family: 'arc', - label: 'Arc', - keychainService: 'Arc Safe Storage', - keychainAccount: 'Arc', - macRoot: 'Arc/User Data' - }, - { - family: 'chromium', - label: 'Brave', - keychainService: 'Brave Safe Storage', - keychainAccount: 'Brave', - macRoot: 'BraveSoftware/Brave-Browser', - winRoot: 'BraveSoftware/Brave-Browser/User Data', - linuxRoot: 'BraveSoftware/Brave-Browser' - }, - { - family: 'comet', - label: 'Comet', - keychainService: 'Comet Safe Storage', - keychainAccount: 'Comet', - macRoot: 'Comet', - winRoot: 'Comet/User Data' - // linuxRoot intentionally omitted — Comet does not ship a Linux build as of 2026-05-15 - }, - { - family: 'helium', - // Why: Helium breaks the ' Safe Storage' convention — its Keychain service is literally 'Helium Storage Key'. - label: 'Helium', - keychainService: 'Helium Storage Key', - keychainAccount: 'Helium', - macRoot: 'net.imput.helium' - // winRoot/linuxRoot intentionally omitted — only the macOS install is verified - } -] - -function browserRootPath(def: ChromiumBrowserDef): string | null { - if (process.platform === 'darwin') { - if (!def.macRoot) { - return null - } - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', def.macRoot) - } - if (process.platform === 'win32') { - if (!def.winRoot) { - return null - } - const localAppData = process.env.LOCALAPPDATA ?? '' - if (!localAppData) { - return null - } - return join(localAppData, def.winRoot) - } - // Linux - if (!def.linuxRoot) { - return null - } - const configHome = process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? '', '.config') - return join(configHome, def.linuxRoot) -} - -function isSafeBrowserProfileDirectory(directory: string): boolean { - return ( - directory.length > 0 && - directory !== '.' && - !directory.includes('\0') && - !directory.includes('/') && - !directory.includes('\\') && - !directory.includes('..') - ) -} - -// Why: Chrome's Local State profile.info_cache maps profile dirs to display names for the picker. -function discoverProfiles(browserRoot: string): BrowserProfile[] { - try { - const localStatePath = join(browserRoot, 'Local State') - if (!existsSync(localStatePath)) { - return [{ name: 'Default', directory: 'Default' }] - } - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const infoCache = localState?.profile?.info_cache - if (!infoCache || typeof infoCache !== 'object') { - return [{ name: 'Default', directory: 'Default' }] - } - const profiles: BrowserProfile[] = [] - for (const [dir, info] of Object.entries(infoCache)) { - // Why: Local State is external metadata, but profile dirs become path segments. - if (!isSafeBrowserProfileDirectory(dir)) { - continue - } - const profileName = (info as { name?: string })?.name ?? dir - profiles.push({ name: profileName, directory: dir }) - } - return profiles.length > 0 ? profiles : [{ name: 'Default', directory: 'Default' }] - } catch { - return [{ name: 'Default', directory: 'Default' }] - } -} - -// --------------------------------------------------------------------------- -// Firefox detection -// --------------------------------------------------------------------------- - -function firefoxProfilesRoot(): string | null { - if (process.platform === 'darwin') { - const home = process.env.HOME ?? '' - return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles') - } - if (process.platform === 'win32') { - const appData = process.env.APPDATA ?? '' - return appData ? join(appData, 'Mozilla', 'Firefox', 'Profiles') : null - } - const home = process.env.HOME ?? '' - return join(home, '.mozilla', 'firefox') -} - -function discoverFirefoxProfiles(): BrowserProfile[] { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return [] - } - try { - if (!existsSync(profilesRoot)) { - return [] - } - const entries = readdirSync(profilesRoot, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name) - // Why: Firefox dirs are named .; prefer 'default-release' as the primary profile on most installs. - const sorted = entries.sort((a, b) => { - if (a.includes('default-release')) { - return -1 - } - if (b.includes('default-release')) { - return 1 - } - if (a.includes('default')) { - return -1 - } - if (b.includes('default')) { - return 1 - } - return 0 - }) - return sorted.map((dir) => { - const label = dir.includes('.') ? dir.split('.').slice(1).join('.') : dir - return { name: label, directory: dir } - }) - } catch { - return [] - } -} - -function detectFirefox(): DetectedBrowser | null { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const profiles = discoverFirefoxProfiles() - for (const profile of profiles) { - const cookiesPath = join(profilesRoot, profile.directory, 'cookies.sqlite') - if (existsSync(cookiesPath)) { - return { - family: 'firefox', - label: 'Firefox', - cookiesPath, - profiles, - selectedProfile: profile.directory - } - } - } - return null -} - -// --------------------------------------------------------------------------- -// Safari detection -// --------------------------------------------------------------------------- - -const MAC_EPOCH_DELTA = 978_307_200 - -function detectSafari(): DetectedBrowser | null { - if (process.platform !== 'darwin') { - return null - } - const home = process.env.HOME ?? '' - const candidates = [ - join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), - join( - home, - 'Library', - 'Containers', - 'com.apple.Safari', - 'Data', - 'Library', - 'Cookies', - 'Cookies.binarycookies' - ) - ] - for (const candidate of candidates) { - if (existsSync(candidate)) { - return { - family: 'safari', - label: 'Safari', - cookiesPath: candidate, - profiles: [{ name: 'Default', directory: 'Default' }], - selectedProfile: 'Default' - } - } - } - return null -} +export type { BrowserProfile, DetectedBrowser, CookieImportOptions, ChromiumCookieColumnInfo } +export { summarizeError as summarizeCookieImportError } export function detectInstalledBrowsers(): DetectedBrowser[] { - const detected: DetectedBrowser[] = [] - for (const browser of CHROMIUM_BROWSERS) { - const root = browserRootPath(browser) - if (!root) { - continue - } - const profiles = discoverProfiles(root) - // Why: a browser counts as detected once a profile has a cookies DB; use the first such profile as default. - for (const profile of profiles) { - const profileDir = join(root, profile.directory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (cookiesPath) { - detected.push({ - family: browser.family, - label: browser.label, - keychainService: browser.keychainService, - keychainAccount: browser.keychainAccount, - cookiesPath, - profiles, - selectedProfile: profile.directory - }) - break - } - } - } - - const firefox = detectFirefox() - if (firefox) { - detected.push(firefox) - } - - const safari = detectSafari() - if (safari) { - detected.push(safari) - } - - return detected + return detectBrowsers() } export function selectBrowserProfile( browser: DetectedBrowser, profileDirectory: string ): DetectedBrowser | null { - if (!isSafeBrowserProfileDirectory(profileDirectory)) { - return null - } - if (browser.family === 'firefox') { - const profilesRoot = firefoxProfilesRoot() - if (!profilesRoot) { - return null - } - const cookiesPath = join(profilesRoot, profileDirectory, 'cookies.sqlite') - if (!existsSync(cookiesPath)) { - return null - } - return { ...browser, cookiesPath, selectedProfile: profileDirectory } - } - - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - const profileDir = join(root, profileDirectory) - const cookiesPath = resolveChromiumCookiesPath(profileDir) - if (!cookiesPath) { - return null - } - return { - ...browser, - cookiesPath, - selectedProfile: profileDirectory - } + return selectProfile(browser, profileDirectory) } -// --------------------------------------------------------------------------- -// Cookie validation (shared between file import and direct import) -// --------------------------------------------------------------------------- - -type RawCookieEntry = { - domain?: unknown - name?: unknown - value?: unknown - path?: unknown - secure?: unknown - httpOnly?: unknown - sameSite?: unknown - expirationDate?: unknown - partitionKey?: unknown - partitionKeyOpaque?: unknown -} - -// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to -// state what it read. An optional field would let a new source silently default to unpartitioned. -type ValidatedCookie = ImportedCookieFields & { - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead -} - -// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. -function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 1: - return 'no_restriction' - case 2: - return 'lax' - case 3: - return 'strict' - default: - return 'unspecified' - } -} - -function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - switch (raw) { - case 0: - return 'no_restriction' - case 1: - return 'lax' - case 2: - return 'strict' - default: - return 'unspecified' - } -} - -function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { - if (typeof raw === 'number') { - return chromiumSameSite(raw) - } - if (typeof raw !== 'string') { - return 'unspecified' - } - const lower = raw.toLowerCase() - if (lower === 'lax') { - return 'lax' - } - if (lower === 'strict') { - return 'strict' - } - if (lower === 'none' || lower === 'no_restriction') { - return 'no_restriction' - } - return 'unspecified' -} - -// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. -function deriveUrl(domain: string, secure: boolean): string | null { - const normalizedDomain = normalizeCookieDomain(domain) - if (!normalizedDomain) { - return null - } - const protocol = secure ? 'https' : 'http' - try { - const url = new URL(`${protocol}://${normalizedDomain}/`) - return url.toString() - } catch { - return null - } -} - -function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { - if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { - return null - } - if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { - return null - } - if (typeof raw.value !== 'string') { - return null - } - - const domain = raw.domain.trim() - const secure = raw.secure === true || raw.secure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = - typeof raw.expirationDate === 'number' && raw.expirationDate > 0 - ? raw.expirationDate - : undefined - - return { - url, - name: raw.name.trim(), - value: raw.value, - domain, - path: typeof raw.path === 'string' ? raw.path : '/', - secure, - httpOnly: raw.httpOnly === true || raw.httpOnly === 1, - sameSite: normalizeSameSite(raw.sameSite), - expirationDate, - partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) - } -} - -// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so -// the partition-dropping write is not merely unused here — it cannot be reached. -type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void } - -type CookieImportTarget = { - partition: string - // Why (STA-4601): the live-jar lock is keyed on an object, and this path no longer holds the - // Session that STA-4300 moved behind openWriteStore. session.fromPartition returns the SAME - // instance for one partition string, so carrying that instance here is what keeps this path's - // lock and the native path's lock on ONE key — a fresh object per call would serialise nothing. - mutationLockOwner: object - openWriteStore: () => CookieImportSessionStore -} - -type CookieImportOptions = { - canReportPartitionSkippedCookies?: boolean -} - -function cookieImportTarget(targetPartition: string): CookieImportTarget { - const targetSession = session.fromPartition(targetPartition) - return { - partition: targetPartition, - mutationLockOwner: targetSession, - openWriteStore: () => openCookieClearStore(targetSession) - } -} - -async function importValidatedCookies( - cookies: ValidatedCookie[], - totalInput: number, - target: CookieImportTarget, - mode: CookieImportMode, - options: CookieImportOptions = {} -): Promise { - const targetPartition = target.partition - const importDomainCache = new Map() - const validDomainCookies = cookies.filter((cookie) => { - let valid = importDomainCache.get(cookie.domain) - if (valid === undefined) { - valid = normalizeCookieImportDomain(cookie.domain) !== null - importDomainCache.set(cookie.domain, valid) - } - return valid - }) - const sourceBoundFiltered = validDomainCookies.filter( - (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) - ) - // Why: dropping these before the replace scope is computed is what keeps the existing - // Google session intact — replaceCookiesForImportedDomains only clears domains we import. - const importableCookies = sourceBoundFiltered.filter( - (cookie) => !isNonTransplantableCookieDomain(cookie.domain) - ) - const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length - const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - const invalidDomainSkipped = cookies.length - validDomainCookies.length - diag( - `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"` - ) - // Why (STA-4300 I1): every cookie's fate is decided here, before the jar is opened. The plan is - // the single value the write set AND the removal scope both derive from, so they cannot drift - // apart the way they did in bf6dc6fcba. - const plan = planImportWrites(importableCookies) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the removal scope, and - // clearing a family we cannot protect is the P0. Refuse before touching anything. - if (plan.hasUnrepresentableSkip) { - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - // Why: an older remote client cannot surface this skip, so fail before opening the target jar. - if (options.canReportPartitionSkippedCookies === false && plan.skips.length > 0) { - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - // Why: a family-suppressed sibling is a partition skip too, so partitionSkippedCookies is a - // BREAKDOWN of skippedCookies and is added into it exactly once — never a separate addend, or - // totalCookies === importedCookies + skippedCookies silently stops holding. - const partitionSkipped = plan.skips.length - let skipped = totalInput - importableCookies.length + partitionSkipped - let phase: ImportWritePhase = emptyImportWritePhase() - // Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only - // they carry partitionKey. cookies.set drops it silently, on the success path as well. - const cookieClearStore = plan.writes.length > 0 ? target.openWriteStore() : null - - if (cookieClearStore) { - // Why (STA-4601): the replace, the writes, and the rollback are one live-jar transaction. - // Releasing after the replace lets a second import interleave, so this run's rollback could - // remove cookies the newer import already wrote and reported as imported. Taken AFTER the - // store is opened on purpose — openWriteStore only builds the adapter, it attaches no - // debugger, so holding it while queued cannot deadlock against the holder. - const releaseMutationLock = await acquireCookieMutationLock(target.mutationLockOwner) - let replaced: ReplacedImportedDomainCookies | null = null - try { - if (mode === 'replace-imported-domains') { - try { - // Why (STA-4300 I2 / §2b): the removal scope is the write set. Filtering per exact - // cookie is NOT enough — replaceCookiesForImportedDomains expands each imported domain - // into its descendant roots, so a readable apex cookie would drag a skipped subdomain's - // live session into the removal scope with nothing written back. plan.writes is already - // family-closed, and using the same array for both makes them impossible to diverge. - const replacementDomains = plan.writes.map((cookie) => cookie.domain) - replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains) - diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`) - } catch (err) { - diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) - return { - ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') - } - } - } - - // Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net. - const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') - phase = await writeImportedCookies( - cookieClearStore, - plan.writes.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })), - { stopOnFailure: replaced !== null, log: diag } - ) - // Why: plan.skips holds every partition-driven skip — the unreadable rows AND the readable - // siblings suppressed by family closure. phase.partitionSkipped is 0 now that only planned - // writes reach the writer, so the count comes from the plan and is added exactly once. - skipped += phase.writeRejected - - if (phase.failure && replaced) { - const rollbackFailures: unknown[] = [] - for (const cookie of phase.attemptedKeys.toReversed()) { - try { - await cookieClearStore.remove(cookie.url, cookie.name) - } catch (err) { - rollbackFailures.push(err) - } - } - // Why: restoreClearIdentities attaches the debugger before it iterates, so an empty - // restore set would spin up a hidden BrowserWindow to put nothing back. - if (replaced.identities.length > 0) { - try { - await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed()) - } catch (err) { - rollbackFailures.push(err) - } - } - if (rollbackFailures.length > 0) { - diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) - } - return { - ok: false, - reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') - } - } - } finally { - try { - cookieClearStore.dispose() - } finally { - releaseMutationLock() - } - } - } - - diag( - `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${partitionSkipped} domains=${phase.domains.size}` - ) - - const summary: BrowserCookieImportSummary = { - totalCookies: totalInput, - importedCookies: phase.importedCount, - skippedCookies: skipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...phase.domains].sort() - } - - return { ok: true, profileId: '', summary } -} - -// --------------------------------------------------------------------------- -// Import from JSON file -// --------------------------------------------------------------------------- - -// Why: use a main-owned native dialog so a compromised renderer can't turn import into arbitrary file reads. export async function pickCookieFile(parentWindow: BrowserWindow | null): Promise { - const opts = { - title: 'Import Cookies', - filters: [ - { name: 'Cookie Files', extensions: ['json'] }, - { name: 'All Files', extensions: ['*'] } - ], - properties: ['openFile' as const] - } - const result = parentWindow - ? await dialog.showOpenDialog(parentWindow, opts) - : await dialog.showOpenDialog(opts) - - if (result.canceled || result.filePaths.length === 0) { - return null - } - return result.filePaths[0] + return pickFile(parentWindow) } export async function importCookiesFromFile( filePath: string, targetPartition: string ): Promise { - let rawContent: string - try { - rawContent = await readFile(filePath, 'utf-8') - } catch { - return { ok: false, reason: 'Could not read the selected file.' } - } - - let parsed: unknown - try { - parsed = JSON.parse(rawContent) - } catch { - return { ok: false, reason: 'File is not valid JSON.' } - } - - if (!Array.isArray(parsed)) { - return { ok: false, reason: 'Expected a JSON array of cookie objects.' } - } - - if (parsed.length === 0) { - return { ok: false, reason: 'Cookie file is empty.' } - } - - const validated: ValidatedCookie[] = [] - let skipped = 0 - for (const entry of parsed) { - if (typeof entry !== 'object' || entry === null) { - skipped++ - continue - } - const cookie = validateCookieEntry(entry as RawCookieEntry) - if (cookie) { - validated.push(cookie) - } else { - skipped++ - } - } - - if (validated.length === 0) { - return { - ok: false, - reason: `No valid cookies found. ${skipped} entries were skipped due to missing or invalid fields.` - } - } - - return importValidatedCookies( - validated, - parsed.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) -} - -const PBKDF2_ITERATIONS = 1003 -const PBKDF2_KEY_LENGTH = 16 -const PBKDF2_SALT = 'saltysalt' - -const CHROMIUM_EPOCH_OFFSET = 11644473600n - -function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { - if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { - return 0 - } - try { - const ts = - typeof chromiumTs === 'bigint' - ? chromiumTs - : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) - if (ts === 0n) { - return 0 - } - return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) - } catch { - return 0 - } -} - -// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. - -type EncryptionKeyResult = - | { - mode: 'aes-128-cbc' - keysByVersion: Partial> - keyringUnavailable?: boolean - } - | { mode: 'aes-256-gcm'; key: Buffer } - -export type ChromiumCookieColumnInfo = { - name: string - type?: string - notnull?: number | bigint - dflt_value?: unknown -} - -function parseSqliteDefaultValue(raw: unknown, type: string): string | number | Buffer | null { - if (raw === null || raw === undefined) { - return null - } - if (typeof raw !== 'string') { - return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) - } - - const trimmed = raw.trim() - if (!trimmed || trimmed.toUpperCase() === 'NULL') { - return null - } - if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { - return Buffer.alloc(0) - } - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed.slice(1, -1).replaceAll("''", "'") - } - if (type.includes('INT')) { - const numeric = Number(trimmed) - return Number.isFinite(numeric) ? numeric : 0 - } - return trimmed -} - -function normalizeSqliteCookieValue(value: unknown): string | number | bigint | Buffer | null { - if (value instanceof Uint8Array) { - return Buffer.from(value) - } - if (value === undefined || value === null) { - return null - } - if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { - return value - } - return String(value) -} - -function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { - return Number(column.notnull ?? 0) !== 0 -} - -function fallbackChromiumCookieColumnValue( - column: ChromiumCookieColumnInfo, - sourceRow: Record -): string | number | bigint | Buffer | null { - const type = (column.type ?? '').toUpperCase() - const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) - if (defaultValue !== null) { - return defaultValue - } - if (!isSqliteNotNull(column)) { - return null - } - - switch (column.name) { - case 'value': - case 'encrypted_value': - return Buffer.alloc(0) - case 'top_frame_site_key': - return '' - case 'source_port': - return -1 - case 'last_update_utc': - return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 - default: - if (type.includes('BLOB')) { - return Buffer.alloc(0) - } - if (type.includes('INT')) { - return 0 - } - return '' - } + return importFile(filePath, targetPartition) } export function buildChromiumCookieInsertParams( @@ -970,1247 +50,23 @@ export function buildChromiumCookieInsertParams( sourceRow: Record, decryptedValue: Buffer ): (string | number | bigint | Buffer | null)[] { - return targetColumns.map((column) => { - if (column.name === 'encrypted_value') { - return Buffer.alloc(0) - } - if (column.name === 'value') { - return decryptedValue - } - - const sourceHasColumn = Object.hasOwn(sourceRow, column.name) - const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null - if (sourceValue !== null) { - return sourceValue - } - if (sourceHasColumn && !isSqliteNotNull(column)) { - return null - } - - // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. - return fallbackChromiumCookieColumnValue(column, sourceRow) - }) + return buildInsertParams(targetColumns, sourceRow, decryptedValue) } -function getEncryptionKey( - keychainService: string, - keychainAccount: string, - browser?: DetectedBrowser -): EncryptionKeyResult | null { - if (process.platform === 'darwin') { - return getMacEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'linux') { - return getLinuxEncryptionKey(keychainService, keychainAccount) - } - if (process.platform === 'win32' && browser) { - return getWindowsEncryptionKey(browser) - } - return null -} - -function getMacEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - try { - const raw = execFileSync( - 'security', - ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], - { encoding: 'utf-8', timeout: 30_000 } - ).trim() - return { - mode: 'aes-128-cbc', - keysByVersion: { - v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') - } - } - } catch { - return null - } -} - -function getLinuxEncryptionKey( - keychainService: string, - keychainAccount: string -): EncryptionKeyResult | null { - // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded - // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. - const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - - let keyringPassword = '' - try { - // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. - keyringPassword = execFileSync( - 'secret-tool', - ['lookup', 'service', keychainService, 'account', keychainAccount], - { encoding: 'utf-8', timeout: 5_000 } - ).trim() - } catch { - // Why: fall back to application-based lookup used by newer Chromium versions. - try { - const app = keychainAccount.toLowerCase().replaceAll(' ', '') - keyringPassword = execFileSync('secret-tool', ['lookup', 'application', app], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() - } catch { - diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') - } - } - - if (!keyringPassword) { - return { - mode: 'aes-128-cbc', - keysByVersion: { v10: v10Key }, - keyringUnavailable: true - } - } - - const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') - return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } -} - -function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { - const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) - if (!browserDef) { - return null - } - const root = browserRootPath(browserDef) - if (!root) { - return null - } - - const localStatePath = join(root, 'Local State') - if (!existsSync(localStatePath)) { - return null - } - - try { - const raw = readFileSync(localStatePath, 'utf-8') - const localState = JSON.parse(raw) - const encryptedKeyB64 = localState?.os_crypt?.encrypted_key - if (typeof encryptedKeyB64 !== 'string') { - return null - } - - const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') - const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') - if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { - return null - } - - // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. - const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') - const script = [ - 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', - 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', - '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', - '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', - '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', - '[Convert]::ToBase64String($out)' - ].join('') - - // Why runProcessSync and an absolute path: a bare `powershell` spawn from a - // GUI-subsystem process opens a visible conhost that takes foreground, so - // keystrokes typed into an Orca terminal during a cookie import land in the - // black box (#14543), and PATH under Electron is not the user's (#11771). - const result = runProcessSync({ - program: windowsPowerShellPath(), - args: ['-NoProfile', '-NonInteractive', '-Command', script], - timeoutMs: 10_000, - input: dpapiData - }) - if (result.code !== 0 || result.timedOut) { - diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') - return null - } - - return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } - } catch (err) { - diag(` Windows DPAPI key extraction failed: ${String(err)}`) - return null - } -} - -// Why: Chromium 127+ prepends a 32-byte HMAC before the value; a hash is ~half non-printable, so ≥8 non-printable of the first 32 bytes flags the prefix. -const CHROMIUM_COOKIE_HMAC_LEN = 32 - -function hasHmacPrefix(buf: Buffer): boolean { - if (buf.length <= CHROMIUM_COOKIE_HMAC_LEN) { - return false - } - let nonPrintable = 0 - for (let i = 0; i < CHROMIUM_COOKIE_HMAC_LEN; i++) { - if (buf[i] < 0x20 || buf[i] > 0x7e) { - nonPrintable++ - } - } - return nonPrintable >= 8 -} - -function stripHmac(buf: Buffer): Buffer { - return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf -} - -// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and -// share it between the decrypt path and the failure attribution. -function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { - if (encryptedBuffer.length < 3) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - return /^v\d\d$/.test(version) ? version : null -} - -// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which -// only the writing browser can unwrap. Classify it before decrypt so it is not folded into corruption. export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { - return cookieEncryptionVersion(encryptedBuffer) === 'v20' + return isAppBoundCookie(encryptedBuffer) } -// Why: a named cause must carry only its exact count; tied causes fall back to unknown. -function buildUndecryptableWarning(counts: { - decryptFailed: number - appBoundFailed: number - keyringUnavailableFailed: number -}): BrowserCookieImportSummary['warning'] { - if (counts.decryptFailed === 0) { - return undefined - } - const unknownFailed = - counts.decryptFailed - counts.appBoundFailed - counts.keyringUnavailableFailed - const rankedCauses = [ - { reason: 'app-bound-encryption' as const, count: counts.appBoundFailed }, - { reason: 'linux-keyring-unavailable' as const, count: counts.keyringUnavailableFailed }, - { reason: 'unknown' as const, count: unknownFailed } - ].sort((left, right) => right.count - left.count) - const [dominant, runnerUp] = rankedCauses - - if (dominant.reason === 'unknown' || dominant.count === runnerUp.count) { - return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason: 'unknown' } - } - - const otherFailedCookies = counts.decryptFailed - dominant.count - return { - code: 'cookies-undecryptable', - failedCookies: dominant.count, - reason: dominant.reason, - ...(otherFailedCookies > 0 ? { otherFailedCookies } : {}) - } -} - -function decryptCookieValueRaw( - encryptedBuffer: Buffer, - keyResult: EncryptionKeyResult -): Buffer | null { - if (!encryptedBuffer || encryptedBuffer.length === 0) { - return null - } - const version = encryptedBuffer.subarray(0, 3).toString('utf-8') - if (!/^v\d\d$/.test(version)) { - return null - } - - if (keyResult.mode === 'aes-256-gcm') { - return decryptAes256Gcm(encryptedBuffer.subarray(3), keyResult.key) - } - - // AES-128-CBC (macOS and Linux) - const key = version === 'v10' || version === 'v11' ? keyResult.keysByVersion[version] : undefined - if (!key) { - return null - } - - const ciphertext = encryptedBuffer.subarray(3) - if (!ciphertext.length) { - return null - } - - try { - const iv = Buffer.alloc(16, ' ') - const decipher = createDecipheriv('aes-128-cbc', key, iv) - decipher.setAutoPadding(true) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -function decryptAes256Gcm(payload: Buffer, key: Buffer): Buffer | null { - // Why: Windows AES-256-GCM layout is: [12-byte nonce][ciphertext][16-byte auth tag] - if (payload.length < 12 + 16) { - return null - } - const nonce = payload.subarray(0, 12) - const authTag = payload.subarray(-16) - const ciphertext = payload.subarray(12, -16) - try { - const decipher = createDecipheriv('aes-256-gcm', key, nonce) - decipher.setAuthTag(authTag) - const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return stripHmac(decrypted) - } catch { - return null - } -} - -// --------------------------------------------------------------------------- -// Safari binary cookie parser -// --------------------------------------------------------------------------- - -function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { - if (buffer.length < 8) { - return [] - } - if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { - return [] - } - - const pageCount = buffer.readUInt32BE(4) - let cursor = 8 - if (cursor + pageCount * 4 > buffer.length) { - return [] - } - const pageSizes: number[] = [] - for (let i = 0; i < pageCount; i++) { - pageSizes.push(buffer.readUInt32BE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const pageSize of pageSizes) { - const page = buffer.subarray(cursor, cursor + pageSize) - cursor += pageSize - appendSafariCookies(cookies, decodeSafariPage(page)) - } - return cookies -} - -function appendSafariCookies(target: ValidatedCookie[], cookies: readonly ValidatedCookie[]): void { - // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. - for (const cookie of cookies) { - target.push(cookie) - } -} - -function decodeSafariPage(page: Buffer): ValidatedCookie[] { - if (page.length < 16) { - return [] - } - if (page.readUInt32BE(0) !== 0x00000100) { - return [] - } - - const cookieCount = page.readUInt32LE(4) - if (8 + cookieCount * 4 > page.length) { - return [] - } - const offsets: number[] = [] - let cursor = 8 - for (let i = 0; i < cookieCount; i++) { - offsets.push(page.readUInt32LE(cursor)) - cursor += 4 - } - - const cookies: ValidatedCookie[] = [] - for (const offset of offsets) { - const cookie = decodeSafariCookie(page.subarray(offset)) - if (cookie) { - cookies.push(cookie) - } - } - return cookies -} - -function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { - if (buf.length < 48) { - return null - } - // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. - const size = Math.min(buf.readUInt32LE(0), buf.length) - if (size < 48) { - return null - } - - const flags = buf.readUInt32LE(8) - const secure = (flags & 1) !== 0 - const httpOnly = (flags & 4) !== 0 - - const urlOffset = buf.readUInt32LE(16) - const nameOffset = buf.readUInt32LE(20) - const pathOffset = buf.readUInt32LE(24) - const valueOffset = buf.readUInt32LE(28) - - // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). - const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 - - const name = readCString(buf, nameOffset, size) - if (!name) { - return null - } - const value = readCString(buf, valueOffset, size) ?? '' - const path = readCString(buf, pathOffset, size) ?? '/' - const rawUrl = readCString(buf, urlOffset, size) ?? '' - - // Why: Safari stores the domain in the URL field, not as a separate domain column. - const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null - if (!domain) { - return null - } - - const url = deriveUrl(domain, secure) - if (!url) { - return null - } - - const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined - - return { - url, - name, - value, - domain, - path, - secure, - httpOnly, - sameSite: 'unspecified', - expirationDate, - // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every - // decoded cookie is genuinely unpartitioned rather than missing an identity. - partition: { status: 'unpartitioned' } - } -} - -function readCString(buf: Buffer, offset: number, end: number): string | null { - if (offset < 0 || offset >= end) { - return null - } - let cursor = offset - while (cursor < end && buf[cursor] !== 0) { - cursor++ - } - if (cursor >= end) { - return null - } - return buf.toString('utf8', offset, cursor) -} - -// --------------------------------------------------------------------------- -// Firefox import -// --------------------------------------------------------------------------- - -async function importCookiesFromFirefox( - browser: DetectedBrowser, - targetPartition: string, - options: CookieImportOptions -): Promise { - diag(`importCookiesFromFirefox: partition="${targetPartition}"`) - - const tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-import-')) - const tmpCookiesPath = join(tmpDir, 'cookies.sqlite') - - try { - copyFileSync(browser.cookiesPath, tmpCookiesPath) - for (const suffix of ['-wal', '-shm'] as const) { - const sidecar = browser.cookiesPath + suffix - if (existsSync(sidecar)) { - try { - copyFileSync(sidecar, tmpCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - } catch { - rmSync(tmpDir, { recursive: true, force: true }) - return { - ok: false, - reason: 'Could not copy Firefox cookies database. Try closing Firefox first.' - } - } - - try { - const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) - type FirefoxRow = Record & { - name: string - value: string - host: string - path: string - expiry: number - isSecure: number - isHttpOnly: number - sameSite: number - isPartitionedAttributeSet?: number - } - // Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema - // without the server-declared partition flag predates that cookie identity. - const firefoxColumns = new Set( - (db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map( - (column) => column.name - ) - ) - const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet') - ? ', isPartitionedAttributeSet' - : '' - const rows = db - .prepare( - `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` - ) - .all() as FirefoxRow[] - db.close() - - diag(` Firefox source has ${rows.length} cookies`) - if (rows.length === 0) { - rmSync(tmpDir, { recursive: true, force: true }) - return { ok: false, reason: 'No cookies found in Firefox.' } - } - - const now = Math.floor(Date.now() / 1000) - const validated: ValidatedCookie[] = [] - for (const row of rows) { - if (!row.name || !row.host) { - continue - } - if (row.expiry > 0 && row.expiry < now) { - continue - } - - const domain = row.host - const secure = row.isSecure === 1 - const url = deriveUrl(domain, secure) - if (!url) { - continue - } - - validated.push({ - url, - name: row.name, - value: row.value ?? '', - domain, - path: row.path || '/', - secure, - httpOnly: row.isHttpOnly === 1, - sameSite: firefoxSameSite(row.sameSite), - expirationDate: row.expiry > 0 ? row.expiry : undefined, - partition: readFirefoxRowPartition(row, firefoxColumns) - }) - } - - rmSync(tmpDir, { recursive: true, force: true }) - - if (validated.length === 0) { - return { ok: false, reason: 'No valid cookies found in Firefox.' } - } - - return importValidatedCookies( - validated, - rows.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains', - options - ) - } catch (err) { - rmSync(tmpDir, { recursive: true, force: true }) - diag(` Firefox import failed: ${String(err)}`) - return { - ok: false, - reason: 'Could not import cookies from Firefox. Try closing Firefox first.' - } - } -} - -// --------------------------------------------------------------------------- -// Safari import -// --------------------------------------------------------------------------- - -async function importCookiesFromSafari( - browser: DetectedBrowser, - targetPartition: string -): Promise { - diag(`importCookiesFromSafari: partition="${targetPartition}"`) - - let data: Buffer - try { - data = readFileSync(browser.cookiesPath) - } catch (err) { - diag(` Safari read failed: ${String(err)}`) - // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. - const isPermError = - err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' - if (isPermError) { - return { - ok: false, - reason: - 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' - } - } - return { ok: false, reason: 'Could not read Safari cookies.' } - } - - try { - const cookies = decodeSafariBinaryCookies(data) - diag(` Safari source has ${cookies.length} cookies`) - - if (cookies.length === 0) { - return { ok: false, reason: 'No cookies found in Safari.' } - } - - const now = Math.floor(Date.now() / 1000) - const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) - - if (valid.length === 0) { - return { ok: false, reason: 'All Safari cookies are expired.' } - } - - return importValidatedCookies( - valid, - cookies.length, - cookieImportTarget(targetPartition), - 'replace-imported-domains' - ) - } catch (err) { - diag(` Safari import failed: ${String(err)}`) - return { ok: false, reason: 'Could not import cookies from Safari.' } - } -} - -// --------------------------------------------------------------------------- -// Import dispatcher -// --------------------------------------------------------------------------- - export async function importCookiesFromBrowser( browser: DetectedBrowser, targetPartition: string, options: CookieImportOptions = {} ): Promise { - diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) - if (!existsSync(browser.cookiesPath)) { - diag(` cookies DB not found: ${browser.cookiesPath}`) - return { ok: false, reason: `${browser.label} cookies database not found.` } - } - if (browser.family === 'firefox') { return importCookiesFromFirefox(browser, targetPartition, options) } if (browser.family === 'safari') { return importCookiesFromSafari(browser, targetPartition) } - - // Why: cookies.set() rejects many valid values (bytes > 0x7F); instead write plaintext to the `value` column, which CookieMonster reads raw when `encrypted_value` is empty and re-encrypts on flush in packaged builds. - - // Why: CookieMonster can reject otherwise valid imported bytes, so stage a populated copy whose - // imported-domain rows can be merged into the live DB on the next cold start. - const targetSession = session.fromPartition(targetPartition) - // Why (STA-4601): native imports mutate the live jar and their staged image before the old - // clear/write lock was reached. Hold the per-partition lock from the first flush through staging, - // live replacement, pending-image bookkeeping, and cleanup so an older image cannot race a newer - // import on the same partition. - return withCookieMutationLock(targetSession, async () => { - await targetSession.cookies.flushStore() - - // Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from - // the caller's partition string. String surgery on a caller-supplied name is what let a value like - // "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement - // over it; it also drifts whenever Chromium changes how a partition name maps to a directory. - const partitionDir = targetSession.getStoragePath() - if (!partitionDir) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - const partitionName = targetPartition.replace('persist:', '') - let liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - - // Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles. - // Why (STA-4601): this probe MUTATES the live jar, so it runs under the same per-partition lock as - // the import itself. An earlier revision left it outside on the argument that no import writes - // https://localhost/__init — that was wrong. normalizeCookieImportDomain accepts `localhost`, - // cookie names are unrestricted, and deriveUrl produces exactly this URL, so an import CAN write - // that coordinate. Unlocked, this probe's remove() would delete a cookie a concurrent import had - // just written and reported as imported. The cost is negligible: the probe only runs for a - // partition that has never stored a cookie, so it is at most a one-time wait per profile. - if (!liveCookiesPath) { - try { - await targetSession.cookies.set({ url: 'https://localhost', name: '__init', value: '1' }) - await targetSession.cookies.remove('https://localhost', '__init') - await targetSession.cookies.flushStore() - } catch { - // ignore — the set/remove may fail but flushStore should still create the file - } - liveCookiesPath = resolveChromiumCookiesPath(partitionDir) - } - - if (!liveCookiesPath) { - return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' } - } - - const stagingDir = join(app.getPath('userData'), 'cookie-import-staging') - const partitionSegment = partitionName.replace(/[^a-zA-Z0-9_-]/g, '_') - const stagingCookiesPath = join( - stagingDir, - `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` - ) - // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory - // import rejects, so losing it must degrade that fallback rather than abort the import. - let stagingAvailable = false - // Why: a client-hosted route partition is derived at runtime and never reaches the startup - // replay, so staging it would only leave a plaintext cookie DB nothing ever consumes. - if (!supportsPendingBrowserCookieImportReplay(targetPartition)) { - diag( - ` restart fallback unsupported for partition "${targetPartition}" — not staging cookies` - ) - } else { - try { - mkdirSync(stagingDir, { recursive: true }) - copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) - stagingAvailable = true - } catch (err) { - const fsErr = err as NodeJS.ErrnoException - diag( - ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` - ) - // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - } - } - - let sourceSnapshot: ChromiumCookieSnapshot - try { - // Why: an open browser may hold cookies in WAL only; snapshot retries avoid pairing the main DB with a racing WAL. - sourceSnapshot = createChromiumCookieSnapshot(browser.cookiesPath) - } catch (err) { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } - diag(` Chromium snapshot failed: ${String(err)}`) - return { - ok: false, - reason: `Could not copy ${browser.label} cookies database. Try closing ${browser.label} first.` - } - } - - let sourceDb: InstanceType | null = null - let stagingDb: InstanceType | null = null - const closeStagingDb = (): void => { - try { - stagingDb?.close() - } catch { - /* best-effort */ - } - stagingDb = null - } - const discardStagingFile = (): void => { - // Why: the staged copy holds plaintext cookie values, and SQLite may have left sidecars beside it. - for (const suffix of ['', '-wal', '-shm']) { - try { - unlinkSync(stagingCookiesPath + suffix) - } catch { - /* best-effort */ - } - } - } - - try { - // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. - sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { - readOnly: true, - readBigInts: true - }) - let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null - let colList: string | null = null - let placeholders: string | null = null - if (stagingAvailable) { - // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same - // transient AV handle can make opening it throw — degrade instead of killing the import. - try { - stagingDb = new DatabaseSync(stagingCookiesPath) - // Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL - // can erase its scope marker and make cold-start replay mistake it for a legacy whole-image - // import, restoring the unrelated-cookie data loss this format is meant to prevent. - stagingDb.exec('PRAGMA journal_mode = DELETE') - targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - colList = targetCols.join(', ') - placeholders = targetCols.map(() => '?').join(', ') - } catch (err) { - diag(` staging database unusable, restart fallback disabled: ${String(err)}`) - stagingAvailable = false - targetColumnInfo = null - colList = null - placeholders = null - closeStagingDb() - // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. - discardStagingFile() - } - } - - // Why (STA-4300): the partition columns drift across Chromium versions, so read the source - // schema rather than assuming a row's missing column means "unpartitioned". - const sourceColumns = new Set( - (sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map( - (column) => column.name - ) - ) - const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< - string, - unknown - >[] - sourceDb.close() - sourceDb = null - - diag(` source has ${sourceRows.length} cookies`) - - if (sourceRows.length === 0) { - closeStagingDb() - discardStagingFile() - return { ok: false, reason: `No cookies found in ${browser.label}.` } - } - - // Why (STA-4300): partition fidelity is a property of the source row, even when its value - // cannot be decrypted. Plan first so decryption failure cannot discard a family's skip. - const partitionCandidates = sourceRows.flatMap((sourceRow) => { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain) - ? [] - : [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }] - }) - const nativePlan = planImportWrites(partitionCandidates) - const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow)) - const partitionBySourceRow = new Map( - partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition]) - ) - - // Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a - // family we cannot protect is the P0. Refuse before the jar is touched. - if (nativePlan.hasUnrepresentableSkip) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.' - } - } - - const needsSourceKey = sourceRows.some((sourceRow) => { - const encRaw = sourceRow.encrypted_value - if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) { - return false - } - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)) - }) - const sourceKey = needsSourceKey - ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) - : null - if (needsSourceKey && !sourceKey) { - closeStagingDb() - // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - discardStagingFile() - return { - ok: false, - reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` - } - } - - let imported = 0 - let skipped = 0 - let decryptFailed = 0 - let appBoundFailed = 0 - let keyringUnavailableFailed = 0 - let integritySkipped = 0 - let nonTransplantableSkipped = 0 - const partitionSkipped = nativePlan.skips.length - let memoryLoaded = 0 - let memoryFailed = 0 - const domainSet = new Set() - - type DecryptedCookie = Omit & { - decryptedValue: Buffer - sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - partition: SourcePartitionRead - } - - const decryptedCookies: DecryptedCookie[] = [] - // Why: the staging insert needs the RAW source row, so each scanned candidate carries it. - // A plan record holding only the derived fields compiles fine and then cannot stage. - const scanned: { entry: DecryptedCookie; sourceRow: Record }[] = [] - const sourceDomainValidity = new Map() - - // Why: staging only backs the cold-restart replay, so any failure writing it disables that - // fallback instead of aborting an import whose in-memory half still works. - let insertStmt: ReturnType['prepare']> | null = null - const disableStaging = (reason: string): void => { - diag(` staging disabled, restart fallback unavailable: ${reason}`) - stagingAvailable = false - insertStmt = null - closeStagingDb() - discardStagingFile() - } - - if (stagingDb && colList && placeholders) { - try { - insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) - stagingDb.exec('BEGIN TRANSACTION') - } catch (err) { - disableStaging(String(err)) - } - } else if (stagingAvailable) { - disableStaging('staged database exposed no cookies columns') - } - - // Why: keep the existing conservative fallback boundary for family-level omissions. Expanding - // partial-import restart behavior is separate from narrowing what a staged replay may replace. - if (nativePlan.skippedFamilies.size > 0) { - disableStaging( - `${nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image` - ) - } - - for (const sourceRow of sourceRows) { - const domain = sourceRow.host_key as string - const name = sourceRow.name as string - - if (isGoogleSourceBoundCookie(name, domain)) { - integritySkipped++ - continue - } - - // Why: transplanting these replaces a working sign-in with a session the site rejects. - if (isNonTransplantableCookieDomain(domain)) { - nonTransplantableSkipped++ - continue - } - - const encRaw = sourceRow.encrypted_value - // Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value. - const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null - const plainRaw = sourceRow.value - - let decryptedValue: Buffer - if (encBuf && encBuf.length > 0) { - const version = cookieEncryptionVersion(encBuf) - const appBoundIneligible = version === 'v20' - const keyringIneligible = - version === 'v11' && - sourceKey?.mode === 'aes-128-cbc' && - sourceKey.keyringUnavailable === true - const raw = - sourceKey && !appBoundIneligible && !keyringIneligible - ? decryptCookieValueRaw(encBuf, sourceKey) - : null - if (!raw) { - // Why: once decrypt returns null every failure looks identical, so attribute the cause - // here while the version prefix is still in hand. Without this an undecryptable profile - // is indistinguishable from an empty one and reports success. - decryptFailed++ - if (appBoundIneligible) { - appBoundFailed++ - } else if (keyringIneligible) { - keyringUnavailableFailed++ - } - skipped++ - continue - } - decryptedValue = raw - } else if (plainRaw instanceof Uint8Array) { - decryptedValue = Buffer.from(plainRaw) - } else if (typeof plainRaw === 'string') { - decryptedValue = Buffer.from(plainRaw, 'latin1') - } else { - decryptedValue = Buffer.alloc(0) - } - - let validDomain = sourceDomainValidity.get(domain) - if (validDomain === undefined) { - validDomain = normalizeCookieImportDomain(domain) !== null - sourceDomainValidity.set(domain, validDomain) - } - if (!validDomain) { - skipped++ - continue - } - - // Decryption failures are already counted above. Every other row suppressed by the - // pre-decryption family plan is counted once here, keeping partitionSkipped a breakdown. - if (!plannedSourceRows.has(sourceRow)) { - skipped++ - continue - } - - const path = sourceRow.path as string - const secure = sourceRow.is_secure === 1n - const httpOnly = sourceRow.is_httponly === 1n - const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0)) - const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint) - const partition = partitionBySourceRow.get(sourceRow)! - // Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement. - const value = decryptedValue.toString('latin1') - - // Why (STA-4300 I1): SCAN only. Nothing is emitted here — not decryptedCookies, not - // domainSet, not a staging row, not the imported count. bf6dc6fcba pushed the cookie and - // THEN applied the unreadable guard, so an unreadable row discovered late could not retract - // a sibling already emitted, and the jar-wide clear then removed more than was written back. - scanned.push({ - entry: { - decryptedValue, - value, - domain, - name, - path, - secure, - httpOnly, - sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined, - partition - }, - sourceRow - }) - } - - for (const { entry } of scanned) { - domainSet.add(entry.domain.startsWith('.') ? entry.domain.slice(1) : entry.domain) - } - // Why (STA-4797): the import may only destroy what it is replacing. Naming the scope from the - // plan — the same rows the writes come from — is what keeps the removal set from drifting past - // the write set, and it is derived here rather than at the clear because the staged image below - // has to be cleared to the identical scope. - const importScope = importedDomainScope([...domainSet]) - - // Why (STA-4797): the staged image must carry the same imported-domain scope as the live clear. - // Cold-start replay uses it to replace only those rows and preserve newer unrelated sessions. - if (stagingDb && insertStmt) { - try { - prepareStagedCookiesForImport(stagingDb, importScope) - } catch (err) { - disableStaging(String(err)) - } - } - - // EMIT: everything downstream derives from the plan, so there is no second place a row can - // leak in. - for (const { entry, sourceRow } of scanned) { - decryptedCookies.push(entry) - if (insertStmt && targetColumnInfo) { - try { - const params = buildChromiumCookieInsertParams( - targetColumnInfo, - sourceRow, - entry.decryptedValue - ) - insertStmt.run(...params) - } catch (err) { - disableStaging(String(err)) - } - } - // Why: counts importable cookies, not staged rows — the summary must stay truthful when - // the optional staging DB is unavailable. - imported++ - } - diag( - ` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies` - ) - const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - - const undecryptableWarning = buildUndecryptableWarning({ - decryptFailed, - appBoundFailed, - keyringUnavailableFailed - }) - - // Why: an older remote client ignores the new counter and would present this loss as success. - // Placed before the early return and before any jar mutation, so a client that cannot render - // the skip fails the import outright rather than reporting a partial import as complete. - if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) { - closeStagingDb() - discardStagingFile() - return { - ok: false, - reason: - 'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.' - } - } - - if (decryptedCookies.length === 0) { - const zeroPathWarning = undecryptableWarning - closeStagingDb() - discardStagingFile() - return { - ok: true, - profileId: '', - summary: { - totalCookies: sourceRows.length, - importedCookies: 0, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - // Why: partition skips are a breakdown of skippedCookies, never an addition to it, so - // totalCookies === importedCookies + skippedCookies keeps holding on this path too. - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [], - // Why: a profile whose rows cannot be decrypted returns here, and without this it is - // reported as a successful empty import. - ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) - } - } - } - - if (stagingDb) { - try { - stagingDb.exec('COMMIT') - closeStagingDb() - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) - } catch (err) { - disableStaging(String(err)) - } - } else { - diag(` staging skipped: ${imported} cookies will load in-memory only`) - } - - // Why: clear stale cookies for the domains being imported first; mixing them with the imported - // set makes sites reject the session. Non-transplantable families are exempt — nothing was - // imported for them, and their live session is the only one that works. - // Why (STA-4797): every other site in the partition is exempt too. The rationale above reaches - // only as far as the domains this import writes; beyond them a clear has nothing to reconcile - // and only signs the user out of sessions the import was never about. - // Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak - // the same CDP identities — cookies.set() cannot express the partition either one reads. - const cookieClearStore = openCookieClearStore(targetSession) - try { - // Why (STA-4601): the outer lock spans the clear and the writes that repopulate the jar, so a - // second import cannot clear between them and write on top of a newer import's jar. - await removeTransplantableCookies( - { - cookies: cookieClearStore, - snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies), - restoreClearIdentities: (identities) => - cookieClearStore.restoreClearIdentities(identities) - }, - // Why (STA-4300): the families this import declined to write must not be removed either. - // Passing them here keeps their coordinates out of the removal plan AND out of the CDP - // snapshot taken from it, so they are never submitted to any mutation. - nativePlan.skippedFamilies, - importScope - ) - diag( - ` cleared existing cookies for ${domainSet.size} imported domains before loading ${decryptedCookies.length} imported cookies` - ) - - const writable: SourceCookieToWrite[] = [] - for (const cookie of decryptedCookies) { - const url = deriveUrl(cookie.domain, cookie.secure) - if (!url) { - memoryFailed++ - continue - } - writable.push({ ...cookie, url }) - } - // Why: a rejected cookie here falls back to the staged cold-start replay rather than - // unwinding the import, so one failure must not stop the rest from loading. - const phase = await writeImportedCookies(cookieClearStore, writable, { - stopOnFailure: false, - log: diag - }) - memoryLoaded = phase.importedCount - memoryFailed += phase.writeRejected - } finally { - cookieClearStore.dispose() - } - - diag( - ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` - ) - - let warning: BrowserCookieImportSummary['warning'] - if (memoryFailed > 0 && stagingAvailable) { - // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. - browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) - diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else if (memoryFailed > 0) { - // Why: never register a path that was never written or can never be replayed — cold start - // would replay a missing or partial DB over the live partition. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) - // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. - warning = { - code: 'restart-fallback-unavailable', - loadedCookies: memoryLoaded, - failedCookies: memoryFailed - } - } else { - // Why: this import already rewrote the live session, so an older staged DB must not replay over it. - browserSessionRegistry.clearPendingCookieImport(targetPartition) - discardStagingFile() - diag(` all cookies loaded in-memory — no restart needed`) - } - - // Why: the session keeps the UA the registry set at startup (clean or native). - // Imports must not impersonate the source browser — the synthesized UA read a - // fork's marketing version as a Chromium version (STA-3514), and Google binds - // sessions to the re-import, not the UA (#12884), so it bought nothing. - // Google-bound integrity cookies are already excluded by - // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. - - // Why: a partial import still drops every undecryptable row, so silence here would report it - // as an unqualified success. The restart-fallback warning describes a lossier outcome and - // keeps precedence. - if (!warning && undecryptableWarning) { - warning = undecryptableWarning - } - - const summary: BrowserCookieImportSummary = { - totalCookies: sourceRows.length, - importedCookies: imported, - skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, - ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), - domains: [...domainSet].sort(), - ...(warning ? { warning } : {}) - } - - return { ok: true, profileId: '', summary } - } catch (err) { - try { - sourceDb?.close() - } catch { - /* may already be closed */ - } - try { - stagingDb?.close() - } catch { - /* may already be closed */ - } - // Why: drop the staging DB so a stale staged import isn't applied on the next cold start. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* may not exist yet */ - } - diag(` SQLite import failed: ${String(err)}`) - return { - ok: false, - reason: reasonWithDiagLog( - `Could not import cookies from ${browser.label}: ${summarizeCookieImportError(err)}.` - ) - } - } finally { - try { - sourceSnapshot.cleanup() - } catch (err) { - diag(` Chromium snapshot cleanup failed: ${String(err)}`) - } - } - }) + return importChromiumCookies(browser, targetPartition, options) } diff --git a/src/main/browser/browser-cookie-key.ts b/src/main/browser/browser-cookie-key.ts new file mode 100644 index 00000000000..76fc14cf9e6 --- /dev/null +++ b/src/main/browser/browser-cookie-key.ts @@ -0,0 +1,162 @@ +import { pbkdf2Sync } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { runProcessSync } from '../../shared/child-process/run-process' +import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary' +import { diag } from './browser-cookie-import-diagnostics' +import { + CHROMIUM_BROWSERS, + browserRootPath, + type DetectedBrowser +} from './browser-cookie-detection-types' +import type { EncryptionKeyResult } from './browser-cookie-sqlite' + +const PBKDF2_ITERATIONS = 1003 +const PBKDF2_KEY_LENGTH = 16 +const PBKDF2_SALT = 'saltysalt' + +function runKeychainCommand(program: string, args: readonly string[], timeoutMs: number): string { + const result = runProcessSync({ program, args, timeoutMs }) + if (result.code !== 0 || result.timedOut) { + throw new Error(`${program} exited with code ${result.code ?? 'unknown'}`) + } + return result.stdout.trim() +} + +export function getEncryptionKey( + keychainService: string, + keychainAccount: string, + browser?: DetectedBrowser +): EncryptionKeyResult | null { + if (process.platform === 'darwin') { + return getMacEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'linux') { + return getLinuxEncryptionKey(keychainService, keychainAccount) + } + if (process.platform === 'win32' && browser) { + return getWindowsEncryptionKey(browser) + } + return null +} + +export function getMacEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + try { + const raw = runKeychainCommand( + 'security', + ['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'], + 30_000 + ) + return { + mode: 'aes-128-cbc', + keysByVersion: { + v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1') + } + } + } catch { + return null + } +} + +export function getLinuxEncryptionKey( + keychainService: string, + keychainAccount: string +): EncryptionKeyResult | null { + // Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded + // "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result. + const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + + let keyringPassword = '' + try { + // Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool. + keyringPassword = runKeychainCommand( + 'secret-tool', + ['lookup', 'service', keychainService, 'account', keychainAccount], + 5_000 + ) + } catch { + // Why: fall back to application-based lookup used by newer Chromium versions. + try { + const app = keychainAccount.toLowerCase().replaceAll(' ', '') + keyringPassword = runKeychainCommand('secret-tool', ['lookup', 'application', app], 5_000) + } catch { + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') + } + } + + if (!keyringPassword) { + return { + mode: 'aes-128-cbc', + keysByVersion: { v10: v10Key }, + keyringUnavailable: true + } + } + + const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') + return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } } +} + +export function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null { + const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family) + if (!browserDef) { + return null + } + const root = browserRootPath(browserDef) + if (!root) { + return null + } + + const localStatePath = join(root, 'Local State') + if (!existsSync(localStatePath)) { + return null + } + + try { + const raw = readFileSync(localStatePath, 'utf-8') + const localState = JSON.parse(raw) + const encryptedKeyB64 = localState?.os_crypt?.encrypted_key + if (typeof encryptedKeyB64 !== 'string') { + return null + } + + const encryptedKey = Buffer.from(encryptedKeyB64, 'base64') + const dpapiPrefix = Buffer.from('DPAPI', 'utf-8') + if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) { + return null + } + + // Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection. + const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64') + const script = [ + 'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }', + 'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };', + '$in=[Convert]::FromBase64String([Console]::In.ReadLine());', + '$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,', + '[System.Security.Cryptography.DataProtectionScope]::CurrentUser);', + '[Convert]::ToBase64String($out)' + ].join('') + + // Why runProcessSync and an absolute path: a bare `powershell` spawn from a + // GUI-subsystem process opens a visible conhost that takes foreground, so + // keystrokes typed into an Orca terminal during a cookie import land in the + // black box (#14543), and PATH under Electron is not the user's (#11771). + const result = runProcessSync({ + program: windowsPowerShellPath(), + args: ['-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: 10_000, + input: dpapiData + }) + if (result.code !== 0 || result.timedOut) { + diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero') + return null + } + + return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' } + } catch (err) { + diag(` Windows DPAPI key extraction failed: ${String(err)}`) + return null + } +} diff --git a/src/main/browser/browser-cookie-safari-import.ts b/src/main/browser/browser-cookie-safari-import.ts new file mode 100644 index 00000000000..e8fa79c5fa0 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-import.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import type { BrowserCookieImportResult } from '../../shared/browser-workspace-types' +import { decodeSafariBinaryCookies } from './browser-cookie-safari-parser' +import { importValidatedCookies, cookieImportTarget } from './browser-cookie-import-pipeline' +import type { DetectedBrowser } from './browser-cookie-detection-types' +import { diag } from './browser-cookie-import-diagnostics' + +// --------------------------------------------------------------------------- +// Safari import +// --------------------------------------------------------------------------- + +export async function importCookiesFromSafari( + browser: DetectedBrowser, + targetPartition: string +): Promise { + diag(`importCookiesFromSafari: partition="${targetPartition}"`) + + let data: Buffer + try { + data = readFileSync(browser.cookiesPath) + } catch (err) { + diag(` Safari read failed: ${String(err)}`) + // Why: Safari's Cookies.binarycookies is in a sandbox container; reading it needs Full Disk Access. + const isPermError = + err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM' + if (isPermError) { + return { + ok: false, + reason: + 'macOS denied access to Safari cookies. Grant Full Disk Access to Orca in System Settings → Privacy & Security → Full Disk Access.' + } + } + return { ok: false, reason: 'Could not read Safari cookies.' } + } + + try { + const cookies = decodeSafariBinaryCookies(data) + diag(` Safari source has ${cookies.length} cookies`) + + if (cookies.length === 0) { + return { ok: false, reason: 'No cookies found in Safari.' } + } + + const now = Math.floor(Date.now() / 1000) + const valid = cookies.filter((c) => !c.expirationDate || c.expirationDate > now) + + if (valid.length === 0) { + return { ok: false, reason: 'All Safari cookies are expired.' } + } + + return importValidatedCookies( + valid, + cookies.length, + cookieImportTarget(targetPartition), + 'replace-imported-domains' + ) + } catch (err) { + diag(` Safari import failed: ${String(err)}`) + return { ok: false, reason: 'Could not import cookies from Safari.' } + } +} diff --git a/src/main/browser/browser-cookie-safari-parser.ts b/src/main/browser/browser-cookie-safari-parser.ts new file mode 100644 index 00000000000..9c4f7552488 --- /dev/null +++ b/src/main/browser/browser-cookie-safari-parser.ts @@ -0,0 +1,148 @@ +import { deriveUrl } from './browser-cookie-validation' +import type { ValidatedCookie } from './browser-cookie-validation' + +const MAC_EPOCH_DELTA = 978_307_200 + +// --------------------------------------------------------------------------- +// Safari binary cookie parser +// --------------------------------------------------------------------------- + +export function decodeSafariBinaryCookies(buffer: Buffer): ValidatedCookie[] { + if (buffer.length < 8) { + return [] + } + if (buffer.subarray(0, 4).toString('utf8') !== 'cook') { + return [] + } + + const pageCount = buffer.readUInt32BE(4) + let cursor = 8 + if (cursor + pageCount * 4 > buffer.length) { + return [] + } + const pageSizes: number[] = [] + for (let i = 0; i < pageCount; i++) { + pageSizes.push(buffer.readUInt32BE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const pageSize of pageSizes) { + const page = buffer.subarray(cursor, cursor + pageSize) + cursor += pageSize + appendSafariCookies(cookies, decodeSafariPage(page)) + } + return cookies +} + +export function appendSafariCookies( + target: ValidatedCookie[], + cookies: readonly ValidatedCookie[] +): void { + // Why: pages can hold large cookie lists; push per-item to avoid exceeding the spread argument limit. + for (const cookie of cookies) { + target.push(cookie) + } +} + +export function decodeSafariPage(page: Buffer): ValidatedCookie[] { + if (page.length < 16) { + return [] + } + if (page.readUInt32BE(0) !== 0x00000100) { + return [] + } + + const cookieCount = page.readUInt32LE(4) + if (8 + cookieCount * 4 > page.length) { + return [] + } + const offsets: number[] = [] + let cursor = 8 + for (let i = 0; i < cookieCount; i++) { + offsets.push(page.readUInt32LE(cursor)) + cursor += 4 + } + + const cookies: ValidatedCookie[] = [] + for (const offset of offsets) { + const cookie = decodeSafariCookie(page.subarray(offset)) + if (cookie) { + cookies.push(cookie) + } + } + return cookies +} + +export function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { + if (buf.length < 48) { + return null + } + // Why: size comes from the file and could be attacker-controlled; clamp so readCString can't escape the subarray. + const size = Math.min(buf.readUInt32LE(0), buf.length) + if (size < 48) { + return null + } + + const flags = buf.readUInt32LE(8) + const secure = (flags & 1) !== 0 + const httpOnly = (flags & 4) !== 0 + + const urlOffset = buf.readUInt32LE(16) + const nameOffset = buf.readUInt32LE(20) + const pathOffset = buf.readUInt32LE(24) + const valueOffset = buf.readUInt32LE(28) + + // Why: Safari stores dates as Mac absolute time (seconds since 2001-01-01). + const expiration = buf.length >= 48 ? buf.readDoubleLE(40) : 0 + + const name = readCString(buf, nameOffset, size) + if (!name) { + return null + } + const value = readCString(buf, valueOffset, size) ?? '' + const path = readCString(buf, pathOffset, size) ?? '/' + const rawUrl = readCString(buf, urlOffset, size) ?? '' + + // Why: Safari stores the domain in the URL field, not as a separate domain column. + const domain = rawUrl.startsWith('.') ? rawUrl : rawUrl || null + if (!domain) { + return null + } + + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = expiration > 0 ? Math.round(expiration + MAC_EPOCH_DELTA) : undefined + + return { + url, + name, + value, + domain, + path, + secure, + httpOnly, + sameSite: 'unspecified', + expirationDate, + // Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every + // decoded cookie is genuinely unpartitioned rather than missing an identity. + partition: { status: 'unpartitioned' } + } +} + +export function readCString(buf: Buffer, offset: number, end: number): string | null { + if (offset < 0 || offset >= end) { + return null + } + let cursor = offset + while (cursor < end && buf[cursor] !== 0) { + cursor++ + } + if (cursor >= end) { + return null + } + return buf.toString('utf8', offset, cursor) +} diff --git a/src/main/browser/browser-cookie-sqlite.ts b/src/main/browser/browser-cookie-sqlite.ts new file mode 100644 index 00000000000..05593c79024 --- /dev/null +++ b/src/main/browser/browser-cookie-sqlite.ts @@ -0,0 +1,147 @@ +const CHROMIUM_EPOCH_OFFSET = 11644473600n + +export function chromiumTimestampToUnix(chromiumTs: bigint | number | string): number { + if (!chromiumTs || chromiumTs === 0n || chromiumTs === 0 || chromiumTs === '0') { + return 0 + } + try { + const ts = + typeof chromiumTs === 'bigint' + ? chromiumTs + : BigInt(typeof chromiumTs === 'number' ? Math.round(chromiumTs) : chromiumTs) + if (ts === 0n) { + return 0 + } + return Math.max(Number(ts / 1000000n - CHROMIUM_EPOCH_OFFSET), 0) + } catch { + return 0 + } +} + +// Why: each platform protects the Chromium key differently: macOS/Linux PBKDF2→AES-128-CBC, Windows DPAPI→AES-256-GCM. + +export type EncryptionKeyResult = + | { + mode: 'aes-128-cbc' + keysByVersion: Partial> + keyringUnavailable?: boolean + } + | { mode: 'aes-256-gcm'; key: Buffer } + +export type ChromiumCookieColumnInfo = { + name: string + type?: string + notnull?: number | bigint + dflt_value?: unknown +} + +export function parseSqliteDefaultValue( + raw: unknown, + type: string +): string | number | Buffer | null { + if (raw === null || raw === undefined) { + return null + } + if (typeof raw !== 'string') { + return typeof raw === 'number' || typeof raw === 'bigint' ? Number(raw) : String(raw) + } + + const trimmed = raw.trim() + if (!trimmed || trimmed.toUpperCase() === 'NULL') { + return null + } + if (/^X''$/i.test(trimmed) || type.includes('BLOB')) { + return Buffer.alloc(0) + } + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1).replaceAll("''", "'") + } + if (type.includes('INT')) { + const numeric = Number(trimmed) + return Number.isFinite(numeric) ? numeric : 0 + } + return trimmed +} + +export function normalizeSqliteCookieValue( + value: unknown +): string | number | bigint | Buffer | null { + if (value instanceof Uint8Array) { + return Buffer.from(value) + } + if (value === undefined || value === null) { + return null + } + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'string') { + return value + } + return String(value) +} + +export function isSqliteNotNull(column: ChromiumCookieColumnInfo): boolean { + return Number(column.notnull ?? 0) !== 0 +} + +export function fallbackChromiumCookieColumnValue( + column: ChromiumCookieColumnInfo, + sourceRow: Record +): string | number | bigint | Buffer | null { + const type = (column.type ?? '').toUpperCase() + const defaultValue = parseSqliteDefaultValue(column.dflt_value, type) + if (defaultValue !== null) { + return defaultValue + } + if (!isSqliteNotNull(column)) { + return null + } + + switch (column.name) { + case 'value': + case 'encrypted_value': + return Buffer.alloc(0) + case 'top_frame_site_key': + return '' + case 'source_port': + return -1 + case 'last_update_utc': + return normalizeSqliteCookieValue(sourceRow.creation_utc) ?? 0 + default: + if (type.includes('BLOB')) { + return Buffer.alloc(0) + } + if (type.includes('INT')) { + return 0 + } + return '' + } +} + +export function buildChromiumCookieInsertParams( + targetColumns: ChromiumCookieColumnInfo[], + sourceRow: Record, + decryptedValue: Buffer +): (string | number | bigint | Buffer | null)[] { + return targetColumns.map((column) => { + if (column.name === 'encrypted_value') { + return Buffer.alloc(0) + } + if (column.name === 'value') { + return decryptedValue + } + + const sourceHasColumn = Object.hasOwn(sourceRow, column.name) + const sourceValue = sourceHasColumn ? normalizeSqliteCookieValue(sourceRow[column.name]) : null + if (sourceValue !== null) { + return sourceValue + } + if (sourceHasColumn && !isSqliteNotNull(column)) { + return null + } + + // Why: cookie columns drift across Chrome/Electron versions; missing NOT NULL columns need Chromium defaults, not NULL. + return fallbackChromiumCookieColumnValue(column, sourceRow) + }) +} diff --git a/src/main/browser/browser-cookie-validation.ts b/src/main/browser/browser-cookie-validation.ts new file mode 100644 index 00000000000..6ae543245ef --- /dev/null +++ b/src/main/browser/browser-cookie-validation.ts @@ -0,0 +1,127 @@ +import { normalizeCookieDomain } from './browser-cookie-import-policy' +import { + readJsonCookiePartition, + type SourcePartitionRead +} from './browser-cookie-source-partition' +import type { ImportedCookieFields } from './browser-cookie-import-write' + +export type RawCookieEntry = { + domain?: unknown + name?: unknown + value?: unknown + path?: unknown + secure?: unknown + httpOnly?: unknown + sameSite?: unknown + expirationDate?: unknown + partitionKey?: unknown + partitionKeyOpaque?: unknown +} + +// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to +// state what it read. An optional field would let a new source silently default to unpartitioned. +export type ValidatedCookie = ImportedCookieFields & { + sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' + partition: SourcePartitionRead +} + +// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. +export function chromiumSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 1: + return 'no_restriction' + case 2: + return 'lax' + case 3: + return 'strict' + default: + return 'unspecified' + } +} + +export function firefoxSameSite(raw: number): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + switch (raw) { + case 0: + return 'no_restriction' + case 1: + return 'lax' + case 2: + return 'strict' + default: + return 'unspecified' + } +} + +export function normalizeSameSite( + raw: unknown +): 'unspecified' | 'no_restriction' | 'lax' | 'strict' { + if (typeof raw === 'number') { + return chromiumSameSite(raw) + } + if (typeof raw !== 'string') { + return 'unspecified' + } + const lower = raw.toLowerCase() + if (lower === 'lax') { + return 'lax' + } + if (lower === 'strict') { + return 'strict' + } + if (lower === 'none' || lower === 'no_restriction') { + return 'no_restriction' + } + return 'unspecified' +} + +// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. +export function deriveUrl(domain: string, secure: boolean): string | null { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { + return null + } + const protocol = secure ? 'https' : 'http' + try { + const url = new URL(`${protocol}://${normalizedDomain}/`) + return url.toString() + } catch { + return null + } +} + +export function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { + if (typeof raw.domain !== 'string' || raw.domain.trim().length === 0) { + return null + } + if (typeof raw.name !== 'string' || raw.name.trim().length === 0) { + return null + } + if (typeof raw.value !== 'string') { + return null + } + + const domain = raw.domain.trim() + const secure = raw.secure === true || raw.secure === 1 + const url = deriveUrl(domain, secure) + if (!url) { + return null + } + + const expirationDate = + typeof raw.expirationDate === 'number' && raw.expirationDate > 0 + ? raw.expirationDate + : undefined + + return { + url, + name: raw.name.trim(), + value: raw.value, + domain, + path: typeof raw.path === 'string' ? raw.path : '/', + secure, + httpOnly: raw.httpOnly === true || raw.httpOnly === 1, + sameSite: normalizeSameSite(raw.sameSite), + expirationDate, + partition: readJsonCookiePartition(raw.partitionKey, raw.partitionKeyOpaque) + } +} diff --git a/src/main/browser/browser-manager-bindings.ts b/src/main/browser/browser-manager-bindings.ts new file mode 100644 index 00000000000..bebb3a1f295 --- /dev/null +++ b/src/main/browser/browser-manager-bindings.ts @@ -0,0 +1,82 @@ +import { resolveRendererWebContents } from './browser-guest-renderer-target' +import { setupGuestContextMenu } from './browser-guest-context-menu' +import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' +import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' +import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' +import { BrowserManagerGrab } from './browser-manager-grab' + +export abstract class BrowserManagerBindings extends BrowserManagerGrab { + protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { + this.contextMenuCleanupByTabId.set( + browserTabId, + setupGuestContextMenu({ + browserTabId, + guest, + resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) + }) + ) + } + + // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. + protected setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + + this.grabShortcutCleanupByTabId.set( + browserTabId, + setupGrabShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), + getKeybindings: () => this.settingsResolver?.().keybindings + }) + ) + } + + // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. + protected setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + + this.shortcutForwardingCleanupByTabId.set( + browserTabId, + setupGuestShortcutForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), + shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, + isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, + getKeybindings: () => this.settingsResolver?.().keybindings, + resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, + resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null + }) + ) + } + + protected setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId) + }) + ) + } +} diff --git a/src/main/browser/browser-manager-download-creation.ts b/src/main/browser/browser-manager-download-creation.ts new file mode 100644 index 00000000000..fcef84b9809 --- /dev/null +++ b/src/main/browser/browser-manager-download-creation.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { routeBrowserClientDownload } from './browser-client-download-routing' +import { + safeOrigin, + type ActiveDownload, + type BrowserDownloadDoneState +} from './browser-manager-types' +import type { BrowserDownloadFinishedEvent } from '../../shared/browser-guest-events' +import { BrowserManagerQueries } from './browser-manager-queries' + +export abstract class BrowserManagerDownloadCreation extends BrowserManagerQueries { + handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { + const { guestWebContentsId, item } = args + const downloadId = randomUUID() + const requestedFilename = (() => { + try { + return item.getFilename() || 'download' + } catch { + return 'download' + } + })() + const totalBytes = (() => { + try { + const total = item.getTotalBytes() + return total > 0 ? total : null + } catch { + return null + } + })() + const mimeType = (() => { + try { + const mime = item.getMimeType() + return mime || null + } catch { + return null + } + })() + const origin = (() => { + try { + return safeOrigin(item.getURL()) + } catch { + return 'unknown' + } + })() + + // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself + // instead of reserving a name in the desktop Downloads folder. A popup downloads to its + // opener's page: the popup itself is a client-local transient with no logical page of its own. + const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) + const decision = routeBrowserClientDownload({ + guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId + }) + const clientRoute = decision.kind === 'remote' ? decision.route : null + const destination = (() => { + if (clientRoute) { + return { + filename: requestedFilename, + savePath: clientRoute.stagingPath, + reservationKey: null + } + } + // Why: a client-hosted download with no resolvable remote destination is canceled rather than + // written to this desktop's Downloads folder. + if (decision.kind === 'blocked') { + return null + } + try { + return browserDownloadDestinationReservations.reserve(requestedFilename) + } catch (error) { + console.error('[browser-download] Failed to choose download destination:', error) + return null + } + })() + + const fallbackSavePath = destination?.savePath ?? '' + + const download: ActiveDownload = { + downloadId, + guestWebContentsId, + browserTabId: null, + rendererWebContentsId: null, + origin, + filename: destination?.filename ?? requestedFilename, + totalBytes, + mimeType, + item, + savePath: fallbackSavePath, + reservationKey: destination?.reservationKey ?? null, + clientRoute, + remoteDestination: undefined, + receivedBytes: 0, + transientState: null, + terminalEvent: null, + startedSent: false, + cleanup: null + } + this.downloadsById.set(downloadId, download) + + const browserTabId = ownerContext?.browserTabId ?? null + if (browserTabId) { + this.bindDownloadToTab(downloadId, browserTabId) + } else { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] + pending.push(downloadId) + this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) + } + + if (!destination) { + this.finishDownloadInternal( + downloadId, + 'failed', + decision.kind === 'blocked' + ? 'Could not save the download to the remote workspace.' + : 'Could not choose a Downloads file name.' + ) + try { + item.cancel() + } catch { + // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. + } + return + } + + try { + item.setSavePath(destination.savePath) + } catch (error) { + console.error('[browser-download] Failed to set download destination:', error) + this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') + try { + item.cancel() + } catch { + // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. + } + return + } + + const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { + download.receivedBytes = this.getDownloadReceivedBytes(download.item) + download.transientState = state + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state + }) + } + const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { + const status: BrowserDownloadFinishedEvent['status'] = + state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' + const failure = + status === 'failed' + ? state === 'interrupted' + ? 'Download was interrupted.' + : 'Download failed.' + : null + if (download.clientRoute) { + void this.settleClientHostedDownload(download, status, failure) + return + } + this.finishDownloadInternal(download.downloadId, status, failure) + } + download.cleanup = (): void => { + try { + download.item.off('updated', updatedHandler) + download.item.off('done', doneHandler) + } catch { + // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. + } + } + item.on('updated', updatedHandler) + item.once('done', doneHandler) + + if (browserTabId) { + this.sendDownloadStarted(downloadId) + } + } + + cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { + const download = this.downloadsById.get(args.downloadId) + if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { + return false + } + this.cancelDownloadInternal(args.downloadId, 'Canceled.') + return true + } +} diff --git a/src/main/browser/browser-manager-download-lifecycle.ts b/src/main/browser/browser-manager-download-lifecycle.ts new file mode 100644 index 00000000000..614b7651d16 --- /dev/null +++ b/src/main/browser/browser-manager-download-lifecycle.ts @@ -0,0 +1,241 @@ +import { browserDownloadDestinationReservations } from './browser-download-destination' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserDownloadRequestedEvent +} from '../../shared/browser-guest-events' +import type { ActiveDownload } from './browser-manager-types' +import { BrowserManagerDownloadCreation } from './browser-manager-download-creation' + +export abstract class BrowserManagerDownloadLifecycle extends BrowserManagerDownloadCreation { + protected bindDownloadToTab(downloadId: string, browserTabId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + download.browserTabId = browserTabId + download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null + } + + protected flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + for (const downloadId of pending) { + this.bindDownloadToTab(downloadId, browserTabId) + this.flushDownloadSnapshot(downloadId) + } + } + + protected flushDownloadSnapshot(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + this.sendDownloadStarted(downloadId) + if (download.receivedBytes > 0 || download.transientState) { + this.sendDownloadProgress(download.browserTabId, { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + receivedBytes: download.receivedBytes, + totalBytes: download.totalBytes, + state: download.transientState + }) + } + if (download.terminalEvent) { + this.sendDownloadFinished(download.browserTabId, { + ...download.terminalEvent, + browserPageId: download.browserTabId ?? undefined + }) + this.downloadsById.delete(downloadId) + } + } + + protected sendDownloadStarted(downloadId: string): void { + const download = this.downloadsById.get(downloadId) + if (!download?.browserTabId) { + return + } + if (download.startedSent) { + return + } + const renderer = this.resolveRendererForBrowserTab(download.browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-requested', { + browserPageId: download.browserTabId, + downloadId: download.downloadId, + origin: download.origin, + filename: download.filename, + totalBytes: download.totalBytes, + mimeType: download.mimeType, + savePath: download.savePath, + status: 'downloading' + } satisfies BrowserDownloadRequestedEvent) + download.startedSent = true + } + + protected sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-progress', payload) + } + + protected sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void { + if (!browserTabId) { + return + } + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:download-finished', payload) + } + + protected async settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise { + const route = download.clientRoute + if (!route) { + return + } + if (status !== 'completed') { + download.clientRoute = null + await route.abort().catch(() => undefined) + this.finishDownloadInternal(download.downloadId, status, failure) + return + } + try { + // Why: the route stays on the record for the whole commit, which spans many round trips -- a + // cancel arriving mid-stream has to find something to abort or the bytes land anyway. + const remoteDestination = await route.complete(download.filename) + download.clientRoute = null + download.remoteDestination = remoteDestination + // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. + download.savePath = '' + this.finishDownloadInternal(download.downloadId, 'completed', null) + } catch (error) { + download.clientRoute = null + if (download.terminalEvent) { + // A cancel already reported the outcome; this rejection is that cancel taking effect. + return + } + console.error('[browser-download] Failed to save download to the remote workspace:', error) + this.finishDownloadInternal( + download.downloadId, + 'failed', + 'Could not save the download to the remote workspace.' + ) + } + } + + protected cancelDownloadInternal(downloadId: string, reason: string): void { + const download = this.downloadsById.get(downloadId) + if (!download) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + const shouldSendCancel = !download.terminalEvent + + try { + download.item.cancel() + } catch { + // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. + } + + if (shouldSendCancel) { + this.finishDownloadInternal(downloadId, 'canceled', reason || null) + return + } + + this.downloadsById.delete(downloadId) + } + + protected finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void { + const download = this.downloadsById.get(downloadId) + if (!download || download.terminalEvent) { + return + } + + if (download.cleanup) { + download.cleanup() + download.cleanup = null + } + browserDownloadDestinationReservations.release(download.reservationKey) + download.reservationKey = null + if (download.clientRoute) { + // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. + void download.clientRoute.abort().catch(() => undefined) + download.clientRoute = null + } + const event: BrowserDownloadFinishedEvent = { + browserPageId: download.browserTabId ?? undefined, + downloadId: download.downloadId, + status, + savePath: download.savePath || null, + ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), + error + } + download.terminalEvent = event + if (download.browserTabId) { + this.sendDownloadStarted(downloadId) + this.sendDownloadFinished(download.browserTabId, event) + this.downloadsById.delete(downloadId) + } + } + + protected cancelPendingDownloadsForGuest(guestWebContentsId: number): void { + const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) + this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) + if (!pending?.length) { + return + } + for (const downloadId of pending) { + const download = this.downloadsById.get(downloadId) + if (!download) { + continue + } + if (download.terminalEvent) { + this.downloadsById.delete(downloadId) + continue + } + this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') + const afterCancel = this.downloadsById.get(downloadId) + if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { + this.downloadsById.delete(downloadId) + } + } + } + + protected getDownloadReceivedBytes(item: Electron.DownloadItem): number { + try { + return Math.max(0, item.getReceivedBytes()) + } catch { + return 0 + } + } +} diff --git a/src/main/browser/browser-manager-event-forwarding.ts b/src/main/browser/browser-manager-event-forwarding.ts new file mode 100644 index 00000000000..62c6e1d00a1 --- /dev/null +++ b/src/main/browser/browser-manager-event-forwarding.ts @@ -0,0 +1,123 @@ +import type { + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { BrowserManagerBindings } from './browser-manager-bindings' +import type { PendingPermissionEvent, PendingPopupEvent } from './browser-manager-types' + +export abstract class BrowserManagerEventForwarding extends BrowserManagerBindings { + protected forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (!browserTabId) { + // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. + this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) + return + } + this.sendGuestLoadFailure(browserTabId, loadError) + } + + protected forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPermissionDenied(browserTabId, event) + } + + protected flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPermissionDenied(browserTabId, event) + } + } + + protected sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:permission-denied', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPermissionDeniedEvent) + } + + protected forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { + const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserTabId) { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] + pending.push(event) + if (pending.length > 5) { + pending.shift() + } + this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) + return + } + this.sendPopupEvent(browserTabId, event) + } + + protected flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) + if (!pending?.length) { + return + } + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + for (const event of pending) { + this.sendPopupEvent(browserTabId, event) + } + } + + protected sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:popup', { + browserPageId: browserTabId, + ...event + } satisfies BrowserPopupEvent) + } + + protected flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { + const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) + if (!pending) { + return + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.sendGuestLoadFailure(browserTabId, pending) + } + + protected sendGuestLoadFailure( + browserTabId: string, + loadError: { code: number; description: string; validatedUrl: string } + ): void { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return + } + renderer.send('browser:guest-load-failed', { + browserPageId: browserTabId, + loadError: { + ...loadError, + validatedUrl: redactKagiSessionToken(loadError.validatedUrl) + } + }) + } +} diff --git a/src/main/browser/browser-manager-final.ts b/src/main/browser/browser-manager-final.ts new file mode 100644 index 00000000000..b19369fc91d --- /dev/null +++ b/src/main/browser/browser-manager-final.ts @@ -0,0 +1,22 @@ +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { normalizeBrowserNavigationUrl } from '../../shared/browser-url' +import { BrowserManagerEventForwarding } from './browser-manager-event-forwarding' + +export abstract class BrowserManagerFinal extends BrowserManagerEventForwarding { + protected openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { + const renderer = this.resolveRendererForBrowserTab(browserTabId) + if (!renderer) { + return false + } + const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) + if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { + return false + } + // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. + renderer.send('browser:open-link-in-orca-tab', { + browserPageId: browserTabId, + url: normalizedUrl + }) + return true + } +} diff --git a/src/main/browser/browser-manager-grab.ts b/src/main/browser/browser-manager-grab.ts new file mode 100644 index 00000000000..77e890138c5 --- /dev/null +++ b/src/main/browser/browser-manager-grab.ts @@ -0,0 +1,126 @@ +import { webContents } from 'electron' +import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' +import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' +import { getWorkspaceDocPageGuest } from './doc-preview-guest-policy' +import type { + BrowserGrabCancelReason, + BrowserGrabResult, + BrowserGrabRect, + BrowserGrabPayload, + BrowserGrabScreenshot +} from './browser-manager-types' +import { BrowserManagerViewport } from './browser-manager-viewport' + +export abstract class BrowserManagerGrab extends BrowserManagerViewport { + // --- Browser Context Grab — main-owned operations --- + + /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ + /** + * The guest a request from `senderWebContentsId` may act on, across both halves of the page + * registry. This is the only door taught about workspace-document guests: they are kept out of + * the browsing maps entirely, so page management, agent commands, download routing and + * certificate attribution all miss them without a guard of their own — and a reader who opens a + * tool on the document in front of them still gets an answer. + */ + getAuthorizedGuest( + browserTabId: string, + senderWebContentsId: number + ): Electron.WebContents | null { + const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) + if (docGuest) { + return docGuest + } + const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) + if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { + return null + } + const guestId = this.webContentsIdByTabId.get(browserTabId) + if (guestId == null) { + return null + } + const guest = webContents.fromId(guestId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return null + } + return guest + } + + /** Returns true if a grab operation is currently active for this tab. */ + hasActiveGrabOp(browserTabId: string): boolean { + return this.grabSessionController.hasActiveGrabOp(browserTabId) + } + + /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ + async setGrabMode( + browserTabId: string, + enabled: boolean, + guest: Electron.WebContents + ): Promise { + if (!enabled) { + const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) + this.cancelGrabOp(browserTabId, 'user') + if (hadActiveGrabOp) { + return true + } + try { + await guest.executeJavaScript(buildGuestOverlayScript('teardown')) + return true + } catch { + return false + } + } + // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. + try { + await guest.executeJavaScript(buildGuestOverlayScript('arm')) + return true + } catch { + return false + } + } + + /** + * Await a single grab selection on the given tab; resolves once on click, cancel, or error. + * + * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. + */ + awaitGrabSelection( + browserTabId: string, + opId: string, + guest: Electron.WebContents + ): Promise { + return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) + } + + /** Cancel an active grab operation for the given tab. */ + cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { + this.grabSessionController.cancelGrabOp(browserTabId, reason) + } + + /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ + async captureSelectionScreenshot( + _browserTabId: string, + rect: BrowserGrabRect, + guest: Electron.WebContents + ): Promise { + return captureGrabSelectionScreenshot(rect, guest) + } + + /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ + async extractHoverPayload( + _browserTabId: string, + guest: Electron.WebContents + ): Promise { + try { + const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) + if (!rawPayload || typeof rawPayload !== 'object') { + return null + } + return clampGrabPayload(rawPayload) + } catch { + return null + } + } +} diff --git a/src/main/browser/browser-manager-guest-cleanup.ts b/src/main/browser/browser-manager-guest-cleanup.ts new file mode 100644 index 00000000000..9dac4f3f665 --- /dev/null +++ b/src/main/browser/browser-manager-guest-cleanup.ts @@ -0,0 +1,44 @@ +import { BrowserManagerGuestNavigationPolicy } from './browser-manager-guest-navigation-policy' + +export abstract class BrowserManagerGuestCleanup extends BrowserManagerGuestNavigationPolicy { + protected retireStaleGuestWebContents(previousWebContentsId: number): void { + // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. + this.cleanupGuestPolicyAttachment(previousWebContentsId) + } + + protected cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + const isPrimaryGuest = browserTabId !== undefined + if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { + this.webContentsIdByTabId.delete(browserTabId) + } + this.tabIdByWebContentsId.delete(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) + const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) + if (policyCleanup) { + policyCleanup() + this.policyCleanupByGuestId.delete(guestWebContentsId) + } + this.policyAttachedGuestIds.delete(guestWebContentsId) + this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) + this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) + // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. + if (isPrimaryGuest) { + for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { + if (owner.rootGuestWebContentsId === guestWebContentsId) { + this.popupOwnerContextByGuestId.delete(popupGuestId) + } + } + } + this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) + this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) + this.pendingPopupEventsByGuestId.delete(guestWebContentsId) + this.cancelPendingDownloadsForGuest(guestWebContentsId) + } +} diff --git a/src/main/browser/browser-manager-guest-navigation-policy.ts b/src/main/browser/browser-manager-guest-navigation-policy.ts new file mode 100644 index 00000000000..abacd268640 --- /dev/null +++ b/src/main/browser/browser-manager-guest-navigation-policy.ts @@ -0,0 +1,152 @@ +import { + normalizeBrowserNavigationUrl, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { isChromiumInternalErrorUrl } from './browser-manager-types' +import { BrowserManagerGuestPopupPolicy } from './browser-manager-guest-popup-policy' + +export abstract class BrowserManagerGuestNavigationPolicy extends BrowserManagerGuestPopupPolicy { + protected installGuestNavigationPolicy(guest: Electron.WebContents): () => void { + const navigationGuard = (event: Electron.Event, url: string): boolean => { + // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. + if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { + return true + } + // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. + if (url.startsWith('file:')) { + event.preventDefault() + return false + } + if (!normalizeBrowserNavigationUrl(url)) { + // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. + event.preventDefault() + return false + } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) + } + + const didFailLoadHandler = ( + _event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ): void => { + if (!isMainFrame) { + return + } + // Why: a nav that never committed must not leave its target standing as the tab's host. + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. + return + } + if (errorCode === -3) { + // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = this.buildLoadError( + errorCode, + errorDescription || 'This site could not be reached.', + validatedURL || guest.getURL() || 'about:blank' + ) + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: once committed, getURL() reports this url, so the pending target is redundant. + this.pendingNavigationByGuestId.delete(guest.id) + // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) + } + + guest.on('will-navigate', navigationGuard) + guest.on('will-redirect', willRedirectHandler) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) + guest.on('did-fail-load', didFailLoadHandler) + const handleDestroyed = (): void => { + // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + + return () => { + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + if (!guest.isDestroyed()) { + guest.off('will-navigate', navigationGuard) + guest.off('will-redirect', willRedirectHandler) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) + guest.off('did-fail-load', didFailLoadHandler) + } + } + } +} diff --git a/src/main/browser/browser-manager-guest-policy.ts b/src/main/browser/browser-manager-guest-policy.ts new file mode 100644 index 00000000000..c0d522235c8 --- /dev/null +++ b/src/main/browser/browser-manager-guest-policy.ts @@ -0,0 +1,76 @@ +import { randomUUID } from 'node:crypto' +import { + BROWSING_GUEST_POLICY, + type BrowserGuestPolicy, + type PopupOwnerContext +} from './browser-manager-types' +import { BrowserManagerGuestCleanup } from './browser-manager-guest-cleanup' +import { installDocPreviewGuestPolicy } from './doc-preview-guest-policy' + +export abstract class BrowserManagerGuestPolicy extends BrowserManagerGuestCleanup { + attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext: PopupOwnerContext | null = null, + policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY + ): void { + if (this.policyAttachedGuestIds.has(guest.id)) { + return + } + this.policyAttachedGuestIds.add(guest.id) + // Why one door with a profile rather than a second installer beside it: whether a guest was + // policy-attached at all is what registration and teardown both key on, so a guest that took + // another path into the app is invisible to both. + if (policy.profile === 'workspace-doc') { + this.attachWorkspaceDocGuestPolicies(guest, policy.host) + return + } + if (inheritedOwnerContext) { + this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) + } + // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. + const clickedLinkFrameName = inheritedOwnerContext + ? null + : `__orca_clicked_link_foreground_${randomUUID()}` + if (clickedLinkFrameName) { + this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) + } + + // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. + const disposeAntiDetection = this.injectAntiDetection(guest) + // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. + guest.setBackgroundThrottling(false) + const disposePopupPolicy = this.installGuestPopupPolicy(guest, clickedLinkFrameName) + const disposeNavigationPolicy = this.installGuestNavigationPolicy(guest) + + // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. + this.policyCleanupByGuestId.set(guest.id, () => { + disposeAntiDetection() + disposePopupPolicy() + disposeNavigationPolicy() + }) + } + + /** + * A workspace document is not the web: no popups, no link routing, no anti-detection, and no + * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is + * this method's teardown, so a retired preview drops its listeners on the same path. + */ + protected attachWorkspaceDocGuestPolicies( + guest: Electron.WebContents, + host: Electron.WebContents + ): void { + const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) + const handleDestroyed = (): void => { + this.cleanupGuestPolicyAttachment(guest.id) + } + guest.on('destroyed', handleDestroyed) + this.policyCleanupByGuestId.set(guest.id, () => { + disposeDocPolicy() + try { + guest.off('destroyed', handleDestroyed) + } catch { + // guest may already be destroyed + } + }) + } +} diff --git a/src/main/browser/browser-manager-guest-popup-policy.ts b/src/main/browser/browser-manager-guest-popup-policy.ts new file mode 100644 index 00000000000..43133fd8e29 --- /dev/null +++ b/src/main/browser/browser-manager-guest-popup-policy.ts @@ -0,0 +1,210 @@ +import { shell } from 'electron' +import { randomUUID } from 'node:crypto' +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' +import { + normalizeBrowserNavigationUrl, + normalizeExternalBrowserUrl, + redactKagiSessionToken +} from '../../shared/browser-url' +import { + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + buildBrowserClickedLinkRoutingScript, + buildBrowserIframeClickedLinkRoutingScript +} from './browser-clicked-link-routing' +import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' +import { SAFE_POPUP_WINDOW_OPTIONS, safeOrigin } from './browser-manager-types' +import type { PopupChildWindowOptions } from './popup-origin-bar-window' +import { BrowserManagerNavigation } from './browser-manager-navigation' + +export abstract class BrowserManagerGuestPopupPolicy extends BrowserManagerNavigation { + protected installGuestPopupPolicy( + guest: Electron.WebContents, + clickedLinkFrameName: string | null + ): () => void { + let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) + const installClickedLinkRouting = (): void => { + if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { + return + } + // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. + void guest + .executeJavaScriptInIsolatedWorld( + BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, + [ + { + // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. + code: buildBrowserClickedLinkRoutingScript( + clickedLinkFrameName, + process.platform === 'darwin' + ) + } + ], + false + ) + .catch(() => {}) + } + if (clickedLinkFrameName) { + guest.on('dom-ready', installClickedLinkRouting) + } + const pendingIframeRoutingInstalls = new Map void>() + const iframeFrameNameByFrame = new Map() + const iframeFrameByFrameName = new Map() + const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { + const name = iframeFrameNameByFrame.get(frame) + if (!name) { + return + } + iframeFrameNameByFrame.delete(frame) + iframeFrameByFrameName.delete(name) + } + const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { + clearIframeFrameName(frame) + if (!clickedLinkRoutingActive || frame.isDestroyed()) { + return + } + const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` + iframeFrameNameByFrame.set(frame, name) + iframeFrameByFrameName.set(name, frame) + // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. + void frame + .executeJavaScript( + buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), + false + ) + .catch(() => { + if (iframeFrameNameByFrame.get(frame) === name) { + clearIframeFrameName(frame) + } + }) + } + const handleFrameCreated = ( + _event: Electron.Event, + { frame }: Electron.FrameCreatedDetails + ): void => { + if (!clickedLinkFrameName || !frame || frame.parent === null) { + return + } + for (const knownFrame of iframeFrameNameByFrame.keys()) { + if (knownFrame.isDestroyed()) { + clearIframeFrameName(knownFrame) + } + } + const installAfterDomReady = (): void => { + pendingIframeRoutingInstalls.delete(frame) + installIframeClickedLinkRouting(frame) + } + pendingIframeRoutingInstalls.set(frame, installAfterDomReady) + frame.once('dom-ready', installAfterDomReady) + } + if (clickedLinkFrameName) { + guest.on('frame-created', handleFrameCreated) + } + const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { + // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. + this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) + } + guest.on('did-create-window', handleDidCreateWindow) + guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { + const ownerContext = this.resolvePopupOwnerContext(guest.id) + const browserTabId = ownerContext?.browserTabId ?? null + const browserUrl = normalizeBrowserNavigationUrl(url) + const externalUrl = normalizeExternalBrowserUrl(url) + const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) + const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined + let isClickedLink = Boolean( + expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName + ) + if (!isClickedLink && iframeFrame) { + isClickedLink = true + clearIframeFrameName(iframeFrame) + queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) + } + + if (isClickedLink) { + if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(browserUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. + return { action: 'deny' } + } + + // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is + // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are + // excluded by isNewBrowserTabPopupIntent and still get a real child window below. + if ( + ownerContext && + externalUrl && + isNewBrowserTabPopupIntent({ frameName, disposition, features }) + ) { + // Why: one activation lets a page loop window.open, and each routed tab persists into + // workspace session state, so it survives the quit that used to clear popup windows. + if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'blocked' + }) + return { action: 'deny' } + } + if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-in-orca' + }) + } + // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. + return { action: 'deny' } + } + + // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. + const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) + if (browserTabId && canOpenAsChild) { + // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } + } else if (externalUrl) { + // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. + void shell.openExternal(redactKagiSessionToken(externalUrl)) + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(externalUrl), + action: 'opened-external' + }) + } else { + // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. + this.forwardOrQueuePopupEvent(guest.id, { + origin: safeOrigin(url), + action: 'blocked' + }) + } + return { action: 'deny' } + }) + + return () => { + clickedLinkRoutingActive = false + try { + guest.off('did-create-window', handleDidCreateWindow) + if (clickedLinkFrameName) { + guest.off('dom-ready', installClickedLinkRouting) + guest.off('frame-created', handleFrameCreated) + for (const [frame, install] of pendingIframeRoutingInstalls) { + if (!frame.isDestroyed()) { + frame.off('dom-ready', install) + } + } + pendingIframeRoutingInstalls.clear() + iframeFrameNameByFrame.clear() + iframeFrameByFrameName.clear() + } + } catch { + // guest may already be destroyed + } + } + } +} diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts new file mode 100644 index 00000000000..4e061d288aa --- /dev/null +++ b/src/main/browser/browser-manager-navigation.ts @@ -0,0 +1,263 @@ +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { cleanElectronUserAgent } from './browser-session-ua' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' +import { + safeOrigin, + type AuthUserAgentOverrideOperation, + type AuthUserAgentOverrideState +} from './browser-manager-types' +import { BrowserManagerVisibility } from './browser-manager-visibility' + +export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { + // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, + // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // must be matched here per navigation or the two layers disagree — itself a bot tell. + // Restores the session's base identity off the auth hosts. Native-UA profiles opt out + // of the whole clean-UA path, so they keep their untouched identity everywhere. + protected applyGoogleAuthUserAgent( + guest: Electron.WebContents, + url: string, + options: { duringRedirect?: boolean } = {} + ): void { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct + // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. + // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. + const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) + if (mode === 'native') { + return + } + const firefoxUa = googleAuthUserAgent() + const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) + const latestPendingOverride = overrideState?.pending.at(-1) + const confirmedOverride = overrideState?.confirmed + const currentOverride = + latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) + ? latestPendingOverride + : confirmedOverride + const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() + const nextUa = isGoogleAuthUrl(url) + ? firefoxUa + : // Only restore when the auth-host override is actually in place, so normal + // navigation never touches the session UA. + currentUa === firefoxUa + ? guest.session.getUserAgent() + : null + let authOverrideIssuedOverCdp = false + if (nextUa !== null && nextUa !== currentUa) { + // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight + // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain + // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without + // touching the navigation, and it outranks the WebContents UA from then on, so a guest that + // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride + // rewrites User-Agent per request for auth-host URLs on its own. + if (options.duringRedirect === true || overrideState !== undefined) { + if (this.canOverrideUserAgentOverCdp(guest)) { + authOverrideIssuedOverCdp = true + // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. + void this.applyAuthUserAgentOverrideOverCdp( + guest, + (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? + false, + url, + nextUa + ) + } + // Why: with no debugger there is no way to retarget the identity without cancelling the + // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. + } else { + guest.setUserAgent(nextUa) + } + } + // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of + // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. + if (browserPageId && !authOverrideIssuedOverCdp) { + this.reapplyViewportUserAgentOverride(guest, browserPageId, url) + } + } + + protected canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { + try { + return !guest.isDestroyed() && guest.debugger.isAttached() + } catch { + return false + } + } + + protected applyAuthUserAgentOverrideOverCdp( + guest: Electron.WebContents, + mobile: boolean, + url: string, + userAgent: string + ): Promise { + if (!this.canOverrideUserAgentOverCdp(guest)) { + return Promise.resolve(false) + } + const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { + confirmed: null, + nextSequence: 0, + pending: [] + } + const operation = { sequence: ++state.nextSequence, userAgent } + state.pending.push(operation) + this.authUserAgentOverrideStateByGuestId.set(guest.id, state) + return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( + () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), + () => { + this.settleAuthUserAgentOverride(guest.id, state, operation, false) + return false + } + ) + } + + protected settleAuthUserAgentOverride( + guestId: number, + state: AuthUserAgentOverrideState, + operation: AuthUserAgentOverrideOperation, + succeeded: boolean + ): boolean { + if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { + return false + } + if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { + state.confirmed = operation + } + const pendingIndex = state.pending.indexOf(operation) + if (pendingIndex !== -1) { + state.pending.splice(pendingIndex, 1) + } + if (state.confirmed === null && state.pending.length === 0) { + this.authUserAgentOverrideStateByGuestId.delete(guestId) + } + return true + } + + protected startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + protected updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return + } + pending.currentUrl = url + } + + protected failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true + } + + // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host + // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through + // here or two writers racing the same navigation will pick opposite identities. + protected resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() + } + + // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, + // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the + // request header says Firefox — the two-layer disagreement this scope exists to remove. + protected reapplyViewportUserAgentOverride( + guest: Electron.WebContents, + browserTabId: string, + url: string + ): void { + const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + if (mobile === undefined) { + return + } + // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the + // later-issued write wins. What matters is that both writers resolve the SAME host, which they + // now do via the navigation target rather than the stale committed URL. + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) + } + + protected async sendViewportUserAgentOverride( + guest: Electron.WebContents, + mobile: boolean, + url?: string, + baseUserAgent?: string + ): Promise { + if (guest.isDestroyed() || !guest.debugger.isAttached()) { + return + } + await guest.debugger.sendCommand( + 'Emulation.setUserAgentOverride', + buildViewportUserAgentOverride({ + url: url ?? this.resolveTabNavigationUrl(guest), + mobile, + // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: + // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to + // the CDP override, so reading it back here would republish that identity on ordinary hosts. + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) + }) + ) + } + + /** Route guests own their own popup handler, so their denials arrive here instead. */ + reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { + this.forwardOrQueuePopupEvent(input.openerWebContentsId, { + origin: safeOrigin(input.url), + action: 'blocked' + }) + } + + protected createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } +} diff --git a/src/main/browser/browser-manager-queries.ts b/src/main/browser/browser-manager-queries.ts new file mode 100644 index 00000000000..dd8a5dc87a7 --- /dev/null +++ b/src/main/browser/browser-manager-queries.ts @@ -0,0 +1,136 @@ +import { webContents } from 'electron' +import type { + BrowserCertificateFailure, + BrowserLoadError +} from '../../shared/browser-workspace-types' +import type { ManagedBrowserGuestContext } from './browser-certificate-trust-controller' +import { redactKagiSessionToken } from '../../shared/browser-url' +import { safeOrigin } from './browser-manager-types' +import { BrowserManagerRegistration } from './browser-manager-registration' + +export abstract class BrowserManagerQueries extends BrowserManagerRegistration { + getGuestWebContentsId(browserTabId: string): number | null { + return this.webContentsIdByTabId.get(browserTabId) ?? null + } + + getWebContentsIdByTabId(): Map { + return this.webContentsIdByTabId + } + + getTabIdForWebContentsId(webContentsId: number): string | null { + return this.tabIdByWebContentsId.get(webContentsId) ?? null + } + + getWorktreeIdForTab(browserTabId: string): string | undefined { + return this.worktreeIdByTabId.get(browserTabId) + } + + getRendererContextForGuest( + guestWebContentsId: number + ): { browserPageId: string; renderer: Electron.WebContents } | null { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return null + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + return renderer ? { browserPageId, renderer } : null + } + + getSessionProfileIdForTab(browserTabId: string): string | null { + return this.sessionProfileIdByPageId.get(browserTabId) ?? null + } + + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. + protected buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { + return { + code, + description, + validatedUrl: redactKagiSessionToken(rawUrl) + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + protected notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + + notifyPermissionDenied(args: { + guestWebContentsId: number + permission: string + rawUrl: string + }): void { + this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { + permission: args.permission, + origin: safeOrigin(args.rawUrl) + }) + } +} diff --git a/src/main/browser/browser-manager-registration.ts b/src/main/browser/browser-manager-registration.ts new file mode 100644 index 00000000000..42d04e8e023 --- /dev/null +++ b/src/main/browser/browser-manager-registration.ts @@ -0,0 +1,222 @@ +import { webContents } from 'electron' +import { browserDownloadDestinationReservations } from './browser-download-destination' +import { isWorkspaceDocPageId } from './doc-preview-guest-policy' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' +import type { BrowserGuestRegistration } from './browser-manager-types' +import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy' + +export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy { + registerGuest({ + browserPageId, + browserTabId: legacyBrowserTabId, + workspaceId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId, + rendererWebContentsId + }: BrowserGuestRegistration): boolean { + const browserTabId = browserPageId ?? legacyBrowserTabId + // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one + // id resolves in both and the tool door silently prefers the document guest over the page. + if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { + return false + } + // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. + this.cancelGrabOp(browserTabId, 'evicted') + + const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + + // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. + if (guest.getType() !== 'webview') { + return false + } + if (!this.policyAttachedGuestIds.has(webContentsId)) { + // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. + return false + } + + const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + } + this.webContentsIdByTabId.set(browserTabId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserTabId) + if (workspaceId) { + this.workspaceIdByPageId.set(browserTabId, workspaceId) + } + this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserTabId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserTabId) + } + this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) + if (worktreeId) { + this.worktreeIdByTabId.set(browserTabId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) + + this.setupContextMenu(browserTabId, guest) + this.setupGrabShortcut(browserTabId, guest) + this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) + this.flushPendingLoadFailure(browserTabId, webContentsId) + this.flushPendingPermissionEvents(browserTabId, webContentsId) + this.flushPendingPopupEvents(browserTabId, webContentsId) + this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true + } + + unregisterGuest(browserTabId: string): void { + // Why the check on the exit door too: a document page withdraws by revoking its grant, never + // through here, so its id arriving is misaddressed — and the cancel below would evict that + // preview's live grab on the strength of it. + if (isWorkspaceDocPageId(browserTabId)) { + return + } + // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. + this.cancelGrabOp(browserTabId, 'evicted') + + // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. + const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) + if (guestWebContentsId !== undefined) { + this.cleanupGuestPolicyAttachment(guestWebContentsId) + } + + const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) + if (cleanup) { + cleanup() + this.contextMenuCleanupByTabId.delete(browserTabId) + } + const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) + if (shortcutCleanup) { + shortcutCleanup() + this.grabShortcutCleanupByTabId.delete(browserTabId) + } + const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) + if (fwdCleanup) { + fwdCleanup() + this.shortcutForwardingCleanupByTabId.delete(browserTabId) + } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. + for (const [downloadId, download] of this.downloadsById.entries()) { + if (download.browserTabId === browserTabId && !download.terminalEvent) { + this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') + } + } + const wcId = this.webContentsIdByTabId.get(browserTabId) + if (wcId !== undefined) { + this.tabIdByWebContentsId.delete(wcId) + } + this.webContentsIdByTabId.delete(browserTabId) + this.rendererWebContentsIdByTabId.delete(browserTabId) + this.workspaceIdByPageId.delete(browserTabId) + this.sessionProfileIdByPageId.delete(browserTabId) + this.userAgentModeByPageId.delete(browserTabId) + this.worktreeIdByTabId.delete(browserTabId) + // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. + this.viewportOpsByTabId.delete(browserTabId) + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + + // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. + registerOffscreenGuest({ + browserPageId, + worktreeId, + sessionProfileId, + userAgentMode, + webContentsId + }: { + browserPageId: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + }): boolean { + // Why the same check on both registration doors: one id resolving in both halves is the exact + // confusion the split registries exist to prevent. + if (isWorkspaceDocPageId(browserPageId)) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + return false + } + // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) + const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) + if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { + this.retireStaleGuestWebContents(previousWebContentsId) + } + this.webContentsIdByTabId.set(browserPageId, webContentsId) + this.tabIdByWebContentsId.set(webContentsId, browserPageId) + this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) + if (userAgentMode) { + this.userAgentModeByPageId.set(browserPageId, userAgentMode) + } else { + this.userAgentModeByPageId.delete(browserPageId) + } + if (worktreeId) { + this.worktreeIdByTabId.set(browserPageId, worktreeId) + } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) + return true + } + + unregisterAll(): void { + // Cancel all active grab ops before tearing down registrations + this.grabSessionController.cancelAll('evicted') + for (const downloadId of this.downloadsById.keys()) { + this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') + } + browserDownloadDestinationReservations.clear() + for (const browserTabId of this.webContentsIdByTabId.keys()) { + this.unregisterGuest(browserTabId) + } + this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() + // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. + for (const cleanup of this.policyCleanupByGuestId.values()) { + cleanup() + } + this.policyCleanupByGuestId.clear() + this.clickedLinkFrameNameByGuestId.clear() + this.tabIdByWebContentsId.clear() + this.popupOwnerContextByGuestId.clear() + this.pageInitiatedTabBudgetByRootGuestId.clear() + this.worktreeIdByTabId.clear() + this.sessionProfileIdByPageId.clear() + this.userAgentModeByPageId.clear() + this.viewportUaOverrideMobileByTabId.clear() + this.authUserAgentOverrideStateByGuestId.clear() + this.pendingNavigationByGuestId.clear() + this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() + this.pendingPermissionEventsByGuestId.clear() + this.pendingPopupEventsByGuestId.clear() + this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() + this.annotationViewportBridgeOpsByTabId.clear() + } +} diff --git a/src/main/browser/browser-manager-state.ts b/src/main/browser/browser-manager-state.ts new file mode 100644 index 00000000000..ee493b760cf --- /dev/null +++ b/src/main/browser/browser-manager-state.ts @@ -0,0 +1,302 @@ +import { webContents } from 'electron' +import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { BrowserGrabSessionController } from './browser-grab-session-controller' +import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { + createPageInitiatedTabBudget, + type PageInitiatedTabBudget +} from './browser-page-initiated-tab-budget' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { + BrowserLoadError, + BrowserSessionUserAgentMode +} from '../../shared/browser-workspace-types' +import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' +import type { + ActiveDownload, + AuthUserAgentOverrideState, + PendingMainFrameNavigation, + PendingPermissionEvent, + PendingPopupEvent, + BrowserGuestPolicy, + BrowserManagerLoadError, + PopupOwnerContext +} from './browser-manager-types' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent +} from '../../shared/browser-guest-events' +import type { BrowserGrabCancelReason } from '../../shared/browser-grab-types' + +export abstract class BrowserManagerState { + protected abstract attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext?: PopupOwnerContext | null, + policy?: BrowserGuestPolicy + ): void + + protected abstract forwardOrQueuePopupEvent( + guestWebContentsId: number, + event: PendingPopupEvent + ): void + + protected abstract cancelPendingDownloadsForGuest(guestWebContentsId: number): void + + protected abstract cleanupGuestPolicyAttachment(guestWebContentsId: number): void + protected abstract notifyBrowserGuestStateChanged(webContentsId: number): void + protected abstract buildLoadError( + code: number, + description: string, + rawUrl: string + ): BrowserLoadError + protected abstract forwardOrQueueGuestLoadFailure( + guestWebContentsId: number, + loadError: BrowserManagerLoadError + ): void + protected abstract forwardOrQueuePermissionDenied( + guestWebContentsId: number, + event: PendingPermissionEvent + ): void + protected abstract flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingPermissionEvents( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void + protected abstract flushPendingDownloadRequests( + browserTabId: string, + guestWebContentsId: number + ): void + protected abstract setupContextMenu(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void + protected abstract setupShortcutForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract setupMouseWheelZoomForwarding( + browserTabId: string, + guest: Electron.WebContents + ): void + protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void + protected abstract hasActiveGrabOp(browserTabId: string): boolean + protected abstract unregisterGuest(browserTabId: string): void + protected abstract cancelDownloadInternal(downloadId: string, reason: string): void + protected abstract bindDownloadToTab(downloadId: string, browserTabId: string): void + protected abstract flushDownloadSnapshot(downloadId: string): void + protected abstract sendDownloadStarted(downloadId: string): void + protected abstract sendDownloadProgress( + browserTabId: string | null, + payload: BrowserDownloadProgressEvent + ): void + protected abstract sendDownloadFinished( + browserTabId: string | null, + payload: BrowserDownloadFinishedEvent + ): void + protected abstract settleClientHostedDownload( + download: ActiveDownload, + status: BrowserDownloadFinishedEvent['status'], + failure: string | null + ): Promise + protected abstract finishDownloadInternal( + downloadId: string, + status: BrowserDownloadFinishedEvent['status'], + error: string | null + ): void + protected abstract getDownloadReceivedBytes(item: Electron.DownloadItem): number + protected abstract openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean + + protected settingsResolver: + | (() => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + }) + | null = null + protected readonly webContentsIdByTabId = new Map() + // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. + protected readonly tabIdByWebContentsId = new Map() + protected readonly popupOwnerContextByGuestId = new Map() + // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. + protected readonly pageInitiatedTabBudgetByRootGuestId = new Map() + // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. + protected readonly workspaceIdByPageId = new Map() + protected readonly sessionProfileIdByPageId = new Map() + protected readonly userAgentModeByPageId = new Map() + protected readonly rendererWebContentsIdByTabId = new Map() + // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. + protected readonly viewportOpsByTabId = new Map>() + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. + protected readonly viewportUaOverrideMobileByTabId = new Map() + // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations + // ordered without claiming a failed write was installed. + protected readonly authUserAgentOverrideStateByGuestId = new Map< + number, + AuthUserAgentOverrideState + >() + // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() + // still reports the outgoing page until then. See resolveTabNavigationUrl. + protected readonly pendingNavigationByGuestId = new Map() + protected readonly contextMenuCleanupByTabId = new Map void>() + protected readonly grabShortcutCleanupByTabId = new Map void>() + protected readonly shortcutForwardingCleanupByTabId = new Map void>() + protected readonly mouseWheelZoomCleanupByTabId = new Map void>() + protected readonly annotationViewportBridgeOpsByTabId = new Map>() + protected readonly worktreeIdByTabId = new Map() + protected readonly policyAttachedGuestIds = new Set() + protected readonly offscreenGuestIds = new Set() + protected readonly policyCleanupByGuestId = new Map void>() + protected readonly clickedLinkFrameNameByGuestId = new Map() + protected readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. + protected readonly clearedLoadErrorsByGuestId = new Map() + protected browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + protected certificateTrustController: BrowserCertificateTrustController | null = null + protected shouldForwardDictationShortcut: (() => boolean) | null = null + protected readonly pendingLoadFailuresByGuestId = new Map< + number, + { code: number; description: string; validatedUrl: string } + >() + protected readonly pendingPermissionEventsByGuestId = new Map() + protected readonly pendingPopupEventsByGuestId = new Map() + protected readonly pendingDownloadIdsByGuestId = new Map() + protected readonly downloadsById = new Map() + protected readonly grabSessionController = new BrowserGrabSessionController() + + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + + setSettingsResolver( + resolver: () => { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean + } + ): void { + this.settingsResolver = resolver + } + + // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. + protected injectAntiDetection(guest: Electron.WebContents): () => void { + let disposed = false + let reattachTimer: ReturnType | null = null + + const attach = (): void => { + if (disposed || guest.isDestroyed()) { + return + } + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + void guest.debugger + .sendCommand('Page.enable', {}) + .then(() => + guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { + source: ANTI_DETECTION_SCRIPT + }) + ) + .catch(() => {}) + } catch { + /* best-effort — debugger may be unavailable */ + } + } + + // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. + const onDetach = (): void => { + this.authUserAgentOverrideStateByGuestId.delete(guest.id) + if (!disposed && !guest.isDestroyed() && reattachTimer === null) { + reattachTimer = setTimeout(() => { + reattachTimer = null + attach() + }, 500) + } + } + + try { + attach() + guest.debugger.on('detach', onDetach) + } catch { + /* best-effort */ + } + + return () => { + disposed = true + if (reattachTimer !== null) { + clearTimeout(reattachTimer) + reattachTimer = null + } + try { + guest.debugger.off('detach', onDetach) + } catch { + /* guest may already be destroyed */ + } + } + } + + protected resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { + return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null + } + + protected resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (browserTabId) { + return { browserTabId, rootGuestWebContentsId: guestWebContentsId } + } + // Route popups live in an Orca-built window, so they never pass through did-create-window and + // have no inherited context; their owning page comes from the route popup registry instead. + const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) + if (routeOpenerWebContentsId !== null) { + const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) + return openerTabId + ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } + : null + } + const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) + if ( + inherited && + this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId + ) { + return inherited + } + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + return null + } + + /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ + protected tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { + let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) + if (!budget) { + budget = createPageInitiatedTabBudget() + this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) + } + return budget.tryConsume(Date.now()) + } + + protected resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { + const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) + if (!rendererWebContentsId) { + return null + } + const renderer = webContents.fromId(rendererWebContentsId) + if (!renderer || renderer.isDestroyed()) { + return null + } + return renderer + } +} diff --git a/src/main/browser/browser-manager-types.ts b/src/main/browser/browser-manager-types.ts new file mode 100644 index 00000000000..a1b832a65bc --- /dev/null +++ b/src/main/browser/browser-manager-types.ts @@ -0,0 +1,238 @@ +import { normalizeExternalBrowserUrl } from '../../shared/browser-url' +import type { + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent +} from '../../shared/browser-guest-events' +import type { + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot +} from '../../shared/browser-grab-types' +import type { BrowserClientDownloadRoute } from './browser-client-download-relay' +import type { PageInitiatedTabBudget } from './browser-page-initiated-tab-budget' +import type { + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride +} from '../../shared/browser-workspace-types' +import type { BrowserAnnotationViewportBridgeOptions } from '../../shared/browser-annotation-viewport-bridge' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 + +export function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + +export function resolveWithTimeout( + promise: Promise, + timeoutMs: number, + fallbackValue: T +): Promise<{ value: T; timedOut: boolean }> { + let timeoutId: ReturnType | null = null + const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { + timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) + }) + return Promise.race([ + promise.then((value) => ({ value, timedOut: false })), + timeoutPromise + ]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) +} + +export function releaseAutomationVisibilityToken( + renderer: Electron.WebContents, + token: string +): void { + if (renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.release !== 'function') return false; + return bridge.release(${JSON.stringify(token)}); + })()` + ) + .catch(() => {}) +} + +export function cleanupLateAutomationVisibilityToken( + renderer: Electron.WebContents, + acquirePromise: Promise +): void { + acquirePromise + .then((lateToken) => { + if (typeof lateToken !== 'string' || lateToken.length === 0) { + return + } + // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. + releaseAutomationVisibilityToken(renderer, lateToken) + }) + .catch(() => {}) +} + +export function createNoopRestoreForTimedOutAutomationAcquire( + renderer: Electron.WebContents, + acquirePromise: Promise, + timedOut: boolean +): () => void { + if (timedOut) { + cleanupLateAutomationVisibilityToken(renderer, acquirePromise) + } + return () => {} +} + +export function isAutomationVisibilityToken(token: unknown): token is string { + return typeof token === 'string' && token.length > 0 +} + +export type BrowserGuestRegistration = { + browserPageId?: string + browserTabId?: string + workspaceId?: string + worktreeId?: string + sessionProfileId?: string | null + userAgentMode?: BrowserSessionUserAgentMode + webContentsId: number + rendererWebContentsId: number +} + +export type PendingPermissionEvent = Omit +export type PendingPopupEvent = Omit +export type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' +export type PopupOwnerContext = { + browserTabId: string + rootGuestWebContentsId: number +} + +/** + * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and + * anti-detection all apply. A workspace-document guest renders one granted document and gets none + * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + */ +export type BrowserGuestPolicy = + | { profile: 'browsing' } + | { profile: 'workspace-doc'; host: Electron.WebContents } + +export const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } + +export type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} + +export type AuthUserAgentOverrideOperation = { + sequence: number + userAgent: string +} + +export type AuthUserAgentOverrideState = { + confirmed: AuthUserAgentOverrideOperation | null + nextSequence: number + pending: AuthUserAgentOverrideOperation[] +} + +export const SAFE_POPUP_WINDOW_OPTIONS = { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } +} satisfies Electron.BrowserWindowConstructorOptions + +export type ActiveDownload = { + downloadId: string + guestWebContentsId: number + browserTabId: string | null + rendererWebContentsId: number | null + origin: string + filename: string + totalBytes: number | null + mimeType: string | null + item: Electron.DownloadItem + savePath: string + reservationKey: string | null + clientRoute: BrowserClientDownloadRoute | null + remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] + receivedBytes: number + transientState: BrowserDownloadProgressEvent['state'] + terminalEvent: BrowserDownloadFinishedEvent | null + startedSent: boolean + cleanup: (() => void) | null +} + +export function safeOrigin(rawUrl: string): string { + const external = normalizeExternalBrowserUrl(rawUrl) + const urlToParse = external ?? rawUrl + try { + return new URL(urlToParse).origin + } catch { + return external ?? 'unknown' + } +} + +export type BrowserManagerSettings = { + keybindings?: KeybindingOverrides + mobileEmulatorEnabled?: boolean +} + +export type BrowserManagerLoadError = Pick< + BrowserLoadError, + 'code' | 'description' | 'validatedUrl' +> + +export type BrowserManagerGrabTypes = { + cancelReason: BrowserGrabCancelReason + payload: BrowserGrabPayload + rect: BrowserGrabRect + result: BrowserGrabResult + screenshot: BrowserGrabScreenshot +} + +export type { + BrowserAnnotationViewportBridgeOptions, + BrowserCertificateFailure, + BrowserLoadError, + BrowserSessionUserAgentMode, + BrowserViewportOverride, + BrowserDownloadFinishedEvent, + BrowserDownloadProgressEvent, + BrowserPermissionDeniedEvent, + BrowserPopupEvent, + BrowserClientDownloadRoute, + BrowserGrabCancelReason, + BrowserGrabPayload, + BrowserGrabRect, + BrowserGrabResult, + BrowserGrabScreenshot, + KeybindingOverrides, + PageInitiatedTabBudget +} diff --git a/src/main/browser/browser-manager-viewport.ts b/src/main/browser/browser-manager-viewport.ts new file mode 100644 index 00000000000..a234fa3f275 --- /dev/null +++ b/src/main/browser/browser-manager-viewport.ts @@ -0,0 +1,186 @@ +import { webContents } from 'electron' +import { + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + buildBrowserAnnotationViewportBridgeScript, + type BrowserAnnotationViewportBridgeOptions +} from '../../shared/browser-annotation-viewport-bridge' +import type { BrowserViewportOverride } from '../../shared/browser-workspace-types' +import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' +import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle' + +export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle { + // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. + async openDevTools(browserTabId: string): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + guest.openDevTools({ mode: 'detach' }) + return true + } + + // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. + async setViewportOverride( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. + const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetViewportOverrideImpl(browserTabId, override)) + this.viewportOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. + if (this.viewportOpsByTabId.get(browserTabId) === next) { + this.viewportOpsByTabId.delete(browserTabId) + } + } + } + + async setAnnotationViewportBridge( + browserTabId: string, + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() + const next = prev + .catch(() => {}) + .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) + this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) + try { + return await next + } finally { + if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { + this.annotationViewportBridgeOpsByTabId.delete(browserTabId) + } + } + } + + // Why the caller resolves the guest: the same bridge serves browsing pages and workspace + // documents, which live in different halves of the page registry. + // Why a resolver and not the guest itself: this op may have waited behind another one, and a + // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — + // injecting into the guest the request named would bridge a page nobody is looking at. + // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and + // taking an id it cannot act on would invite the next reader to act on it. + protected async doSetAnnotationViewportBridgeImpl( + options: BrowserAnnotationViewportBridgeOptions, + resolveGuest: () => Electron.WebContents | null + ): Promise { + // Why no teardown here: the resolver already unregisters a page whose guest died, and the only + // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would + // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. + const guest = resolveGuest() + if (!guest || guest.isDestroyed()) { + return false + } + + try { + // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. + await guest.executeJavaScriptInIsolatedWorld( + BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, + [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], + false + ) + return true + } catch { + return false + } + } + + protected async doSetViewportOverrideImpl( + browserTabId: string, + override: BrowserViewportOverride | null + ): Promise { + const webContentsId = this.webContentsIdByTabId.get(browserTabId) + if (!webContentsId) { + return false + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. + this.unregisterGuest(browserTabId) + return false + } + + try { + if (!guest.debugger.isAttached()) { + guest.debugger.attach('1.3') + } + } catch (err) { + // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. + console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { + browserTabId, + webContentsId, + error: err instanceof Error ? err.message : String(err) + }) + return false + } + + const dbg = guest.debugger + try { + if (override) { + await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { + width: override.width, + height: override.height, + deviceScaleFactor: override.deviceScaleFactor, + mobile: override.mobile + }) + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: override.mobile, + maxTouchPoints: override.mobile ? 5 : 0 + }) + // Why: viewport sizing must not override a profile's explicit native-UA identity. + if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { + // Navigation must see the preset intent while the final CDP command is in flight. + this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) + } + } else { + await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) + await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { + enabled: false, + maxTouchPoints: 0 + }) + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. + this.viewportUaOverrideMobileByTabId.delete(browserTabId) + try { + if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { + const url = this.resolveTabNavigationUrl(guest) + const restored = await this.applyAuthUserAgentOverrideOverCdp( + guest, + false, + url, + isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() + ) + if (!restored) { + throw new Error('Failed to preserve auth user agent') + } + } else { + // Why: passing an empty string restores the session default UA. + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } + } + return true + } catch { + return false + } + } +} diff --git a/src/main/browser/browser-manager-visibility.ts b/src/main/browser/browser-manager-visibility.ts new file mode 100644 index 00000000000..1da2a75638d --- /dev/null +++ b/src/main/browser/browser-manager-visibility.ts @@ -0,0 +1,256 @@ +import { + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + createNoopRestoreForTimedOutAutomationAcquire, + isAutomationVisibilityToken, + releaseAutomationVisibilityToken, + resolveWithTimeout +} from './browser-manager-types' +import { BrowserManagerState } from './browser-manager-state' + +export abstract class BrowserManagerVisibility extends BrowserManagerState { + // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. + async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId + const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + const prev = await renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return null; + var state = store.getState(); + var prevTabType = state.activeTabType; + var prevActiveWorktreeId = state.activeWorktreeId || null; + var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; + var prevActiveBrowserPageId = null; + var prevFocusedGroupTabId = null; + var targetWorktreeId = ${JSON.stringify(worktreeId)}; + var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; + var browserPageId = ${JSON.stringify(browserPageId)}; + var browserTabsByWorktree = state.browserTabsByWorktree || {}; + + if (prevActiveWorktreeId) { + var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; + var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; + for (var pg = 0; pg < prevGroups.length; pg++) { + if (prevGroups[pg].id === prevFocusedGroupId) { + prevFocusedGroupTabId = prevGroups[pg].activeTabId; + break; + } + } + } + + if (prevActiveBrowserWorkspaceId) { + for (var prevWtId in browserTabsByWorktree) { + var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; + for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { + if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { + prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; + break; + } + } + if (prevActiveBrowserPageId) break; + } + } + + if ( + targetWorktreeId && + prevActiveWorktreeId !== targetWorktreeId && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(targetWorktreeId); + state = store.getState(); + } + + var foundWorkspace = null; + for (var wtId in browserTabsByWorktree) { + var tabs = browserTabsByWorktree[wtId] || []; + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].id === browserWorkspaceId) { + foundWorkspace = tabs[i]; + if (!targetWorktreeId) { + targetWorktreeId = wtId; + } + break; + } + } + if (foundWorkspace) break; + } + + var hasTargetPage = false; + var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; + for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { + if (targetPages[pageIndex].id === browserPageId) { + hasTargetPage = true; + break; + } + } + + if (foundWorkspace) { + if (typeof state.setActiveBrowserTab === 'function') { + state.setActiveBrowserTab(browserWorkspaceId); + state = store.getState(); + } else { + var allTabs = state.unifiedTabsByWorktree || {}; + var found = null; + for (var unifiedWtId in allTabs) { + var unifiedTabs = allTabs[unifiedWtId] || []; + for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { + if ( + unifiedTabs[unifiedIndex].contentType === 'browser' && + unifiedTabs[unifiedIndex].entityId === browserWorkspaceId + ) { + found = unifiedTabs[unifiedIndex]; + break; + } + } + if (found) break; + } + if (found) { + state.activateTab(found.id); + } + state.setActiveTabType('browser'); + state = store.getState(); + } + // Why: activating the workspace alone is not enough for screenshot + // capture when a browser workspace contains multiple pages. The + // compositor only paints the currently mounted page guest. + if ( + hasTargetPage && + foundWorkspace.activePageId !== browserPageId && + typeof state.setActiveBrowserPage === 'function' + ) { + state.setActiveBrowserPage(browserWorkspaceId, browserPageId); + state = store.getState(); + } + } + + return { + prevTabType: prevTabType, + prevActiveWorktreeId: prevActiveWorktreeId, + prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, + prevActiveBrowserPageId: prevActiveBrowserPageId, + prevFocusedGroupTabId: prevFocusedGroupTabId, + targetWorktreeId: targetWorktreeId, + targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, + targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null + }; + })()` + ) + .catch(() => null) + + const needsRestore = + prev && + (prev.prevTabType !== 'browser' || + prev.prevActiveWorktreeId !== prev.targetWorktreeId || + prev.prevFocusedGroupTabId !== null || + prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || + prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) + + if (!needsRestore) { + return () => {} + } + + return () => { + if (!prev || !renderer || renderer.isDestroyed()) { + return + } + renderer + .executeJavaScript( + `(function() { + var store = window.__store; + if (!store) return; + var state = store.getState(); + if ( + ${JSON.stringify(prev?.prevActiveWorktreeId)} && + ${JSON.stringify(prev?.prevActiveWorktreeId)} !== + ${JSON.stringify(prev?.targetWorktreeId)} && + typeof state.setActiveWorktree === 'function' + ) { + state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== + ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && + typeof state.setActiveBrowserTab === 'function' + ) { + state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} && + ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== + ${JSON.stringify(prev?.targetBrowserPageId)} && + typeof state.setActiveBrowserPage === 'function' + ) { + // Why: Orca remembers the last browser workspace/page even when + // the user is currently in terminal/editor view. Screenshot prep + // temporarily switches that hidden browser selection state, so + // restore it independently of the visible tab type. + state.setActiveBrowserPage( + ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, + ${JSON.stringify(prev?.prevActiveBrowserPageId)} + ); + state = store.getState(); + } + if ( + ${JSON.stringify(prev?.prevTabType)} !== 'browser' && + ${JSON.stringify(prev?.prevFocusedGroupTabId)} + ) { + state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); + } + if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { + state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); + } + })()` + ) + .catch(() => {}) + } + } + + async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { + const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) + if (!browserPageId) { + return () => {} + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + if (!renderer || renderer.isDestroyed()) { + return () => {} + } + + // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. + const acquirePromise = renderer + .executeJavaScript( + `(async function() { + var bridge = window.__orcaBrowserAutomationVisibility; + if (!bridge || typeof bridge.acquire !== 'function') return null; + return await bridge.acquire(${JSON.stringify(browserPageId)}); + })()` + ) + .catch(() => null) + const { value: token, timedOut } = await resolveWithTimeout( + acquirePromise, + AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, + null + ) + + if (!isAutomationVisibilityToken(token)) { + return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) + } + + return () => { + releaseAutomationVisibilityToken(renderer, token) + } + } +} diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index fbd667d4119..bdac69dd906 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -1,2597 +1,14 @@ -/* eslint-disable max-lines -- Why: single privileged facade for guest registration, authorization, and lifecycle cleanup; keeps the browser security boundary in one file. */ -import { randomUUID } from 'node:crypto' +import { webContents } from 'electron' +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' +import { BrowserManagerFinal } from './browser-manager-final' -import { shell, webContents } from 'electron' -import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' -import { - normalizeBrowserNavigationUrl, - normalizeExternalBrowserUrl, - redactKagiSessionToken, - toSecureCertificateEndpoint -} from '../../shared/browser-url' -import type { - BrowserDownloadFinishedEvent, - BrowserDownloadProgressEvent, - BrowserDownloadRequestedEvent, - BrowserPermissionDeniedEvent, - BrowserPopupEvent -} from '../../shared/browser-guest-events' -import type { - BrowserGrabCancelReason, - BrowserGrabPayload, - BrowserGrabRect, - BrowserGrabResult, - BrowserGrabScreenshot -} from '../../shared/browser-grab-types' -import { buildGuestOverlayScript } from './grab-guest-script' -import { clampGrabPayload } from './browser-grab-payload' -import { captureSelectionScreenshot as captureGrabSelectionScreenshot } from './browser-grab-screenshot' -import { BrowserGrabSessionController } from './browser-grab-session-controller' -import { browserDownloadDestinationReservations } from './browser-download-destination' -import type { BrowserClientDownloadRoute } from './browser-client-download-relay' -import { routeBrowserClientDownload } from './browser-client-download-routing' -import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership' -import { resolveRendererWebContents } from './browser-guest-renderer-target' -import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts' -import { setupGuestContextMenu } from './browser-guest-context-menu' -import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom' -import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding' -import { ANTI_DETECTION_SCRIPT } from './anti-detection' -import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' -import { - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - buildBrowserClickedLinkRoutingScript, - buildBrowserIframeClickedLinkRoutingScript -} from './browser-clicked-link-routing' -import { - createPageInitiatedTabBudget, - type PageInitiatedTabBudget -} from './browser-page-initiated-tab-budget' -import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent' -import { cleanElectronUserAgent } from './browser-session-ua' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' -import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' -import type { - BrowserCertificateFailure, - BrowserLoadError, - BrowserSessionUserAgentMode, - BrowserViewportOverride -} from '../../shared/browser-workspace-types' -import { - type BrowserAnnotationViewportBridgeOptions, - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - buildBrowserAnnotationViewportBridgeScript -} from '../../shared/browser-annotation-viewport-bridge' -import { - getWorkspaceDocPageGuest, - installDocPreviewGuestPolicy, - isWorkspaceDocPageId -} from './doc-preview-guest-policy' -import type { KeybindingOverrides } from '../../shared/keybindings' -import { - BrowserCertificateTrustController, - type ManagedBrowserGuestContext -} from './browser-certificate-trust-controller' +export type { BrowserGuestPolicy, BrowserGuestRegistration } from './browser-manager-types' -const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 - -function isChromiumInternalErrorUrl(url: string): boolean { - return url.startsWith('chrome-error://') -} - -function resolveWithTimeout( - promise: Promise, - timeoutMs: number, - fallbackValue: T -): Promise<{ value: T; timedOut: boolean }> { - let timeoutId: ReturnType | null = null - const timeoutPromise = new Promise<{ value: T; timedOut: boolean }>((resolve) => { - timeoutId = setTimeout(() => resolve({ value: fallbackValue, timedOut: true }), timeoutMs) - }) - return Promise.race([ - promise.then((value) => ({ value, timedOut: false })), - timeoutPromise - ]).finally(() => { - if (timeoutId) { - clearTimeout(timeoutId) - } - }) -} - -function releaseAutomationVisibilityToken(renderer: Electron.WebContents, token: string): void { - if (renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.release !== 'function') return false; - return bridge.release(${JSON.stringify(token)}); - })()` - ) - .catch(() => {}) -} - -function cleanupLateAutomationVisibilityToken( - renderer: Electron.WebContents, - acquirePromise: Promise -): void { - acquirePromise - .then((lateToken) => { - if (typeof lateToken !== 'string' || lateToken.length === 0) { - return - } - // Why: the lease is created before paint; if main's acquire timed out, release the late token so hidden webviews don't stay paintable. - releaseAutomationVisibilityToken(renderer, lateToken) - }) - .catch(() => {}) -} - -function createNoopRestoreForTimedOutAutomationAcquire( - renderer: Electron.WebContents, - acquirePromise: Promise, - timedOut: boolean -): () => void { - if (timedOut) { - cleanupLateAutomationVisibilityToken(renderer, acquirePromise) - } - return () => {} -} - -function isAutomationVisibilityToken(token: unknown): token is string { - return typeof token === 'string' && token.length > 0 -} - -export type BrowserGuestRegistration = { - browserPageId?: string - browserTabId?: string - workspaceId?: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - rendererWebContentsId: number -} - -type PendingPermissionEvent = Omit -type PendingPopupEvent = Omit -type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' -type PopupOwnerContext = { - browserTabId: string - rootGuestWebContentsId: number -} /** - * What a guest is allowed to be. A browsing guest is the web — popups, clicked-link routing and - * anti-detection all apply. A workspace-document guest renders one granted document and gets none - * of that; `host` is the renderer that minted its grant, and the only sink for what it reports. + * Privileged browser guest facade. Behavior is organized by lifecycle concern in the focused + * manager modules while this module keeps the stable import surface used by main and renderer code. */ -export type BrowserGuestPolicy = - | { profile: 'browsing' } - | { profile: 'workspace-doc'; host: Electron.WebContents } -const BROWSING_GUEST_POLICY: BrowserGuestPolicy = { profile: 'browsing' } -type PendingMainFrameNavigation = { - currentUrl: string - supersededUrls: string[] -} -type AuthUserAgentOverrideOperation = { - sequence: number - userAgent: string -} -type AuthUserAgentOverrideState = { - confirmed: AuthUserAgentOverrideOperation | null - nextSequence: number - pending: AuthUserAgentOverrideOperation[] -} -const SAFE_POPUP_WINDOW_OPTIONS = { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false, - // Why: Electron applies these before createWindow; feature strings/opener inheritance must not relax the child's isolation. - webPreferences: { - allowRunningInsecureContent: false, - contextIsolation: true, - nodeIntegration: false, - nodeIntegrationInSubFrames: false, - sandbox: true, - webviewTag: false - } -} satisfies Electron.BrowserWindowConstructorOptions - -type ActiveDownload = { - downloadId: string - guestWebContentsId: number - browserTabId: string | null - rendererWebContentsId: number | null - origin: string - filename: string - totalBytes: number | null - mimeType: string | null - item: Electron.DownloadItem - savePath: string - reservationKey: string | null - clientRoute: BrowserClientDownloadRoute | null - remoteDestination: BrowserDownloadFinishedEvent['remoteDestination'] - receivedBytes: number - transientState: BrowserDownloadProgressEvent['state'] - terminalEvent: BrowserDownloadFinishedEvent | null - startedSent: boolean - cleanup: (() => void) | null -} - -function safeOrigin(rawUrl: string): string { - const external = normalizeExternalBrowserUrl(rawUrl) - const urlToParse = external ?? rawUrl - try { - return new URL(urlToParse).origin - } catch { - return external ?? 'unknown' - } -} - -export class BrowserManager { - private settingsResolver: - | (() => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - }) - | null = null - private readonly webContentsIdByTabId = new Map() - // Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event. - private readonly tabIdByWebContentsId = new Map() - private readonly popupOwnerContextByGuestId = new Map() - // Why: keyed by the opener tree's root so named child popups can't each mint a fresh tab quota. - private readonly pageInitiatedTabBudgetByRootGuestId = new Map() - // Why: guests are keyed by page id but renderer visibility by workspace id; bridge the mismatch to activate the right tab before capture. - private readonly workspaceIdByPageId = new Map() - private readonly sessionProfileIdByPageId = new Map() - private readonly userAgentModeByPageId = new Map() - private readonly rendererWebContentsIdByTabId = new Map() - // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. - private readonly viewportOpsByTabId = new Map>() - // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation - // can re-issue it against the target URL's identity. - private readonly viewportUaOverrideMobileByTabId = new Map() - // Why: the confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations - // ordered without claiming a failed write was installed. - private readonly authUserAgentOverrideStateByGuestId = new Map< - number, - AuthUserAgentOverrideState - >() - // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() - // still reports the outgoing page until then. See resolveTabNavigationUrl. - private readonly pendingNavigationByGuestId = new Map() - private readonly contextMenuCleanupByTabId = new Map void>() - private readonly grabShortcutCleanupByTabId = new Map void>() - private readonly shortcutForwardingCleanupByTabId = new Map void>() - private readonly mouseWheelZoomCleanupByTabId = new Map void>() - private readonly annotationViewportBridgeOpsByTabId = new Map>() - private readonly worktreeIdByTabId = new Map() - private readonly policyAttachedGuestIds = new Set() - private readonly offscreenGuestIds = new Set() - private readonly policyCleanupByGuestId = new Map void>() - private readonly clickedLinkFrameNameByGuestId = new Map() - private readonly loadErrorsByGuestId = new Map() - // Why: did-start-navigation hides the overlay optimistically; stash the cleared error so did-fail-load(-3) can restore an aborted nav. - private readonly clearedLoadErrorsByGuestId = new Map() - private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null - private certificateTrustController: BrowserCertificateTrustController | null = null - private shouldForwardDictationShortcut: (() => boolean) | null = null - private readonly pendingLoadFailuresByGuestId = new Map< - number, - { code: number; description: string; validatedUrl: string } - >() - private readonly pendingPermissionEventsByGuestId = new Map() - private readonly pendingPopupEventsByGuestId = new Map() - private readonly pendingDownloadIdsByGuestId = new Map() - private readonly downloadsById = new Map() - private readonly grabSessionController = new BrowserGrabSessionController() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } - - setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { - this.browserGuestStateChangedListener = listener - } - - setCertificateTrustController(controller: BrowserCertificateTrustController): void { - this.certificateTrustController = controller - } - - installCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.installSessionRequestGuard(session) - } - - removeCertificateRequestGuard(session: Electron.Session): void { - this.certificateTrustController?.removeSessionRequestGuard(session) - } - - setSettingsResolver( - resolver: () => { - keybindings?: KeybindingOverrides - mobileEmulatorEnabled?: boolean - } - ): void { - this.settingsResolver = resolver - } - - // Why: addScriptToEvaluateOnNewDocument (CDP) is the only reliable pre-page-script hook per nav; executeJavaScript ran on the old page context. - private injectAntiDetection(guest: Electron.WebContents): () => void { - let disposed = false - let reattachTimer: ReturnType | null = null - - const attach = (): void => { - if (disposed || guest.isDestroyed()) { - return - } - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - void guest.debugger - .sendCommand('Page.enable', {}) - .then(() => - guest.debugger.sendCommand('Page.addScriptToEvaluateOnNewDocument', { - source: ANTI_DETECTION_SCRIPT - }) - ) - .catch(() => {}) - } catch { - /* best-effort — debugger may be unavailable */ - } - } - - // Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides. - const onDetach = (): void => { - this.authUserAgentOverrideStateByGuestId.delete(guest.id) - if (!disposed && !guest.isDestroyed() && reattachTimer === null) { - reattachTimer = setTimeout(() => { - reattachTimer = null - attach() - }, 500) - } - } - - try { - attach() - guest.debugger.on('detach', onDetach) - } catch { - /* best-effort */ - } - - return () => { - disposed = true - if (reattachTimer !== null) { - clearTimeout(reattachTimer) - reattachTimer = null - } - try { - guest.debugger.off('detach', onDetach) - } catch { - /* guest may already be destroyed */ - } - } - } - - private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { - return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null - } - - private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (browserTabId) { - return { browserTabId, rootGuestWebContentsId: guestWebContentsId } - } - // Route popups live in an Orca-built window, so they never pass through did-create-window and - // have no inherited context; their owning page comes from the route popup registry instead. - const routeOpenerWebContentsId = resolveBrowserRouteGuestPopupOpener(guestWebContentsId) - if (routeOpenerWebContentsId !== null) { - const openerTabId = this.tabIdByWebContentsId.get(routeOpenerWebContentsId) - return openerTabId - ? { browserTabId: openerTabId, rootGuestWebContentsId: routeOpenerWebContentsId } - : null - } - const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) - if ( - inherited && - this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId - ) { - return inherited - } - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - return null - } - - /** Shared across the whole opener tree, so a chain of popups draws from one budget. */ - private tryConsumePageInitiatedTab(rootGuestWebContentsId: number): boolean { - let budget = this.pageInitiatedTabBudgetByRootGuestId.get(rootGuestWebContentsId) - if (!budget) { - budget = createPageInitiatedTabBudget() - this.pageInitiatedTabBudgetByRootGuestId.set(rootGuestWebContentsId, budget) - } - return budget.tryConsume(Date.now()) - } - - private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { - const rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) - if (!rendererWebContentsId) { - return null - } - const renderer = webContents.fromId(rendererWebContentsId) - if (!renderer || renderer.isDestroyed()) { - return null - } - return renderer - } - - // Why: screenshots target page ids but visible chrome is keyed by workspace id; activate by workspace or the webview stays hidden and capture times out. - async ensureWebviewVisible(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const browserWorkspaceId = this.workspaceIdByPageId.get(browserPageId) ?? browserPageId - const worktreeId = this.worktreeIdByTabId.get(browserPageId) ?? null - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - const prev = await renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return null; - var state = store.getState(); - var prevTabType = state.activeTabType; - var prevActiveWorktreeId = state.activeWorktreeId || null; - var prevActiveBrowserWorkspaceId = state.activeBrowserTabId || null; - var prevActiveBrowserPageId = null; - var prevFocusedGroupTabId = null; - var targetWorktreeId = ${JSON.stringify(worktreeId)}; - var browserWorkspaceId = ${JSON.stringify(browserWorkspaceId)}; - var browserPageId = ${JSON.stringify(browserPageId)}; - var browserTabsByWorktree = state.browserTabsByWorktree || {}; - - if (prevActiveWorktreeId) { - var prevFocusedGroupId = (state.activeGroupIdByWorktree || {})[prevActiveWorktreeId]; - var prevGroups = (state.groupsByWorktree || {})[prevActiveWorktreeId] || []; - for (var pg = 0; pg < prevGroups.length; pg++) { - if (prevGroups[pg].id === prevFocusedGroupId) { - prevFocusedGroupTabId = prevGroups[pg].activeTabId; - break; - } - } - } - - if (prevActiveBrowserWorkspaceId) { - for (var prevWtId in browserTabsByWorktree) { - var prevBrowserTabs = browserTabsByWorktree[prevWtId] || []; - for (var pbt = 0; pbt < prevBrowserTabs.length; pbt++) { - if (prevBrowserTabs[pbt].id === prevActiveBrowserWorkspaceId) { - prevActiveBrowserPageId = prevBrowserTabs[pbt].activePageId || null; - break; - } - } - if (prevActiveBrowserPageId) break; - } - } - - if ( - targetWorktreeId && - prevActiveWorktreeId !== targetWorktreeId && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(targetWorktreeId); - state = store.getState(); - } - - var foundWorkspace = null; - for (var wtId in browserTabsByWorktree) { - var tabs = browserTabsByWorktree[wtId] || []; - for (var i = 0; i < tabs.length; i++) { - if (tabs[i].id === browserWorkspaceId) { - foundWorkspace = tabs[i]; - if (!targetWorktreeId) { - targetWorktreeId = wtId; - } - break; - } - } - if (foundWorkspace) break; - } - - var hasTargetPage = false; - var targetPages = (state.browserPagesByWorkspace || {})[browserWorkspaceId] || []; - for (var pageIndex = 0; pageIndex < targetPages.length; pageIndex++) { - if (targetPages[pageIndex].id === browserPageId) { - hasTargetPage = true; - break; - } - } - - if (foundWorkspace) { - if (typeof state.setActiveBrowserTab === 'function') { - state.setActiveBrowserTab(browserWorkspaceId); - state = store.getState(); - } else { - var allTabs = state.unifiedTabsByWorktree || {}; - var found = null; - for (var unifiedWtId in allTabs) { - var unifiedTabs = allTabs[unifiedWtId] || []; - for (var unifiedIndex = 0; unifiedIndex < unifiedTabs.length; unifiedIndex++) { - if ( - unifiedTabs[unifiedIndex].contentType === 'browser' && - unifiedTabs[unifiedIndex].entityId === browserWorkspaceId - ) { - found = unifiedTabs[unifiedIndex]; - break; - } - } - if (found) break; - } - if (found) { - state.activateTab(found.id); - } - state.setActiveTabType('browser'); - state = store.getState(); - } - // Why: activating the workspace alone is not enough for screenshot - // capture when a browser workspace contains multiple pages. The - // compositor only paints the currently mounted page guest. - if ( - hasTargetPage && - foundWorkspace.activePageId !== browserPageId && - typeof state.setActiveBrowserPage === 'function' - ) { - state.setActiveBrowserPage(browserWorkspaceId, browserPageId); - state = store.getState(); - } - } - - return { - prevTabType: prevTabType, - prevActiveWorktreeId: prevActiveWorktreeId, - prevActiveBrowserWorkspaceId: prevActiveBrowserWorkspaceId, - prevActiveBrowserPageId: prevActiveBrowserPageId, - prevFocusedGroupTabId: prevFocusedGroupTabId, - targetWorktreeId: targetWorktreeId, - targetBrowserWorkspaceId: foundWorkspace ? browserWorkspaceId : null, - targetBrowserPageId: foundWorkspace && hasTargetPage ? browserPageId : null - }; - })()` - ) - .catch(() => null) - - const needsRestore = - prev && - (prev.prevTabType !== 'browser' || - prev.prevActiveWorktreeId !== prev.targetWorktreeId || - prev.prevFocusedGroupTabId !== null || - prev.prevActiveBrowserWorkspaceId !== prev.targetBrowserWorkspaceId || - prev.prevActiveBrowserPageId !== prev.targetBrowserPageId) - - if (!needsRestore) { - return () => {} - } - - return () => { - if (!prev || !renderer || renderer.isDestroyed()) { - return - } - renderer - .executeJavaScript( - `(function() { - var store = window.__store; - if (!store) return; - var state = store.getState(); - if ( - ${JSON.stringify(prev?.prevActiveWorktreeId)} && - ${JSON.stringify(prev?.prevActiveWorktreeId)} !== - ${JSON.stringify(prev?.targetWorktreeId)} && - typeof state.setActiveWorktree === 'function' - ) { - state.setActiveWorktree(${JSON.stringify(prev?.prevActiveWorktreeId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} !== - ${JSON.stringify(prev?.targetBrowserWorkspaceId)} && - typeof state.setActiveBrowserTab === 'function' - ) { - state.setActiveBrowserTab(${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} && - ${JSON.stringify(prev?.prevActiveBrowserPageId)} !== - ${JSON.stringify(prev?.targetBrowserPageId)} && - typeof state.setActiveBrowserPage === 'function' - ) { - // Why: Orca remembers the last browser workspace/page even when - // the user is currently in terminal/editor view. Screenshot prep - // temporarily switches that hidden browser selection state, so - // restore it independently of the visible tab type. - state.setActiveBrowserPage( - ${JSON.stringify(prev?.prevActiveBrowserWorkspaceId)}, - ${JSON.stringify(prev?.prevActiveBrowserPageId)} - ); - state = store.getState(); - } - if ( - ${JSON.stringify(prev?.prevTabType)} !== 'browser' && - ${JSON.stringify(prev?.prevFocusedGroupTabId)} - ) { - state.activateTab(${JSON.stringify(prev?.prevFocusedGroupTabId)}); - } - if (${JSON.stringify(prev?.prevTabType)} !== 'browser') { - state.setActiveTabType(${JSON.stringify(prev?.prevTabType)}); - } - })()` - ) - .catch(() => {}) - } - } - - async acquireAutomationVisibility(guestWebContentsId: number): Promise<() => void> { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return () => {} - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - if (!renderer || renderer.isDestroyed()) { - return () => {} - } - - // Why: agent commands need a paintable webview for lazy-loading sites without stealing the user's visible tab. - const acquirePromise = renderer - .executeJavaScript( - `(async function() { - var bridge = window.__orcaBrowserAutomationVisibility; - if (!bridge || typeof bridge.acquire !== 'function') return null; - return await bridge.acquire(${JSON.stringify(browserPageId)}); - })()` - ) - .catch(() => null) - const { value: token, timedOut } = await resolveWithTimeout( - acquirePromise, - AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS, - null - ) - - if (!isAutomationVisibilityToken(token)) { - return createNoopRestoreForTimedOutAutomationAcquire(renderer, acquirePromise, timedOut) - } - - return () => { - releaseAutomationVisibilityToken(renderer, token) - } - } - - attachGuestPolicies( - guest: Electron.WebContents, - inheritedOwnerContext: PopupOwnerContext | null = null, - policy: BrowserGuestPolicy = BROWSING_GUEST_POLICY - ): void { - if (this.policyAttachedGuestIds.has(guest.id)) { - return - } - this.policyAttachedGuestIds.add(guest.id) - // Why one door with a profile rather than a second installer beside it: whether a guest was - // policy-attached at all is what registration and teardown both key on, so a guest that took - // another path into the app is invisible to both. - if (policy.profile === 'workspace-doc') { - this.attachWorkspaceDocGuestPolicies(guest, policy.host) - return - } - if (inheritedOwnerContext) { - this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) - } - // Why: only the primary embedded browser converts new-tab clicks to Orca tabs; OAuth child windows keep native link behavior. - const clickedLinkFrameName = inheritedOwnerContext - ? null - : `__orca_clicked_link_foreground_${randomUUID()}` - if (clickedLinkFrameName) { - this.clickedLinkFrameNameByGuestId.set(guest.id, clickedLinkFrameName) - } - let clickedLinkRoutingActive = Boolean(clickedLinkFrameName) - - // Why: bot detectors probe APIs that differ in Electron webviews; inject overrides each load so manual browsing passes. - const disposeAntiDetection = this.injectAntiDetection(guest) - // Why: disable throttling so background screenshots still get frames; else the compositor stalls and capture returns empty. - guest.setBackgroundThrottling(false) - const installClickedLinkRouting = (): void => { - if (!clickedLinkRoutingActive || !clickedLinkFrameName || guest.isDestroyed()) { - return - } - // Why: an isolated-world listener labels real anchor clicks without exposing the frame name to page scripts. - void guest - .executeJavaScriptInIsolatedWorld( - BROWSER_CLICKED_LINK_ROUTING_WORLD_ID, - [ - { - // Why: mobile emulation spoofs the UA as iOS, so use the real host platform from main for modifier routing. - code: buildBrowserClickedLinkRoutingScript( - clickedLinkFrameName, - process.platform === 'darwin' - ) - } - ], - false - ) - .catch(() => {}) - } - if (clickedLinkFrameName) { - guest.on('dom-ready', installClickedLinkRouting) - } - const pendingIframeRoutingInstalls = new Map void>() - const iframeFrameNameByFrame = new Map() - const iframeFrameByFrameName = new Map() - const clearIframeFrameName = (frame: Electron.WebFrameMain): void => { - const name = iframeFrameNameByFrame.get(frame) - if (!name) { - return - } - iframeFrameNameByFrame.delete(frame) - iframeFrameByFrameName.delete(name) - } - const installIframeClickedLinkRouting = (frame: Electron.WebFrameMain): void => { - clearIframeFrameName(frame) - if (!clickedLinkRoutingActive || frame.isDestroyed()) { - return - } - const name = `__orca_clicked_link_iframe_foreground_${randomUUID()}` - iframeFrameNameByFrame.set(frame, name) - iframeFrameByFrameName.set(name, frame) - // Why: child-frame tokens live in the page world, so consume after one trusted click and replace before the next. - void frame - .executeJavaScript( - buildBrowserIframeClickedLinkRoutingScript(name, process.platform === 'darwin'), - false - ) - .catch(() => { - if (iframeFrameNameByFrame.get(frame) === name) { - clearIframeFrameName(frame) - } - }) - } - const handleFrameCreated = ( - _event: Electron.Event, - { frame }: Electron.FrameCreatedDetails - ): void => { - if (!clickedLinkFrameName || !frame || frame.parent === null) { - return - } - for (const knownFrame of iframeFrameNameByFrame.keys()) { - if (knownFrame.isDestroyed()) { - clearIframeFrameName(knownFrame) - } - } - const installAfterDomReady = (): void => { - pendingIframeRoutingInstalls.delete(frame) - installIframeClickedLinkRouting(frame) - } - pendingIframeRoutingInstalls.set(frame, installAfterDomReady) - frame.once('dom-ready', installAfterDomReady) - } - if (clickedLinkFrameName) { - guest.on('frame-created', handleFrameCreated) - } - const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { - // Why: popup descendants inherit the opener's owner context but must not replace its primary registration. - this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) - } - guest.on('did-create-window', handleDidCreateWindow) - guest.setWindowOpenHandler(({ url, frameName, disposition, features }) => { - const ownerContext = this.resolvePopupOwnerContext(guest.id) - const browserTabId = ownerContext?.browserTabId ?? null - const browserUrl = normalizeBrowserNavigationUrl(url) - const externalUrl = normalizeExternalBrowserUrl(url) - const expectedClickedLinkFrameName = this.clickedLinkFrameNameByGuestId.get(guest.id) - const iframeFrame = frameName ? iframeFrameByFrameName.get(frameName) : undefined - let isClickedLink = Boolean( - expectedClickedLinkFrameName && frameName === expectedClickedLinkFrameName - ) - if (!isClickedLink && iframeFrame) { - isClickedLink = true - clearIframeFrameName(iframeFrame) - queueMicrotask(() => installIframeClickedLinkRouting(iframeFrame)) - } - - if (isClickedLink) { - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(browserUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized gesture must never fall through to a native popup if its renderer vanished mid-click. - return { action: 'deny' } - } - - // Why: an unnamed, featureless window.open() is Chromium's own new-tab shape, so an Orca tab is - // the honest presentation; a floating origin-bar window is not. Opener-dependent shapes are - // excluded by isNewBrowserTabPopupIntent and still get a real child window below. - if ( - ownerContext && - externalUrl && - isNewBrowserTabPopupIntent({ frameName, disposition, features }) - ) { - // Why: one activation lets a page loop window.open, and each routed tab persists into - // workspace session state, so it survives the quit that used to clear popup windows. - if (!this.tryConsumePageInitiatedTab(ownerContext.rootGuestWebContentsId)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'blocked' - }) - return { action: 'deny' } - } - if (this.openLinkInOrcaTab(ownerContext.browserTabId, externalUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-in-orca' - }) - } - // Why: a recognized new-tab intent must never fall through to a native popup if its renderer vanished mid-open. - return { action: 'deny' } - } - - // Why: file URLs are fine for in-pane previews, but must not spawn native child windows targeting local paths. - const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) - if (browserTabId && canOpenAsChild) { - // Why: OAuth may request size/position, but content must not create deceptive or inescapable native chrome. - return { - action: 'allow', - overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, - // Why: default child windows lack an address bar; host in an Orca origin-bar window so the destination is verifiable. - createWindow: (options: PopupChildWindowOptions) => - this.createPopupChildWindowWithOriginBar(guest, url, options) - } - } else if (externalUrl) { - // Why: Kagi target=_blank popup URLs still contain the bearer token; redact before handing to the OS browser. - void shell.openExternal(redactKagiSessionToken(externalUrl)) - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(externalUrl), - action: 'opened-external' - }) - } else { - // Why: popup URLs can carry auth redirects/one-time tokens; surface only sanitized origin metadata. - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(url), - action: 'blocked' - }) - } - return { action: 'deny' } - }) - - const navigationGuard = (event: Electron.Event, url: string): boolean => { - // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. - if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return true - } - // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. - if (url.startsWith('file:')) { - event.preventDefault() - return false - } - if (!normalizeBrowserNavigationUrl(url)) { - // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. - event.preventDefault() - return false - } - return true - } - - const willRedirectHandler = ( - event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - this.updatePendingNavigationForRedirect(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true }) - } - - const didFailLoadHandler = ( - _event: Electron.Event, - errorCode: number, - errorDescription: string, - validatedURL: string, - isMainFrame: boolean - ): void => { - if (!isMainFrame) { - return - } - // Why: a nav that never committed must not leave its target standing as the tab's host. - const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) - if (failedNavigationWasCurrent) { - // The attempted host never committed, so restore every UA layer to the document that remains. - this.applyGoogleAuthUserAgent(guest, guest.getURL()) - } - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - const certificateFailure = browserPageId - ? this.certificateTrustController?.getFailure(browserPageId) - : null - if ( - certificateFailure && - toSecureCertificateEndpoint(validatedURL || guest.getURL()) === - toSecureCertificateEndpoint(certificateFailure.origin) - ) { - // Why: this cancellation carries the existing cert warning; don't overwrite it with ERR_ABORTED copy. - return - } - if (errorCode === -3) { - // Why: an aborted nav never committed; restore the error did-start-navigation cleared so it isn't lost. - const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) - if (clearedError !== undefined) { - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.loadErrorsByGuestId.set(guest.id, clearedError) - this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) - this.notifyBrowserGuestStateChanged(guest.id) - } - return - } - this.clearedLoadErrorsByGuestId.delete(guest.id) - const loadError = this.buildLoadError( - errorCode, - errorDescription || 'This site could not be reached.', - validatedURL || guest.getURL() || 'about:blank' - ) - this.loadErrorsByGuestId.set(guest.id, loadError) - this.forwardOrQueueGuestLoadFailure(guest.id, loadError) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didStartNavigationHandler = ( - _event: Electron.Event, - url: string, - _isInPlace: boolean, - isMainFrame: boolean - ): void => { - if (!isMainFrame || isChromiumInternalErrorUrl(url)) { - return - } - // Why: getURL() still reports the previous committed URL until this navigation commits, so - // every UA writer must read the in-flight target or they disagree about the tab's host. - this.startPendingNavigation(guest.id, url) - this.applyGoogleAuthUserAgent(guest, url) - this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) - // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. - this.pendingLoadFailuresByGuestId.delete(guest.id) - const activeError = this.loadErrorsByGuestId.get(guest.id) - if (activeError === undefined) { - // Why: no error to hide; drop any stale stash so a later abort can't resurrect an old failure. - this.clearedLoadErrorsByGuestId.delete(guest.id) - return - } - this.clearedLoadErrorsByGuestId.set(guest.id, activeError) - this.loadErrorsByGuestId.delete(guest.id) - this.notifyBrowserGuestStateChanged(guest.id) - } - - const didNavigateHandler = (_event: Electron.Event, url: string): void => { - // Why: once committed, getURL() reports this url, so the pending target is redundant. - this.pendingNavigationByGuestId.delete(guest.id) - // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. - this.clearedLoadErrorsByGuestId.delete(guest.id) - this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) - } - - guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', willRedirectHandler) - guest.on('did-start-navigation', didStartNavigationHandler) - guest.on('did-navigate', didNavigateHandler) - guest.on('did-fail-load', didFailLoadHandler) - const handleDestroyed = (): void => { - // Why: guests can die before renderer registration, else attach-time closures leak until shutdown. - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - - // Why: store cleanup so unregisterGuest can drop these listeners on teardown and let the WebContents wrapper GC. - this.policyCleanupByGuestId.set(guest.id, () => { - disposeAntiDetection() - try { - guest.off('destroyed', handleDestroyed) - guest.off('did-create-window', handleDidCreateWindow) - if (clickedLinkFrameName) { - clickedLinkRoutingActive = false - guest.off('dom-ready', installClickedLinkRouting) - guest.off('frame-created', handleFrameCreated) - for (const [frame, install] of pendingIframeRoutingInstalls) { - if (!frame.isDestroyed()) { - frame.off('dom-ready', install) - } - } - pendingIframeRoutingInstalls.clear() - iframeFrameNameByFrame.clear() - iframeFrameByFrameName.clear() - } - } catch { - // guest may already be destroyed - } - if (!guest.isDestroyed()) { - guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', willRedirectHandler) - guest.off('did-start-navigation', didStartNavigationHandler) - guest.off('did-navigate', didNavigateHandler) - guest.off('did-fail-load', didFailLoadHandler) - } - }) - } - - /** - * A workspace document is not the web: no popups, no link routing, no anti-detection, and no - * navigation bookkeeping for chrome it does not have. What it does share with a browsing guest is - * this method's teardown, so a retired preview drops its listeners on the same path. - */ - private attachWorkspaceDocGuestPolicies( - guest: Electron.WebContents, - host: Electron.WebContents - ): void { - const disposeDocPolicy = installDocPreviewGuestPolicy(guest, host) - const handleDestroyed = (): void => { - this.cleanupGuestPolicyAttachment(guest.id) - } - guest.on('destroyed', handleDestroyed) - this.policyCleanupByGuestId.set(guest.id, () => { - disposeDocPolicy() - try { - guest.off('destroyed', handleDestroyed) - } catch { - // guest may already be destroyed - } - }) - } - - // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupClientHintsOverride - // must be matched here per navigation or the two layers disagree — itself a bot tell. - // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the whole clean-UA path, so they keep their untouched identity everywhere. - private applyGoogleAuthUserAgent( - guest: Electron.WebContents, - url: string, - options: { duringRedirect?: boolean } = {} - ): void { - const browserPageId = this.tabIdByWebContentsId.get(guest.id) - // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct - // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so - // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. - const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - // Session state is authoritative before renderer registration and after a native profile imports a source UA. - const mode = - getBrowserSessionUserAgentMode(guest.session) ?? - (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) - if (mode === 'native') { - return - } - const firefoxUa = googleAuthUserAgent() - const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id) - const latestPendingOverride = overrideState?.pending.at(-1) - const confirmedOverride = overrideState?.confirmed - const currentOverride = - latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1) - ? latestPendingOverride - : confirmedOverride - const currentUa = currentOverride?.userAgent ?? guest.getUserAgent() - const nextUa = isGoogleAuthUrl(url) - ? firefoxUa - : // Only restore when the auth-host override is actually in place, so normal - // navigation never touches the session UA. - currentUa === firefoxUa - ? guest.session.getUserAgent() - : null - let authOverrideIssuedOverCdp = false - if (nextUa !== null && nextUa !== currentUa) { - // Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight - // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain - // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without - // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride - // rewrites User-Agent per request for auth-host URLs on its own. - if (options.duringRedirect === true || overrideState !== undefined) { - if (this.canOverrideUserAgentOverCdp(guest)) { - authOverrideIssuedOverCdp = true - // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off - // them, any mobile preset preserved. Writing the session UA directly would put the - // unlaundered Electron token back on the wire. - void this.applyAuthUserAgentOverrideOverCdp( - guest, - (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? - false, - url, - nextUa - ) - } - // Why: with no debugger there is no way to retarget the identity without cancelling the - // redirect. A stale navigator.userAgent is recoverable; a dead navigation is not. - } else { - guest.setUserAgent(nextUa) - } - } - // Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of - // its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport. - if (browserPageId && !authOverrideIssuedOverCdp) { - this.reapplyViewportUserAgentOverride(guest, browserPageId, url) - } - } - - private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean { - try { - return !guest.isDestroyed() && guest.debugger.isAttached() - } catch { - return false - } - } - - private applyAuthUserAgentOverrideOverCdp( - guest: Electron.WebContents, - mobile: boolean, - url: string, - userAgent: string - ): Promise { - if (!this.canOverrideUserAgentOverCdp(guest)) { - return Promise.resolve(false) - } - const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? { - confirmed: null, - nextSequence: 0, - pending: [] - } - const operation = { sequence: ++state.nextSequence, userAgent } - state.pending.push(operation) - this.authUserAgentOverrideStateByGuestId.set(guest.id, state) - return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then( - () => this.settleAuthUserAgentOverride(guest.id, state, operation, true), - () => { - this.settleAuthUserAgentOverride(guest.id, state, operation, false) - return false - } - ) - } - - private settleAuthUserAgentOverride( - guestId: number, - state: AuthUserAgentOverrideState, - operation: AuthUserAgentOverrideOperation, - succeeded: boolean - ): boolean { - if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) { - return false - } - if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) { - state.confirmed = operation - } - const pendingIndex = state.pending.indexOf(operation) - if (pendingIndex !== -1) { - state.pending.splice(pendingIndex, 1) - } - if (state.confirmed === null && state.pending.length === 0) { - this.authUserAgentOverrideStateByGuestId.delete(guestId) - } - return true - } - - private startPendingNavigation(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] - }) - } - - private updatePendingNavigationForRedirect(guestId: number, url: string): void { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - this.pendingNavigationByGuestId.set(guestId, { - currentUrl: url, - supersededUrls: [] - }) - return - } - pending.currentUrl = url - } - - private failPendingNavigation(guestId: number, failedUrl: string): boolean { - const pending = this.pendingNavigationByGuestId.get(guestId) - if (!pending) { - return false - } - const supersededIndex = pending.supersededUrls.indexOf(failedUrl) - if (supersededIndex !== -1) { - pending.supersededUrls.splice(supersededIndex, 1) - return false - } - if (pending.currentUrl !== failedUrl) { - return false - } - this.pendingNavigationByGuestId.delete(guestId) - return true - } - - // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host - // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through - // here or two writers racing the same navigation will pick opposite identities. - private resolveTabNavigationUrl(guest: Electron.WebContents): string { - return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() - } - - // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, - // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the - // request header says Firefox — the two-layer disagreement this scope exists to remove. - private reapplyViewportUserAgentOverride( - guest: Electron.WebContents, - browserTabId: string, - url: string - ): void { - const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - if (mobile === undefined) { - return - } - // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the - // later-issued write wins. What matters is that both writers resolve the SAME host, which they - // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) - } - - private async sendViewportUserAgentOverride( - guest: Electron.WebContents, - mobile: boolean, - url?: string, - baseUserAgent?: string - ): Promise { - if (guest.isDestroyed() || !guest.debugger.isAttached()) { - return - } - await guest.debugger.sendCommand( - 'Emulation.setUserAgentOverride', - buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest), - mobile, - // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: - // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to - // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) - }) - ) - } - - /** Route guests own their own popup handler, so their denials arrive here instead. */ - reportRouteGuestPopupBlocked(input: { openerWebContentsId: number; url: string }): void { - this.forwardOrQueuePopupEvent(input.openerWebContentsId, { - origin: safeOrigin(input.url), - action: 'blocked' - }) - } - - private createPopupChildWindowWithOriginBar( - openerGuest: Electron.WebContents, - targetUrl: string, - options: PopupChildWindowOptions - ): Electron.WebContents { - const popup = openPopupWithOriginBar(options, targetUrl) - // Why: Electron emits no did-create-window for createWindow children, so attach the opener's policies here. - this.attachGuestPolicies( - popup.contentWebContents, - this.resolvePopupOwnerContext(openerGuest.id) - ) - this.forwardOrQueuePopupEvent(openerGuest.id, { - origin: safeOrigin(targetUrl), - action: 'opened-in-orca' - }) - // Why: match Electron's child-window lifecycle so closing the owning tab doesn't orphan session-bearing popups. - const closePopupWithOpener = (): void => popup.close() - openerGuest.once('destroyed', closePopupWithOpener) - popup.onClosed(() => { - if (!openerGuest.isDestroyed()) { - openerGuest.off('destroyed', closePopupWithOpener) - } - }) - return popup.contentWebContents - } - - private retireStaleGuestWebContents(previousWebContentsId: number): void { - // Why: after a renderer-process swap, stop the dead guest id resolving to the live page so stale callbacks don't hit the wrong session. - this.cleanupGuestPolicyAttachment(previousWebContentsId) - } - - private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - const isPrimaryGuest = browserTabId !== undefined - if (browserTabId && this.webContentsIdByTabId.get(browserTabId) === guestWebContentsId) { - this.webContentsIdByTabId.delete(browserTabId) - } - this.tabIdByWebContentsId.delete(guestWebContentsId) - this.certificateTrustController?.onGuestRetired(guestWebContentsId) - const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) - if (policyCleanup) { - policyCleanup() - this.policyCleanupByGuestId.delete(guestWebContentsId) - } - this.policyAttachedGuestIds.delete(guestWebContentsId) - this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) - this.offscreenGuestIds.delete(guestWebContentsId) - this.popupOwnerContextByGuestId.delete(guestWebContentsId) - this.pageInitiatedTabBudgetByRootGuestId.delete(guestWebContentsId) - this.authUserAgentOverrideStateByGuestId.delete(guestWebContentsId) - this.pendingNavigationByGuestId.delete(guestWebContentsId) - // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. - if (isPrimaryGuest) { - for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { - if (owner.rootGuestWebContentsId === guestWebContentsId) { - this.popupOwnerContextByGuestId.delete(popupGuestId) - } - } - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.loadErrorsByGuestId.delete(guestWebContentsId) - this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - this.cancelPendingDownloadsForGuest(guestWebContentsId) - } - - registerGuest({ - browserPageId, - browserTabId: legacyBrowserTabId, - workspaceId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId, - rendererWebContentsId - }: BrowserGuestRegistration): boolean { - const browserTabId = browserPageId ?? legacyBrowserTabId - // Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one - // id resolves in both and the tool door silently prefers the document guest over the page. - if (!browserTabId || isWorkspaceDocPageId(browserTabId)) { - return false - } - // Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents. - this.cancelGrabOp(browserTabId, 'evicted') - - const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - - // Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests. - if (guest.getType() !== 'webview') { - return false - } - if (!this.policyAttachedGuestIds.has(webContentsId)) { - // Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview. - return false - } - - const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - } - this.webContentsIdByTabId.set(browserTabId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserTabId) - if (workspaceId) { - this.workspaceIdByPageId.set(browserTabId, workspaceId) - } - this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserTabId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserTabId) - } - this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId) - if (worktreeId) { - this.worktreeIdByTabId.set(browserTabId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) - - this.setupContextMenu(browserTabId, guest) - this.setupGrabShortcut(browserTabId, guest) - this.setupShortcutForwarding(browserTabId, guest) - this.setupMouseWheelZoomForwarding(browserTabId, guest) - this.flushPendingLoadFailure(browserTabId, webContentsId) - this.flushPendingPermissionEvents(browserTabId, webContentsId) - this.flushPendingPopupEvents(browserTabId, webContentsId) - this.flushPendingDownloadRequests(browserTabId, webContentsId) - return true - } - - unregisterGuest(browserTabId: string): void { - // Why the check on the exit door too: a document page withdraws by revoking its grant, never - // through here, so its id arriving is misaddressed — and the cancel below would evict that - // preview's live grab on the strength of it. - if (isWorkspaceDocPageId(browserTabId)) { - return - } - // Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise. - this.cancelGrabOp(browserTabId, 'evicted') - - // Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC. - const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId) - if (guestWebContentsId !== undefined) { - this.cleanupGuestPolicyAttachment(guestWebContentsId) - } - - const cleanup = this.contextMenuCleanupByTabId.get(browserTabId) - if (cleanup) { - cleanup() - this.contextMenuCleanupByTabId.delete(browserTabId) - } - const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (shortcutCleanup) { - shortcutCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (fwdCleanup) { - fwdCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (mouseWheelZoomCleanup) { - mouseWheelZoomCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - // Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them. - for (const [downloadId, download] of this.downloadsById.entries()) { - if (download.browserTabId === browserTabId && !download.terminalEvent) { - this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.') - } - } - const wcId = this.webContentsIdByTabId.get(browserTabId) - if (wcId !== undefined) { - this.tabIdByWebContentsId.delete(wcId) - } - this.webContentsIdByTabId.delete(browserTabId) - this.rendererWebContentsIdByTabId.delete(browserTabId) - this.workspaceIdByPageId.delete(browserTabId) - this.sessionProfileIdByPageId.delete(browserTabId) - this.userAgentModeByPageId.delete(browserTabId) - this.worktreeIdByTabId.delete(browserTabId) - // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. - this.viewportOpsByTabId.delete(browserTabId) - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - if (wcId !== undefined) { - this.pendingNavigationByGuestId.delete(wcId) - } - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - - // Why: headless orca serve has no window; back pages with offscreen WebContents and skip the webview-only setup. - registerOffscreenGuest({ - browserPageId, - worktreeId, - sessionProfileId, - userAgentMode, - webContentsId - }: { - browserPageId: string - worktreeId?: string - sessionProfileId?: string | null - userAgentMode?: BrowserSessionUserAgentMode - webContentsId: number - }): boolean { - // Why the same check on both registration doors: one id resolving in both halves is the exact - // confusion the split registries exist to prevent. - if (isWorkspaceDocPageId(browserPageId)) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - return false - } - // Why: offscreen pages have no renderer webview listeners, so main owns their load-failure lifecycle. - this.offscreenGuestIds.add(webContentsId) - this.attachGuestPolicies(guest) - const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) - if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { - this.retireStaleGuestWebContents(previousWebContentsId) - } - this.webContentsIdByTabId.set(browserPageId, webContentsId) - this.tabIdByWebContentsId.set(webContentsId, browserPageId) - this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null) - if (userAgentMode) { - this.userAgentModeByPageId.set(browserPageId, userAgentMode) - } else { - this.userAgentModeByPageId.delete(browserPageId) - } - if (worktreeId) { - this.worktreeIdByTabId.set(browserPageId, worktreeId) - } - this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) - return true - } - - unregisterAll(): void { - // Cancel all active grab ops before tearing down registrations - this.grabSessionController.cancelAll('evicted') - for (const downloadId of this.downloadsById.keys()) { - this.cancelDownloadInternal(downloadId, 'Orca is shutting down.') - } - browserDownloadDestinationReservations.clear() - for (const browserTabId of this.webContentsIdByTabId.keys()) { - this.unregisterGuest(browserTabId) - } - this.policyAttachedGuestIds.clear() - this.offscreenGuestIds.clear() - // Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here. - for (const cleanup of this.policyCleanupByGuestId.values()) { - cleanup() - } - this.policyCleanupByGuestId.clear() - this.clickedLinkFrameNameByGuestId.clear() - this.tabIdByWebContentsId.clear() - this.popupOwnerContextByGuestId.clear() - this.pageInitiatedTabBudgetByRootGuestId.clear() - this.worktreeIdByTabId.clear() - this.sessionProfileIdByPageId.clear() - this.userAgentModeByPageId.clear() - this.viewportUaOverrideMobileByTabId.clear() - this.authUserAgentOverrideStateByGuestId.clear() - this.pendingNavigationByGuestId.clear() - this.pendingLoadFailuresByGuestId.clear() - this.loadErrorsByGuestId.clear() - this.clearedLoadErrorsByGuestId.clear() - this.pendingPermissionEventsByGuestId.clear() - this.pendingPopupEventsByGuestId.clear() - this.pendingDownloadIdsByGuestId.clear() - this.mouseWheelZoomCleanupByTabId.clear() - this.annotationViewportBridgeOpsByTabId.clear() - } - - getGuestWebContentsId(browserTabId: string): number | null { - return this.webContentsIdByTabId.get(browserTabId) ?? null - } - - getWebContentsIdByTabId(): Map { - return this.webContentsIdByTabId - } - - getTabIdForWebContentsId(webContentsId: number): string | null { - return this.tabIdByWebContentsId.get(webContentsId) ?? null - } - - getWorktreeIdForTab(browserTabId: string): string | undefined { - return this.worktreeIdByTabId.get(browserTabId) - } - - getRendererContextForGuest( - guestWebContentsId: number - ): { browserPageId: string; renderer: Electron.WebContents } | null { - const browserPageId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserPageId) { - return null - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - return renderer ? { browserPageId, renderer } : null - } - - getSessionProfileIdForTab(browserTabId: string): string | null { - return this.sessionProfileIdByPageId.get(browserTabId) ?? null - } - - getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { - const webContentsId = this.webContentsIdByTabId.get(browserPageId) - return webContentsId === undefined - ? null - : (this.loadErrorsByGuestId.get(webContentsId) ?? null) - } - - getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { - return this.certificateTrustController?.getFailure(browserPageId) ?? null - } - - getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { - if (this.popupOwnerContextByGuestId.has(webContentsId)) { - return null - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null - const offscreen = this.offscreenGuestIds.has(webContentsId) - if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { - return null - } - if (!offscreen) { - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { - return null - } - } - return { - browserPageId, - worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, - sessionProfileId: browserPageId - ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) - : null, - owner: offscreen ? 'offscreen' : 'desktop-webview' - } - } - - // Why: centralize Kagi session-token redaction so every load-error path (did-fail-load, cert failure) strips it. - private buildLoadError(code: number, description: string, rawUrl: string): BrowserLoadError { - return { - code, - description, - validatedUrl: redactKagiSessionToken(rawUrl) - } - } - - notifyCertificateFailureChanged( - webContentsId: number, - failure: BrowserCertificateFailure | null, - navigationUrl?: string - ): void { - if (failure && navigationUrl) { - const loadError = this.buildLoadError(failure.errorCode ?? -1, failure.error, navigationUrl) - this.loadErrorsByGuestId.set(webContentsId, loadError) - this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - if (!browserPageId) { - return - } - if (this.offscreenGuestIds.has(webContentsId)) { - this.notifyBrowserGuestStateChanged(webContentsId) - return - } - const renderer = this.resolveRendererForBrowserTab(browserPageId) - renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) - } - - private notifyBrowserGuestStateChanged(webContentsId: number): void { - if (!this.offscreenGuestIds.has(webContentsId)) { - return - } - const browserPageId = this.tabIdByWebContentsId.get(webContentsId) - const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null - if (worktreeId) { - // Why: runs inside an Electron guest event dispatch, so an escaping throw would be a fatal uncaught exception. - try { - this.browserGuestStateChangedListener?.(worktreeId) - } catch (error) { - console.error('[browser-manager] browserGuestStateChanged listener failed', error) - } - } - } - - notifyPermissionDenied(args: { - guestWebContentsId: number - permission: string - rawUrl: string - }): void { - this.forwardOrQueuePermissionDenied(args.guestWebContentsId, { - permission: args.permission, - origin: safeOrigin(args.rawUrl) - }) - } - - handleGuestWillDownload(args: { guestWebContentsId: number; item: Electron.DownloadItem }): void { - const { guestWebContentsId, item } = args - const downloadId = randomUUID() - const requestedFilename = (() => { - try { - return item.getFilename() || 'download' - } catch { - return 'download' - } - })() - const totalBytes = (() => { - try { - const total = item.getTotalBytes() - return total > 0 ? total : null - } catch { - return null - } - })() - const mimeType = (() => { - try { - const mime = item.getMimeType() - return mime || null - } catch { - return null - } - })() - const origin = (() => { - try { - return safeOrigin(item.getURL()) - } catch { - return 'unknown' - } - })() - - // Why: a client-hosted page's bytes belong on the remote workspace, so main stages them itself - // instead of reserving a name in the desktop Downloads folder. A popup downloads to its - // opener's page: the popup itself is a client-local transient with no logical page of its own. - const ownerContext = this.resolvePopupOwnerContext(guestWebContentsId) - const decision = routeBrowserClientDownload({ - guestWebContentsId: ownerContext?.rootGuestWebContentsId ?? guestWebContentsId - }) - const clientRoute = decision.kind === 'remote' ? decision.route : null - const destination = (() => { - if (clientRoute) { - return { - filename: requestedFilename, - savePath: clientRoute.stagingPath, - reservationKey: null - } - } - // Why: a client-hosted download with no resolvable remote destination is canceled rather than - // written to this desktop's Downloads folder. - if (decision.kind === 'blocked') { - return null - } - try { - return browserDownloadDestinationReservations.reserve(requestedFilename) - } catch (error) { - console.error('[browser-download] Failed to choose download destination:', error) - return null - } - })() - - const fallbackSavePath = destination?.savePath ?? '' - - const download: ActiveDownload = { - downloadId, - guestWebContentsId, - browserTabId: null, - rendererWebContentsId: null, - origin, - filename: destination?.filename ?? requestedFilename, - totalBytes, - mimeType, - item, - savePath: fallbackSavePath, - reservationKey: destination?.reservationKey ?? null, - clientRoute, - remoteDestination: undefined, - receivedBytes: 0, - transientState: null, - terminalEvent: null, - startedSent: false, - cleanup: null - } - this.downloadsById.set(downloadId, download) - - const browserTabId = ownerContext?.browserTabId ?? null - if (browserTabId) { - this.bindDownloadToTab(downloadId, browserTabId) - } else { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) ?? [] - pending.push(downloadId) - this.pendingDownloadIdsByGuestId.set(guestWebContentsId, pending) - } - - if (!destination) { - this.finishDownloadInternal( - downloadId, - 'failed', - decision.kind === 'blocked' - ? 'Could not save the download to the remote workspace.' - : 'Could not choose a Downloads file name.' - ) - try { - item.cancel() - } catch { - // Why: with no destination Chromium must not keep writing invisibly; cancel is best-effort after surfacing the failure. - } - return - } - - try { - item.setSavePath(destination.savePath) - } catch (error) { - console.error('[browser-download] Failed to set download destination:', error) - this.finishDownloadInternal(downloadId, 'failed', 'Failed to set download destination.') - try { - item.cancel() - } catch { - // Why: a failed setSavePath can leave Electron partially finalized; cancel is best-effort after the UI is made terminal. - } - return - } - - const updatedHandler = (_event: Electron.Event, state: 'progressing' | 'interrupted'): void => { - download.receivedBytes = this.getDownloadReceivedBytes(download.item) - download.transientState = state - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state - }) - } - const doneHandler = (_event: Electron.Event, state: BrowserDownloadDoneState): void => { - const status: BrowserDownloadFinishedEvent['status'] = - state === 'completed' ? 'completed' : state === 'cancelled' ? 'canceled' : 'failed' - const failure = - status === 'failed' - ? state === 'interrupted' - ? 'Download was interrupted.' - : 'Download failed.' - : null - if (download.clientRoute) { - void this.settleClientHostedDownload(download, status, failure) - return - } - this.finishDownloadInternal(download.downloadId, status, failure) - } - download.cleanup = (): void => { - try { - download.item.off('updated', updatedHandler) - download.item.off('done', doneHandler) - } catch { - // Why: a completed DownloadItem may already be finalized; keep cleanup best-effort so teardown never crashes main. - } - } - item.on('updated', updatedHandler) - item.once('done', doneHandler) - - if (browserTabId) { - this.sendDownloadStarted(downloadId) - } - } - - cancelDownload(args: { downloadId: string; senderWebContentsId: number }): boolean { - const download = this.downloadsById.get(args.downloadId) - if (!download || download.rendererWebContentsId !== args.senderWebContentsId) { - return false - } - this.cancelDownloadInternal(args.downloadId, 'Canceled.') - return true - } - - // Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup. - async openDevTools(browserTabId: string): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - guest.openDevTools({ mode: 'detach' }) - return true - } - - // Why: emulate viewport via CDP; never detach the debugger here or per-guest overrides (addScriptToEvaluateOnNewDocument) are cleared. - async setViewportOverride( - browserTabId: string, - override: BrowserViewportOverride | null - ): Promise { - // Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins. - const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetViewportOverrideImpl(browserTabId, override)) - this.viewportOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - // Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization. - if (this.viewportOpsByTabId.get(browserTabId) === next) { - this.viewportOpsByTabId.delete(browserTabId) - } - } - } - - async setAnnotationViewportBridge( - browserTabId: string, - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - const prev = this.annotationViewportBridgeOpsByTabId.get(browserTabId) ?? Promise.resolve() - const next = prev - .catch(() => {}) - .then(() => this.doSetAnnotationViewportBridgeImpl(options, resolveGuest)) - this.annotationViewportBridgeOpsByTabId.set(browserTabId, next) - try { - return await next - } finally { - if (this.annotationViewportBridgeOpsByTabId.get(browserTabId) === next) { - this.annotationViewportBridgeOpsByTabId.delete(browserTabId) - } - } - } - - // Why the caller resolves the guest: the same bridge serves browsing pages and workspace - // documents, which live in different halves of the page registry. - // Why a resolver and not the guest itself: this op may have waited behind another one, and a - // cross-process navigation meanwhile swaps the tab's contents without destroying the old one — - // injecting into the guest the request named would bridge a page nobody is looking at. - // Why no tab id: with teardown gone this reaches only the guest the resolver hands back, and - // taking an id it cannot act on would invite the next reader to act on it. - private async doSetAnnotationViewportBridgeImpl( - options: BrowserAnnotationViewportBridgeOptions, - resolveGuest: () => Electron.WebContents | null - ): Promise { - // Why no teardown here: the resolver already unregisters a page whose guest died, and the only - // case it uniquely leaves is an ownership mismatch on a healthy page — where tearing down would - // cancel that page's in-flight downloads and grabs over a request that was merely misaddressed. - const guest = resolveGuest() - if (!guest || guest.isDestroyed()) { - return false - } - - try { - // Why: run the scroll bridge in an isolated world so page scripts can't read the per-tab token or tamper with it. - await guest.executeJavaScriptInIsolatedWorld( - BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID, - [{ code: buildBrowserAnnotationViewportBridgeScript(options) }], - false - ) - return true - } catch { - return false - } - } - - private async doSetViewportOverrideImpl( - browserTabId: string, - override: BrowserViewportOverride | null - ): Promise { - const webContentsId = this.webContentsIdByTabId.get(browserTabId) - if (!webContentsId) { - return false - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return false - } - - try { - if (!guest.debugger.isAttached()) { - guest.debugger.attach('1.3') - } - } catch (err) { - // Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable. - console.warn('[browser-manager] setViewportOverride: failed to attach debugger', { - browserTabId, - webContentsId, - error: err instanceof Error ? err.message : String(err) - }) - return false - } - - const dbg = guest.debugger - try { - if (override) { - await dbg.sendCommand('Emulation.setDeviceMetricsOverride', { - width: override.width, - height: override.height, - deviceScaleFactor: override.deviceScaleFactor, - mobile: override.mobile - }) - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: override.mobile, - maxTouchPoints: override.mobile ? 5 : 0 - }) - // Why: viewport sizing must not override a profile's explicit native-UA identity. - if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Navigation must see the preset intent while the final CDP command is in flight. - this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, override.mobile) - } - } else { - await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) - await dbg.sendCommand('Emulation.setTouchEmulationEnabled', { - enabled: false, - maxTouchPoints: 0 - }) - const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) - // A navigation after this point must not re-install the override behind the clear. - this.viewportUaOverrideMobileByTabId.delete(browserTabId) - try { - if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) { - const url = this.resolveTabNavigationUrl(guest) - const restored = await this.applyAuthUserAgentOverrideOverCdp( - guest, - false, - url, - isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent() - ) - if (!restored) { - throw new Error('Failed to preserve auth user agent') - } - } else { - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - } - } catch (error) { - if (trackedMobile !== undefined) { - this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) - } - throw error - } - } - return true - } catch { - return false - } - } - - // --- Browser Context Grab — main-owned operations --- - - /** Validate that the sender owns browserTabId; returns the guest WebContents or null. */ - /** - * The guest a request from `senderWebContentsId` may act on, across both halves of the page - * registry. This is the only door taught about workspace-document guests: they are kept out of - * the browsing maps entirely, so page management, agent commands, download routing and - * certificate attribution all miss them without a guard of their own — and a reader who opens a - * tool on the document in front of them still gets an answer. - */ - getAuthorizedGuest( - browserTabId: string, - senderWebContentsId: number - ): Electron.WebContents | null { - const docGuest = getWorkspaceDocPageGuest(browserTabId, senderWebContentsId) - if (docGuest) { - return docGuest - } - const registeredRenderer = this.rendererWebContentsIdByTabId.get(browserTabId) - if (registeredRenderer == null || registeredRenderer !== senderWebContentsId) { - return null - } - const guestId = this.webContentsIdByTabId.get(browserTabId) - if (guestId == null) { - return null - } - const guest = webContents.fromId(guestId) - if (!guest || guest.isDestroyed()) { - // Why: a stale guest must clear every per-tab registry entry, not just the WebContents maps. - this.unregisterGuest(browserTabId) - return null - } - return guest - } - - /** Returns true if a grab operation is currently active for this tab. */ - hasActiveGrabOp(browserTabId: string): boolean { - return this.grabSessionController.hasActiveGrabOp(browserTabId) - } - - /** Enable/disable grab mode for a tab: on enable inject the overlay runtime, on disable cancel any active grab op. */ - async setGrabMode( - browserTabId: string, - enabled: boolean, - guest: Electron.WebContents - ): Promise { - if (!enabled) { - const hadActiveGrabOp = this.hasActiveGrabOp(browserTabId) - this.cancelGrabOp(browserTabId, 'user') - if (hadActiveGrabOp) { - return true - } - try { - await guest.executeJavaScript(buildGuestOverlayScript('teardown')) - return true - } catch { - return false - } - } - // Why: inject the overlay runtime eagerly on arm so the hover UI appears instantly; re-injection is idempotent/safe. - try { - await guest.executeJavaScript(buildGuestOverlayScript('arm')) - return true - } catch { - return false - } - } - - /** - * Await a single grab selection on the given tab; resolves once on click, cancel, or error. - * - * Why in-guest: before-input-event fires only for keyboard (not mouse) on guests, so the overlay hit-catcher consumes the click. - */ - awaitGrabSelection( - browserTabId: string, - opId: string, - guest: Electron.WebContents - ): Promise { - return this.grabSessionController.awaitGrabSelection(browserTabId, opId, guest) - } - - /** Cancel an active grab operation for the given tab. */ - cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void { - this.grabSessionController.cancelGrabOp(browserTabId, reason) - } - - /** Capture a screenshot of the guest surface, optionally cropped to the given CSS-pixel rect. */ - async captureSelectionScreenshot( - _browserTabId: string, - rect: BrowserGrabRect, - guest: Electron.WebContents - ): Promise { - return captureGrabSelectionScreenshot(rect, guest) - } - - /** Extract the hovered element's payload without disrupting the active grab overlay/awaitClick listener. */ - async extractHoverPayload( - _browserTabId: string, - guest: Electron.WebContents - ): Promise { - try { - const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover')) - if (!rawPayload || typeof rawPayload !== 'object') { - return null - } - return clampGrabPayload(rawPayload) - } catch { - return null - } - } - - private setupContextMenu(browserTabId: string, guest: Electron.WebContents): void { - this.contextMenuCleanupByTabId.set( - browserTabId, - setupGuestContextMenu({ - browserTabId, - guest, - resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId) - }) - ) - } - - // Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works. - private setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.grabShortcutCleanupByTabId.delete(browserTabId) - } - - this.grabShortcutCleanupByTabId.set( - browserTabId, - setupGrabShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId), - getKeybindings: () => this.settingsResolver?.().keybindings - }) - ) - } - - // Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts. - private setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.shortcutForwardingCleanupByTabId.delete(browserTabId) - } - - this.shortcutForwardingCleanupByTabId.set( - browserTabId, - setupGuestShortcutForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId), - shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false, - isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false, - getKeybindings: () => this.settingsResolver?.().keybindings, - resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null, - resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null - }) - ) - } - - private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { - const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) - if (previousCleanup) { - previousCleanup() - this.mouseWheelZoomCleanupByTabId.delete(browserTabId) - } - - this.mouseWheelZoomCleanupByTabId.set( - browserTabId, - setupGuestMouseWheelZoomForwarding({ - browserTabId, - guest, - resolveRenderer: (tabId) => - resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId) - }) - ) - } - - private forwardOrQueueGuestLoadFailure( - guestWebContentsId: number, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) - if (!browserTabId) { - // Why: a failure can arrive before the tab is registered; queue by guest ID so registerGuest can replay it. - this.pendingLoadFailuresByGuestId.set(guestWebContentsId, loadError) - return - } - this.sendGuestLoadFailure(browserTabId, loadError) - } - - private forwardOrQueuePermissionDenied( - guestWebContentsId: number, - event: PendingPermissionEvent - ): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPermissionEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPermissionDenied(browserTabId, event) - } - - private flushPendingPermissionEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPermissionEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPermissionDenied(browserTabId, event) - } - } - - private sendPermissionDenied(browserTabId: string, event: PendingPermissionEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:permission-denied', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPermissionDeniedEvent) - } - - private forwardOrQueuePopupEvent(guestWebContentsId: number, event: PendingPopupEvent): void { - const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId) - if (!browserTabId) { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) ?? [] - pending.push(event) - if (pending.length > 5) { - pending.shift() - } - this.pendingPopupEventsByGuestId.set(guestWebContentsId, pending) - return - } - this.sendPopupEvent(browserTabId, event) - } - - private flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingPopupEventsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingPopupEventsByGuestId.delete(guestWebContentsId) - for (const event of pending) { - this.sendPopupEvent(browserTabId, event) - } - } - - private sendPopupEvent(browserTabId: string, event: PendingPopupEvent): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:popup', { - browserPageId: browserTabId, - ...event - } satisfies BrowserPopupEvent) - } - - private bindDownloadToTab(downloadId: string, browserTabId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - download.browserTabId = browserTabId - download.rendererWebContentsId = this.rendererWebContentsIdByTabId.get(browserTabId) ?? null - } - - private flushPendingDownloadRequests(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - if (!pending?.length) { - return - } - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - for (const downloadId of pending) { - this.bindDownloadToTab(downloadId, browserTabId) - this.flushDownloadSnapshot(downloadId) - } - } - - private flushDownloadSnapshot(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - this.sendDownloadStarted(downloadId) - if (download.receivedBytes > 0 || download.transientState) { - this.sendDownloadProgress(download.browserTabId, { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - receivedBytes: download.receivedBytes, - totalBytes: download.totalBytes, - state: download.transientState - }) - } - if (download.terminalEvent) { - this.sendDownloadFinished(download.browserTabId, { - ...download.terminalEvent, - browserPageId: download.browserTabId ?? undefined - }) - this.downloadsById.delete(downloadId) - } - } - - private sendDownloadStarted(downloadId: string): void { - const download = this.downloadsById.get(downloadId) - if (!download?.browserTabId) { - return - } - if (download.startedSent) { - return - } - const renderer = this.resolveRendererForBrowserTab(download.browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-requested', { - browserPageId: download.browserTabId, - downloadId: download.downloadId, - origin: download.origin, - filename: download.filename, - totalBytes: download.totalBytes, - mimeType: download.mimeType, - savePath: download.savePath, - status: 'downloading' - } satisfies BrowserDownloadRequestedEvent) - download.startedSent = true - } - - private sendDownloadProgress( - browserTabId: string | null, - payload: BrowserDownloadProgressEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-progress', payload) - } - - private sendDownloadFinished( - browserTabId: string | null, - payload: BrowserDownloadFinishedEvent - ): void { - if (!browserTabId) { - return - } - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - renderer.send('browser:download-finished', payload) - } - - private async settleClientHostedDownload( - download: ActiveDownload, - status: BrowserDownloadFinishedEvent['status'], - failure: string | null - ): Promise { - const route = download.clientRoute - if (!route) { - return - } - if (status !== 'completed') { - download.clientRoute = null - await route.abort().catch(() => undefined) - this.finishDownloadInternal(download.downloadId, status, failure) - return - } - try { - // Why: the route stays on the record for the whole commit, which spans many round trips -- a - // cancel arriving mid-stream has to find something to abort or the bytes land anyway. - const remoteDestination = await route.complete(download.filename) - download.clientRoute = null - download.remoteDestination = remoteDestination - // Why: the staged copy is deleted, so a client save path would name a file that no longer exists. - download.savePath = '' - this.finishDownloadInternal(download.downloadId, 'completed', null) - } catch (error) { - download.clientRoute = null - if (download.terminalEvent) { - // A cancel already reported the outcome; this rejection is that cancel taking effect. - return - } - console.error('[browser-download] Failed to save download to the remote workspace:', error) - this.finishDownloadInternal( - download.downloadId, - 'failed', - 'Could not save the download to the remote workspace.' - ) - } - } - - private cancelDownloadInternal(downloadId: string, reason: string): void { - const download = this.downloadsById.get(downloadId) - if (!download) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - const shouldSendCancel = !download.terminalEvent - - try { - download.item.cancel() - } catch { - // Why: cancel() can throw on an already-finalized item; best-effort since UI state is authoritative. - } - - if (shouldSendCancel) { - this.finishDownloadInternal(downloadId, 'canceled', reason || null) - return - } - - this.downloadsById.delete(downloadId) - } - - private finishDownloadInternal( - downloadId: string, - status: BrowserDownloadFinishedEvent['status'], - error: string | null - ): void { - const download = this.downloadsById.get(downloadId) - if (!download || download.terminalEvent) { - return - } - - if (download.cleanup) { - download.cleanup() - download.cleanup = null - } - browserDownloadDestinationReservations.release(download.reservationKey) - download.reservationKey = null - if (download.clientRoute) { - // Why: a cancel path can reach here before the relay settled; the staged copy must not survive. - void download.clientRoute.abort().catch(() => undefined) - download.clientRoute = null - } - const event: BrowserDownloadFinishedEvent = { - browserPageId: download.browserTabId ?? undefined, - downloadId: download.downloadId, - status, - savePath: download.savePath || null, - ...(download.remoteDestination ? { remoteDestination: download.remoteDestination } : {}), - error - } - download.terminalEvent = event - if (download.browserTabId) { - this.sendDownloadStarted(downloadId) - this.sendDownloadFinished(download.browserTabId, event) - this.downloadsById.delete(downloadId) - } - } - - private cancelPendingDownloadsForGuest(guestWebContentsId: number): void { - const pending = this.pendingDownloadIdsByGuestId.get(guestWebContentsId) - this.pendingDownloadIdsByGuestId.delete(guestWebContentsId) - if (!pending?.length) { - return - } - for (const downloadId of pending) { - const download = this.downloadsById.get(downloadId) - if (!download) { - continue - } - if (download.terminalEvent) { - this.downloadsById.delete(downloadId) - continue - } - this.cancelDownloadInternal(downloadId, 'Browser page closed before download could be shown.') - const afterCancel = this.downloadsById.get(downloadId) - if (afterCancel?.terminalEvent && !afterCancel.browserTabId) { - this.downloadsById.delete(downloadId) - } - } - } - - private getDownloadReceivedBytes(item: Electron.DownloadItem): number { - try { - return Math.max(0, item.getReceivedBytes()) - } catch { - return 0 - } - } - - private flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void { - const pending = this.pendingLoadFailuresByGuestId.get(guestWebContentsId) - if (!pending) { - return - } - this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) - this.sendGuestLoadFailure(browserTabId, pending) - } - - private sendGuestLoadFailure( - browserTabId: string, - loadError: { code: number; description: string; validatedUrl: string } - ): void { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return - } - - // Why: redact Kagi session tokens before the renderer persists validatedUrl to disk. - renderer.send('browser:guest-load-failed', { - browserPageId: browserTabId, - loadError: { - ...loadError, - validatedUrl: redactKagiSessionToken(loadError.validatedUrl) - } - }) - } - - private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return false - } - const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) - if (!normalizedUrl || normalizedUrl === ORCA_BROWSER_BLANK_URL) { - return false - } - // Why: only the renderer owns Orca's worktree/tab model; main forwards a validated URL, never letting guest content mutate it. - renderer.send('browser:open-link-in-orca-tab', { - browserPageId: browserTabId, - url: normalizedUrl - }) - return true - } -} +export class BrowserManager extends BrowserManagerFinal {} export const browserManager = new BrowserManager() export const browserCertificateTrustController = new BrowserCertificateTrustController({ diff --git a/src/main/codex-accounts/runtime-home-service-auth-core.ts b/src/main/codex-accounts/runtime-home-service-auth-core.ts new file mode 100644 index 00000000000..f24aa12d0c7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-core.ts @@ -0,0 +1,183 @@ +import { existsSync, chmodSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from './fs-utils' +import type { + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthProvenance +} from './runtime-home-service-types' +import { CodexRuntimeHomeLegacyMigration } from './runtime-home-service-legacy-migration' + +export abstract class CodexRuntimeHomeAuthCore extends CodexRuntimeHomeLegacyMigration { + protected readSystemDefaultAuth(): string | null { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null + } + + protected writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean { + // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. + const runtimeAuthPath = this.getRuntimeAuthPath() + if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { + return false + } + const provenance: CodexSharedRuntimeAuthProvenance = + owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner + const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) + if (runtimeAuthComparison === null) { + // Why: an unreadable runtime auth.json may hold a token Codex rotated a + // moment ago. Treating "could not read" as "differs" sent execution to the + // unconditional write below, consuming that rotation and logging the user + // out for good. Refuse; the next sync retries. + return false + } + const runtimeAuthAlreadyMatches = runtimeAuthComparison + if ( + runtimeAuthAlreadyMatches && + this.sharedRuntimeAuthProvenanceMatches( + this.resolveSharedRuntimeAuthProvenanceStatus(), + provenance + ) + ) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.clearRuntimeLogoutMarker() + return true + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson: contents + }) + if (runtimeAuthAlreadyMatches) { + this.ensureOwnerOnlyMode(runtimeAuthPath) + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + const replaced = options + ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { + mode: 0o600 + }) + : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) + if (!replaced) { + return false + } + this.lastWrittenAuthJson = contents + this.persistSharedRuntimeAuthProvenance(provenance) + this.clearRuntimeLogoutMarker() + return true + } + + protected writeRuntimeAuthAtPath(authPath: string, contents: string): void { + if (this.fileContentsEqual(authPath, contents)) { + this.ensureOwnerOnlyMode(authPath) + return + } + mkdirSync(dirname(authPath), { recursive: true }) + writeFileAtomically(authPath, contents, { mode: 0o600 }) + } + + /** + * `true`/`false` only when the bytes were actually read; `null` when the file + * could not be read at all. The old `catch { return false }` reported "these + * differ" for a file nobody could open, and every caller reads that as + * permission to write. + */ + protected compareFileContents(targetPath: string, contents: string): boolean | null { + try { + return readFileSync(targetPath, 'utf-8') === contents + } catch (error) { + return isDefinitiveAbsence(error) ? false : null + } + } + + protected fileContentsEqual(targetPath: string, contents: string): boolean { + return this.compareFileContents(targetPath, contents) === true + } + + protected fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean { + if (expectedContents === null) { + // Why: `!existsSync` does report `true` for a locked file, but this branch + // is not where that matters — the write it guards is + // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the + // real file and refuses on its own. Classifying here would be a guard no + // test can drive. + return !existsSync(targetPath) + } + return this.fileContentsEqual(targetPath, expectedContents) + } + + protected ensureOwnerOnlyMode(targetPath: string): void { + if (process.platform === 'win32') { + return + } + try { + chmodSync(targetPath, 0o600) + } catch { + /* Best effort: the next atomic write will set the restrictive mode. */ + } + } + + protected getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { + const marker = this.readRuntimeLogoutMarker() + if (!marker) { + return { kind: 'missing' } + } + const systemDefaultAuthJson = this.readSystemDefaultAuth() + if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { + return { kind: 'applies' } + } + this.clearRuntimeLogoutMarker() + return { kind: 'system-default-changed', systemDefaultAuthJson } + } + + protected persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { + const marker: CodexRuntimeLogoutMarker = { + systemDefaultAuthJson, + loggedOutAt: Date.now() + } + writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { + mode: 0o600 + }) + } + + protected readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown + } catch { + return null + } + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + !('systemDefaultAuthJson' in parsed) || + !('loggedOutAt' in parsed) + ) { + return null + } + const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } + if ( + (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || + typeof marker.loggedOutAt !== 'number' + ) { + return null + } + return marker as CodexRuntimeLogoutMarker + } + + protected clearRuntimeLogoutMarker(): void { + rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-provenance.ts b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts new file mode 100644 index 00000000000..d4018741405 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-provenance.ts @@ -0,0 +1,224 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { writeFileAtomically } from './fs-utils' +import type { + CodexSharedRuntimeAuthPendingProvenance, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import { CodexRuntimeHomeAuthCore } from './runtime-home-service-auth-core' + +export abstract class CodexRuntimeHomeAuthProvenance extends CodexRuntimeHomeAuthCore { + protected persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void { + writeFileAtomically( + this.getSharedRuntimeAuthProvenancePath(), + `${JSON.stringify(provenance, null, 2)}\n`, + { mode: 0o600 } + ) + } + + protected markSharedRuntimeAuthManaged(accountId: string): void { + const status = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + status.kind === 'committed' && + status.provenance.owner === 'managed' && + status.provenance.accountId === accountId + ) { + return + } + const runtimeAuthJson = this.readRuntimeAuthForProvenance() + const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) + const provenance: CodexSharedRuntimeAuthProvenance = { + owner: 'managed', + accountId, + ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) + } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: provenance, + runtimeAuthJson + }) + if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { + this.persistSharedRuntimeAuthProvenance(provenance) + } + } + + protected getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null { + if (status.kind !== 'committed') { + return null + } + const baseline = + status.provenance.owner === 'system-default' + ? { authJson: status.provenance.authJson } + : status.provenance.systemDefaultBaseline + return baseline && runtimeAuthJson === baseline.authJson ? baseline : null + } + + protected restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null { + const baseline = provenance.systemDefaultBaseline + if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + const restored = { owner: 'system-default' as const, authJson: baseline.authJson } + this.persistSharedRuntimeAuthProvenance({ + owner: 'pending', + next: restored, + runtimeAuthJson: baseline.authJson + }) + if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { + return null + } + this.persistSharedRuntimeAuthProvenance(restored) + return restored + } + + protected sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean { + if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { + return false + } + return expected.owner === 'system-default' + ? status.provenance.owner === 'system-default' && + status.provenance.authJson === expected.authJson + : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId + } + + protected resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { + const provenancePath = this.getSharedRuntimeAuthProvenancePath() + if (!existsSync(provenancePath)) { + return { kind: 'missing' } + } + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown + } catch { + return { kind: 'fenced' } + } + const committed = this.parseSharedRuntimeAuthProvenance(parsed) + if (committed) { + return { kind: 'committed', provenance: committed } + } + const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) + if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { + return { kind: 'fenced' } + } + try { + this.persistSharedRuntimeAuthProvenance(pending.next) + return { kind: 'committed', provenance: pending.next } + } catch { + return { kind: 'fenced' } + } + } + + protected parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const provenance = value as Record + if ( + provenance.owner === 'system-default' && + (typeof provenance.authJson === 'string' || provenance.authJson === null) + ) { + return { owner: 'system-default', authJson: provenance.authJson } + } + if ( + provenance.owner !== 'managed' || + typeof provenance.accountId !== 'string' || + provenance.accountId.length === 0 + ) { + return null + } + const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) + if ('systemDefaultBaseline' in provenance && !baseline) { + return null + } + return { + owner: 'managed', + accountId: provenance.accountId, + ...(baseline ? { systemDefaultBaseline: baseline } : {}) + } + } + + protected parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const baseline = value as Record + return typeof baseline.authJson === 'string' || baseline.authJson === null + ? { authJson: baseline.authJson } + : null + } + + protected parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const pending = value as Record + const next = this.parseSharedRuntimeAuthProvenance(pending.next) + return pending.owner === 'pending' && + next && + (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) + ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } + : null + } + + protected readRuntimeAuthForProvenance(): string | null { + try { + return readFileSync(this.getRuntimeAuthPath(), 'utf-8') + } catch { + return null + } + } + + protected readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { + let rawContents: string + try { + rawContents = readFileSync(snapshotPath, 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(rawContents) as unknown + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + 'authJson' in parsed && + (typeof (parsed as { authJson: unknown }).authJson === 'string' || + (parsed as { authJson: unknown }).authJson === null) + ) { + return parsed as CodexSystemDefaultSnapshot + } + // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + !('authJson' in parsed) + ) { + return { authJson: rawContents } + } + } catch { + return null + } + return null + } + + clearSystemDefaultSnapshot(): void { + rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts new file mode 100644 index 00000000000..d0fa50fa5ec --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync-identity.ts @@ -0,0 +1,37 @@ +import { codexAuthIsFresher } from './codex-auth-identity' + +function readCodexLastRefresh(authJson: string): number | null { + try { + const parsed = JSON.parse(authJson) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const value = (parsed as Record).last_refresh + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null + } + if (typeof value !== 'string' || !value.trim()) { + return null + } + const timestamp = Date.parse(value) + return Number.isFinite(timestamp) ? timestamp : null + } catch { + return null + } +} + +export function codexAuthIsMonotonicallyFresher( + candidateAuthJson: string, + baselineAuthJson: string +): boolean { + const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) + const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) + if (candidateLastRefresh !== null || baselineLastRefresh !== null) { + return ( + candidateLastRefresh !== null && + baselineLastRefresh !== null && + candidateLastRefresh > baselineLastRefresh + ) + } + return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) +} diff --git a/src/main/codex-accounts/runtime-home-service-auth-sync.ts b/src/main/codex-accounts/runtime-home-service-auth-sync.ts new file mode 100644 index 00000000000..3cb06cac9e7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-auth-sync.ts @@ -0,0 +1,292 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { removeFileAtomicallyIfUnchanged, writeFileAtomically } from './fs-utils' +import { CodexRuntimeHomeLaunch } from './runtime-home-service-launch' +import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' + +export abstract class CodexRuntimeHomeAuthSync extends CodexRuntimeHomeLaunch { + protected captureSystemDefaultSnapshot(options: { force: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + if (!options.force && existsSync(snapshotPath)) { + return + } + + const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') + const snapshot: CodexSystemDefaultSnapshot = { + authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null + } + writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } + + protected syncRuntimeAuthWithSystemDefault(): void { + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (!existsSync(runtimeAuthPath)) { + return + } + + try { + const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') + const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + if (provenance?.owner === 'managed') { + this.captureSystemDefaultSnapshot({ force: true }) + if (!existsSync(systemDefaultAuthPath)) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { + owner: 'system-default' + }) + return + } + const { + ownershipProven: systemDefaultOwnershipProven, + mirroredAuthJson: mirroredSystemDefaultAuth + } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) + if (!existsSync(systemDefaultAuthPath)) { + if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) + } + return + } + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + if (runtimeAuth === systemDefaultAuth) { + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + if ( + systemDefaultOwnershipProven && + mirroredSystemDefaultAuth !== null && + systemDefaultAuth === mirroredSystemDefaultAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) + ) { + // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. + this.writeSystemDefaultAuth(runtimeAuth) + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) + return + } + // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) + } + } + + protected syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { + if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = false + return + } + const runtimeAuthPath = this.getRuntimeAuthPath() + try { + let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'managed' + ) { + const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( + provenanceStatus.provenance + ) + if (restoredProvenance) { + provenanceStatus = { kind: 'committed', provenance: restoredProvenance } + } + } + if ( + provenanceStatus.kind === 'fenced' || + (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') + ) { + return + } + const systemAuth = this.readSystemDefaultAuth() + if (!existsSync(runtimeAuthPath)) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const knownSystemAuthBaseline = + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' + ? provenanceStatus.provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson) + : undefined + if (systemAuth === null) { + if ( + provenanceStatus.kind === 'committed' && + provenanceStatus.provenance.owner === 'system-default' && + provenanceStatus.provenance.authJson === null && + logoutMarkerStatus.kind === 'applies' && + snapshot?.authJson === null + ) { + this.lastWrittenAuthJson = null + return + } + // Why: commit a crashed logout before a managed transition can discard its recovery baseline. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if ( + logoutMarkerStatus.kind === 'system-default-changed' || + (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) + ) { + const replaced = this.writeRuntimeAuth( + systemAuth, + { + owner: 'system-default' + }, + { expectedContents: null } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } + return + } + const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') + const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const knownSharedAuth = + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) + : null + // Why: only bytes Orca can prove it wrote belong to the compatibility + // mirror; retained Codex or a managed transition owns every other value. + if (knownSharedAuth === null) { + return + } + const sharedAuthOwnedBySystemDefault = + runtimeAuthBeforeSync === knownSharedAuth || + (provenance?.owner === 'system-default' && + systemAuth === null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) + if (!sharedAuthOwnedBySystemDefault) { + return + } + if (systemAuth === null) { + removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) + if (existsSync(runtimeAuthPath)) { + this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) + return + } + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + return + } + if (runtimeAuthBeforeSync !== knownSharedAuth) { + return + } + const replaced = this.writeRuntimeAuth( + systemAuth, + { owner: 'system-default' }, + { expectedContents: runtimeAuthBeforeSync } + ) + if (replaced) { + this.captureSystemDefaultSnapshot({ force: true }) + } + } catch (error) { + console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) + } + } + + protected restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { + const snapshotPath = this.getSystemDefaultSnapshotPath() + const runtimeAuthPath = this.getRuntimeAuthPath() + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + if (existsSync(systemDefaultAuthPath)) { + const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') + this.captureSystemDefaultSnapshot({ force: true }) + this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) + return + } + + if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { + // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (options.detectExternalLogin) { + // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + + if (!existsSync(snapshotPath)) { + this.captureSystemDefaultSnapshot({ force: true }) + } + + const snapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!snapshot) { + console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') + rmSync(snapshotPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) + if (!refreshedSnapshot) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + if (refreshedSnapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) + return + } + if (snapshot.authJson === null) { + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) + return + } + this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) + } + + protected writeSystemDefaultAuth(contents: string): void { + const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') + mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) + writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) + this.ensureOwnerOnlyMode(systemDefaultAuthPath) + } + + protected clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { + // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. + rmSync(runtimeAuthPath, { force: true }) + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker() + this.lastWrittenAuthJson = null + this.persistSharedRuntimeAuthProvenance({ + owner: 'system-default', + authJson: null + }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts new file mode 100644 index 00000000000..9c983ecd5e3 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -0,0 +1,274 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { + getSystemCodexHomePath, + syncCodexGlobalInstructionsIntoManagedHome, + syncSystemCodexResourcesIntoManagedHome +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { + hasRecordedLegacySharedCodexPane, + getCodexPaneAccount, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' +import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexRateLimitHomeResolution } from './runtime-home-service-types' +import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home' + +export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome { + getHostCodexHomePathsForSessionDiscovery(): string[] { + const homes = [this.getRuntimeHomePath()] + if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { + // Why: nested Orca processes can retain an ambient managed CODEX_HOME. + // Per-account lanes no longer bridge real-home history into the shared + // mirror, so include the real root for both directly-routed host lanes. + homes.push(getSystemCodexHomePath()) + } + // Why: account-scoped rollouts live in each account's own home, including WSL. + for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { + homes.push(perAccountHome) + } + return homes.filter((home, index) => homes.indexOf(home) === index) + } + + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + + /** + * Same selection, but an unreadable home refuses instead of collapsing to + * `null`. Session resume must not read "no managed selection" out of a failed + * marker stat: another account's readable alias would then win the legacy + * rescan and the pane would resume under that account's credentials while the + * UI still shows this one (#STA-4422). + */ + resolveSelectedHostAccountCodexHomePathForResume(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (!selfContainedAccount) { + return null + } + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(selfContainedAccount) + return null + } + return resolved.homePath + } + + /** Trust-gates host previews without changing WSL routing or durable account state. */ + resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { + if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { + return { kind: 'ready', homePath: account.managedHomePath } + } + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' + ? { kind: 'ready', homePath: resolved.homePath } + : { kind: 'skip' } + } + + getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { + if (this.getSelfContainedManagedHostAccount()) { + return 'account-home' + } + return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' + } + + getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { + const settings = this.store.getSettings() + const homes = new Map() + for (const ptyId of ptyIds) { + const record = getCodexPaneAccount(ptyId) + if (!record || record.selectionKey !== 'host') { + continue + } + if ( + record.homeRoute === undefined || + record.homeRoute === 'shared-home' || + record.homeRoute === 'custom-home' + ) { + const homePath = this.getRuntimeHomePath() + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + continue + } + if (record.homeRoute !== 'account-home' || !record.accountId) { + continue + } + const account = settings.codexManagedAccounts.find( + (candidate) => candidate.id === record.accountId + ) + if (!account || this.getWslManagedHomePath(account)) { + continue + } + const homePath = this.getTrustedSelfContainedManagedHomePath(account) + if (homePath) { + homes.set(normalizeRuntimePathForComparison(homePath), homePath) + } + } + return [...homes.values()] + } + + // Why: the real-home hook installer flips this gate off when the trust-grant + // client reports the host incapable, keeping that host byte-identical to the + // managed lane instead of shipping status-blind panes. + protected realHomeLaneGate: () => boolean = () => true + + setRealHomeLaneGate(gate: () => boolean): void { + this.realHomeLaneGate = gate + } + + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. + isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + !isShellStartupEnvProbeSupported() + ) { + return false + } + return !hasCustomCodexHomeOverrideForLaunch(launchEnv) + } + + isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { + return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() + } + + reconcileLegacySharedHomeForRetainedPanes(): void { + if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { + return + } + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + syncLegacySharedCodexConfigForRetainedPanes() + } + + syncActiveWslSelectionsBeforeRestart(): void { + if (process.platform !== 'win32') { + return + } + + const settings = this.store.getSettings() + for (const [selectedDistroKey, accountId] of Object.entries( + normalizeCodexRuntimeSelection(settings).wsl + )) { + if (!accountId) { + continue + } + const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) + if (!account || account.managedHomeRuntime !== 'wsl') { + continue + } + this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } + } + + protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + const distro = target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return null + } + const home = getWslHome(distro) + return home ? this.joinWslPath(home, '.codex') : null + } + + protected syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (!runtimeHomePath) { + return + } + const distro = + parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + return + } + const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemHomePath || systemHomePath === runtimeHomePath) { + return + } + // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. + syncCodexGlobalInstructionsIntoManagedHome({ + systemHomePath, + managedHomePath: runtimeHomePath + }) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath, + systemHomePath, + systemConfigDir: toLinuxPath(systemHomePath) + }) + } + + // Why: `null` is a real value here — it means "use the system-default lane". + // A skipped poll needs its own channel or the fetcher silently retargets the + // user's real ~/.codex (#STA-4422). + prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) + return { + kind: 'ready', + codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + } + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'owned') { + // Why: the quota fetch reads the account's own auth.json in place; no + // shared-home hot-swap or per-poll resource relink (that is launch prep). + return { kind: 'ready', codexHomePath: resolved.homePath } + } + if (resolved.kind === 'indeterminate') { + // Why: returning null here would NOT skip — the fetcher maps null to + // ~/.codex and would probe the user's real home with a token-refreshing + // app-server. Skip the poll outright and keep the selection. + return { kind: 'skip' } + } + this.clearSelfContainedManagedSelection(selfContainedAccount) + } + if (this.isHostSystemDefaultRealHome()) { + // Why: null lets the fetcher fall back to the main process's inherited + // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the + // managed home, restarting the background OAuth conflict (#5370), so + // pin this non-interactive lane to the native home explicitly. + if (hasRecordedLegacySharedCodexPane()) { + this.syncLegacySharedSystemDefaultAuthForRetainedPanes() + } + return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } + } + this.syncForCurrentSelection() + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-launch.ts b/src/main/codex-accounts/runtime-home-service-launch.ts new file mode 100644 index 00000000000..7eb1c77c759 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-launch.ts @@ -0,0 +1,138 @@ +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker' +import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing' + +export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting { + protected initializeLastSyncedState(): void { + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. + this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) + ? null + : normalizeCodexRuntimeSelection(settings).host + } + + /** + * Materializes the runtime home needed before launching the CLI. + * + * Historical session bridging is requested in the background so launch setup + * returns as soon as the active runtime home is ready. + */ + prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null { + if (target?.runtime === 'wsl') { + const wslTarget = this.resolveWslDefaultTarget(target) + const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) + this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) + const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath + } + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( + selfContainedAccount, + options?.unavailableManagedHomePath + ) + if (perAccountHome) { + return perAccountHome + } + // Why: only an untrusted home clears the selection; fall through to the + // system default without injecting a path Orca cannot prove it owns. + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: the system default runs Codex on the user's own ~/.codex. + // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; + // the retired mirror is refreshed only for pre-rollout PTYs. + this.reconcileLegacySharedHomeForRetainedPanes() + return null + } + this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) + this.syncForCurrentSelection(target, launchEnv) + syncSystemCodexResourcesIntoManagedHome() + syncSystemConfigIntoManagedCodexHome() + // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. + void startSystemCodexSessionBridgeInBackground( + {}, + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + return this.getRuntimeHomePath() + } + + beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} + ): boolean | null { + if ( + !this.isHostSystemDefaultSessionMigrationEligible() || + (!codexHomePath && !options.reattached) || + (codexHomePath && + normalizeRuntimePathForComparison(codexHomePath) !== + normalizeRuntimePathForComparison(this.getRuntimeHomePath())) + ) { + return null + } + // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. + return this.invalidateBackfillAfterManagedSystemDefaultLaunch( + options.reattached && !codexHomePath ? undefined : options.launchEnv + ) + } + + isHostSystemDefaultSessionMigrationEligible(): boolean { + return ( + normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && + !hasCustomCodexHomeOverrideForLaunch() + ) + } + + prepareHostSystemDefaultSessionMigrationPass( + scanDates: readonly CodexSessionBackfillDate[] = [] + ): boolean { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) + if ( + this.hostSystemDefaultSessionMigrationPending && + this.pendingHostSystemDefaultSessionMigrationTarget !== target + ) { + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true + this.pendingHostSystemDefaultSessionMigrationTarget = target + } + // Why: the launch creates rollouts for these dates; record them durably so a + // force-quit recovers a bounded window instead of re-walking all history. + const markerOwesFullScan = markCodexSessionBackfillMarkerPending( + paths.markerPath, + paths.systemSessionsRoot, + scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] + ) + // Why: the marker is the only place an overflowed pending window survives a + // restart, so its demand has to reach this pass rather than die in the file. + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan + return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan + } + + finishHostSystemDefaultSessionMigrationPass(): void { + this.hostSystemDefaultSessionMigrationPending = false + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + this.pendingHostSystemDefaultSessionMigrationTarget = null + } +} diff --git a/src/main/codex-accounts/runtime-home-service-legacy-migration.ts b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts new file mode 100644 index 00000000000..dcb337cac29 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-legacy-migration.ts @@ -0,0 +1,204 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync +} from 'node:fs' +import { dirname, extname, join, parse, relative } from 'node:path' +import { writeFileAtomically } from './fs-utils' +import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { CodexRuntimeHomePaths } from './runtime-home-service-paths' + +export abstract class CodexRuntimeHomeLegacyMigration extends CodexRuntimeHomePaths { + protected safeMigrateLegacySharedAuth(): void { + const settings = this.store.getSettings() + try { + migrateLegacySharedAuthToPerAccountHome({ + activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, + hostAccounts: settings.codexManagedAccounts.filter( + (account) => !this.getWslManagedHomePath(account) + ), + managedAccountsRoot: this.getManagedAccountsRoot(), + metadataDir: this.getRuntimeMetadataDir(), + sharedRuntimeHome: this.getRuntimeHomePath(), + systemCodexHome: getSystemCodexHomePath() + }) + } catch (error) { + // Why: an inconclusive identity, ownership, or filesystem result must + // leave the marker absent so the next startup can retry safely. + console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) + } + } + + protected safeMigrateLegacyManagedState(): void { + try { + this.migrateLegacyManagedStateIfNeeded() + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) + } + } + + protected safeMigrateLegacyActiveHomePointer(): void { + try { + const activeHomePath = this.getLegacyHostActiveHomePath() + if (!this.legacyActiveHomePathExists(activeHomePath)) { + return + } + this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) + } + } + + protected migrateLegacyManagedStateIfNeeded(): void { + if (existsSync(this.getMigrationMarkerPath())) { + return + } + + const managedHomes = this.getLegacyManagedHomes() + for (const managedHomePath of managedHomes) { + const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( + /[\\/]/ + )[0] + if (!accountId) { + continue + } + this.migrateLegacyHistory(managedHomePath) + this.migrateLegacySessions(managedHomePath, accountId) + } + + // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. + writeFileAtomically( + this.getMigrationMarkerPath(), + `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` + ) + } + + protected getLegacyManagedHomes(): string[] { + const managedAccountsRoot = this.getManagedAccountsRoot() + if (!existsSync(managedAccountsRoot)) { + return [] + } + + const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) + const managedHomes: string[] = [] + for (const entry of accountEntries) { + if (!entry.isDirectory()) { + continue + } + const managedHomePath = join(managedAccountsRoot, entry.name, 'home') + if (existsSync(join(managedHomePath, '.orca-managed-home'))) { + managedHomes.push(managedHomePath) + } + } + return managedHomes.sort() + } + + protected migrateLegacyHistory(managedHomePath: string): void { + const legacyHistoryPath = join(managedHomePath, 'history.jsonl') + if (!existsSync(legacyHistoryPath)) { + return + } + + const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') + const existingLines = existsSync(runtimeHistoryPath) + ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) + : [] + const mergedLines = [...existingLines] + const seenLines = new Set(existingLines) + for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { + if (!line || seenLines.has(line)) { + continue + } + seenLines.add(line) + mergedLines.push(line) + } + + if (mergedLines.length === 0) { + return + } + writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) + } + + protected migrateLegacySessions(managedHomePath: string, accountId: string): void { + const legacySessionsRoot = join(managedHomePath, 'sessions') + if (!existsSync(legacySessionsRoot)) { + return + } + + const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') + mkdirSync(runtimeSessionsRoot, { recursive: true }) + for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { + const relativePath = relative(legacySessionsRoot, legacyFilePath) + const runtimeFilePath = join(runtimeSessionsRoot, relativePath) + mkdirSync(dirname(runtimeFilePath), { recursive: true }) + if (!existsSync(runtimeFilePath)) { + copyFileSync(legacyFilePath, runtimeFilePath) + continue + } + + const legacyContents = readFileSync(legacyFilePath) + const runtimeContents = readFileSync(runtimeFilePath) + if (runtimeContents.equals(legacyContents)) { + continue + } + + const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) + copyFileSync(legacyFilePath, preservedPath) + this.appendMigrationDiagnostic({ + type: 'session-conflict', + accountId, + runtimeFilePath, + preservedPath + }) + } + } + + protected listFilesRecursively(rootPath: string): string[] { + const stat = statSync(rootPath) + if (!stat.isDirectory()) { + return [rootPath] + } + + const files: string[] = [] + for (const entry of readdirSync(rootPath, { withFileTypes: true })) { + const childPath = join(rootPath, entry.name) + if (entry.isDirectory()) { + this.appendListedFiles(files, this.listFilesRecursively(childPath)) + continue + } + if (entry.isFile()) { + files.push(childPath) + } + } + return files.sort() + } + + protected appendListedFiles(target: string[], source: readonly string[]): void { + // Why: tolerate directories larger than V8's argument limit for spread calls. + for (const filePath of source) { + target.push(filePath) + } + } + + protected getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { + const extension = extname(runtimeFilePath) + const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) + return `${basename}.orca-legacy-${accountId}${extension}` + } + + protected appendMigrationDiagnostic(record: Record): void { + const diagnosticsPath = this.getMigrationDiagnosticsPath() + try { + appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) + } catch (error) { + // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. + console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-managed-home.ts b/src/main/codex-accounts/runtime-home-service-managed-home.ts new file mode 100644 index 00000000000..789ab9e238b --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-managed-home.ts @@ -0,0 +1,270 @@ +import { join } from 'node:path' +import { + syncSystemCodexResourcesIntoManagedHome, + getSystemCodexHomePath, + resolveOrcaManagedCodexHomePath +} from '../codex/codex-home-paths' +import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' +import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' +import { + resolveHostCodexSessionSourceHome, + resolveWslCodexSessionSourceHome +} from '../codex/codex-session-source-home' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { normalizeCodexRuntimeSelection } from './runtime-selection' +import { + resolveHostCodexManagedHomeVerdict, + ManagedCodexHomeTemporarilyUnavailableError +} from './host-codex-managed-home-ownership' +import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' +import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' +import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' +import { getDefaultWslDistro } from '../wsl' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' +import type { CodexAccountSelectionTarget } from './runtime-selection' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeSync } from './runtime-home-service-sync' + +export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync { + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. + protected getSelfContainedManagedHostAccount(): CodexManagedAccount | null { + const settings = this.store.getSettings() + const account = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (!account || this.getWslManagedHomePath(account)) { + return null + } + return account + } + + // Why: session discovery must surface every account's own rollouts wherever they live. + protected getManagedAccountHomesForSessionDiscovery(): string[] { + const settings = this.store.getSettings() + const homes: string[] = [] + for (const account of settings.codexManagedAccounts) { + const wslHome = this.getWslManagedHomePath(account) + if (wslHome) { + homes.push(wslHome) + continue + } + const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) + if (trustedHome) { + homes.push(trustedHome) + } + } + return homes + } + + protected getManagedHostAccountHomesForSessionDiscovery(): string[] { + return this.getManagedAccountHomesForSessionDiscovery().filter( + (home) => parseWslUncPath(home) === null + ) + } + + protected prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: refuse the launch rather than silently falling through to the + // system default, which would run a different account behind a UI still + // showing this one. The selection stays put; a later read may succeed. + throw new ManagedCodexHomeTemporarilyUnavailableError() + } + if (resolved.kind === 'untrusted') { + this.clearSelfContainedManagedSelection(account) + return null + } + const perAccountHome = resolved.homePath + if ( + unavailableManagedHomePath && + normalizeRuntimePathForComparison(unavailableManagedHomePath) === + normalizeRuntimePathForComparison(perAccountHome) + ) { + const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) + if (absence.state !== 'present' && absence.durable) { + this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') + return null + } + // Why: a transient missing/unreadable auth.json is usually codex rotating + // it; keep the selection and launch — the CLI re-reads the settled file. + } + // Why: link the user's real ~/.codex resources and mirror config into THIS + // home (never symlinking into or mutating ~/.codex), so the per-account home + // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + syncSystemCodexResourcesIntoManagedHome(perAccountHome) + syncSystemConfigIntoManagedCodexHome({ + runtimeHomePath: perAccountHome, + systemHomePath: getSystemCodexHomePath() + }) + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return perAccountHome + } + + // Why: Codex's own `/resume` picker only lists rollouts under the launch + // CODEX_HOME, so a self-contained account home starts out with no history at + // all. Hardlink every other Orca-visible home's rollouts in — after launch, + // since history trees can be large — so switching accounts no longer hides + // the user's conversations. + protected startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { + void startCodexAccountSessionBridgeInBackground({ + targetCodexHomePath: perAccountHome, + sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() + }) + } + + protected getSelfContainedSessionBridgeSourceHomes(): string[] { + return [ + // Why: history-only override lets custom-CODEX_HOME users bridge from the + // home they actually record sessions in; falls back to the real ~/.codex. + resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), + // Why: path only — a per-account install must not materialize the mirror. + resolveOrcaManagedCodexHomePath(), + ...this.getManagedHostAccountHomesForSessionDiscovery() + ] + } + + // Why: the per-account home is both the launch CODEX_HOME and the credential + // store, so codex reads/refreshes auth.json in place — there is no shared-home + // hot-swap or token read-back to reconcile. A trusted home remains selected + // while Codex atomically replaces auth.json. + protected syncSelfContainedManagedSelection(account: CodexManagedAccount): void { + const resolved = this.resolveSelfContainedManagedHome(account) + if (resolved.kind === 'indeterminate') { + // Why: a sync runs on every app start, exactly when antivirus is busiest. + // An unreadable home must not deselect the account (#STA-4422). + return + } + const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null + if (perAccountHome) { + this.lastSyncedAccountId = account.id + this.lastHostAccountUsedSelfContainedHome = true + this.sharedAuthRefreshBlockedByManagedTransition = true + this.markSharedRuntimeAuthManaged(account.id) + // Why: selection runs well before the user restarts a pane, so history is + // already linked in by the time the newly launched Codex opens /resume. + this.startSelfContainedSessionBridgeForLaunch(perAccountHome) + return + } + this.clearSelfContainedManagedSelection(account) + } + + /** + * Why: an unreadable home and an untrustworthy one demand opposite responses. + * Only `untrusted` may clear the user's selection; `indeterminate` means we + * could not tell, so callers refuse the operation and leave durable state + * alone (#STA-4422). + */ + protected resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { + const verdict = resolveHostCodexManagedHomeVerdict({ + candidatePath: account.managedHomePath, + managedAccountsRoot: this.getManagedAccountsRoot(), + systemCodexHomePath: getSystemCodexHomePath(), + expectedAccountId: account.id + }) + if (verdict.kind === 'owned') { + // Preserve the persisted path spelling (notably /var vs /private/var on + // macOS) so injected CODEX_HOME stays stable across the rollout. + return { kind: 'owned', homePath: account.managedHomePath } + } + if (verdict.kind === 'untrusted') { + console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) + return { kind: 'untrusted' } + } + console.warn( + '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', + verdict.error + ) + return { kind: 'indeterminate' } + } + + /** Read-only callers that mutate nothing and simply skip an unusable home. */ + protected getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { + const resolved = this.resolveSelfContainedManagedHome(account) + return resolved.kind === 'owned' ? resolved.homePath : null + } + + protected clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason = 'home is invalid' + ): void { + console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) + const settings = this.store.getSettings() + if (normalizeCodexRuntimeSelection(settings).host !== account.id) { + return + } + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + this.lastSyncedAccountId = null + this.lastHostAccountUsedSelfContainedHome = false + } + + protected invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null { + const settings = this.store.getSettings() + if ( + normalizeCodexRuntimeSelection(settings).host !== null || + hasCustomCodexHomeOverrideForLaunch(launchEnv) + ) { + return null + } + if (!this.hostSystemDefaultSessionMigrationPending) { + const paths = resolveCodexSessionBackfillPaths( + resolveHostCodexSessionSourceHome(this.store.getSettings()) + ) + this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = + !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) + this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( + paths.systemSessionsRoot + ) + this.hostSystemDefaultSessionMigrationPending = true + } + return this.prepareHostSystemDefaultSessionMigrationPass() + } + + protected startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. + const systemCodexHomePath = + resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { + return + } + // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-paths.ts b/src/main/codex-accounts/runtime-home-service-paths.ts new file mode 100644 index 00000000000..87ef3cedeaa --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-paths.ts @@ -0,0 +1,187 @@ +import { + lstatSync, + mkdirSync, + readlinkSync, + renameSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { app } from 'electron' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { getOrcaManagedCodexHomePath, getOrcaUserDataPath } from '../codex/codex-home-paths' +import type { CodexMirroredHomeStatus } from './runtime-home-service-types' +import { CodexRuntimeHomeState } from './runtime-home-service-state' + +export abstract class CodexRuntimeHomePaths extends CodexRuntimeHomeState { + protected getRuntimeHomePath(): string { + return getOrcaManagedCodexHomePath() + } + + /** + * Resolves the managed home the config mirror actually targets for the + * current HOST selection, or null when no mirror runs for it. + * + * Read-only on purpose: unlike the launch and quota-fetch paths this prepares + * nothing and creates no directories, so surfacing sync health cannot alter + * the state it is reporting on. Returns null for the system default on the + * real-home lane, which runs Codex directly against ~/.codex — there is no + * mirror there, so there is nothing that can fall behind. + */ + getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) + if (resolved.kind === 'indeterminate') { + // Why: `null` here is a positive claim that no mirror exists, which the + // status channel reports as healthy. An unreadable home is not that. + return { kind: 'unavailable' } + } + return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } + } + if (this.isHostSystemDefaultRealHome()) { + return { kind: 'ready', homePath: null } + } + return { + kind: 'ready', + homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + } + } + + protected getRuntimeAuthPath(): string { + return join(this.getRuntimeHomePath(), 'auth.json') + } + + protected getSystemDefaultSnapshotPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') + } + + protected getRuntimeLogoutMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') + } + + protected getSharedRuntimeAuthProvenancePath(): string { + return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') + } + + protected getRuntimeMetadataDir(): string { + const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') + mkdirSync(metadataDir, { recursive: true }) + return metadataDir + } + + protected getLegacyHostActiveHomePath(): string { + return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') + } + + protected getMigrationMarkerPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-v1.json') + } + + protected getMigrationDiagnosticsPath(): string { + return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') + } + + protected getManagedAccountsRoot(): string { + return join(app.getPath('userData'), 'codex-accounts') + } + + protected repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { + if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { + return + } + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + return + } + + mkdirSync(runtimeHomePath, { recursive: true }) + mkdirSync(dirname(activeHomePath), { recursive: true }) + const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + try { + symlinkSync( + runtimeHomePath, + nextLinkPath, + process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() + ? 'junction' + : undefined + ) + try { + renameSync(nextLinkPath, activeHomePath) + } catch (error) { + if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { + throw error + } + this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) + renameSync(nextLinkPath, activeHomePath) + } + } finally { + this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) + } + } + + protected activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean { + try { + return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) + } catch { + return false + } + } + + protected linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean { + const resolvedLinkTarget = isAbsolute(linkTarget) + ? resolve(linkTarget) + : resolve(dirname(linkPath), linkTarget) + return resolvedLinkTarget === resolve(expectedTargetPath) + } + + protected legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { + try { + const stat = lstatSync(activeHomePath) + return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) + } catch { + return true + } + } + + protected legacyActiveHomePathExists(activeHomePath: string): boolean { + try { + lstatSync(activeHomePath) + return true + } catch { + return false + } + } + + protected removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { + try { + const stat = lstatSync(activeHomePath) + if (stat.isSymbolicLink()) { + unlinkSync(activeHomePath) + } else if (this.isWindowsReadableLink(activeHomePath)) { + rmdirSync(activeHomePath) + } + } catch { + // Missing or inaccessible temporary links are handled by the caller. + } + } + + protected isWindowsReadableLink(targetPath: string): boolean { + if (process.platform !== 'win32') { + return false + } + try { + readlinkSync(targetPath) + return true + } catch { + return false + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts new file mode 100644 index 00000000000..cfbbd147a95 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -0,0 +1,278 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { Store } from '../persistence' +import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' +import type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution, + CodexReadBackMatch, + CodexReadBackResult, + CodexRuntimeLogoutMarker, + CodexRuntimeLogoutMarkerStatus, + CodexSharedRuntimeAuthPendingProvenance, + CodexSelfContainedManagedHomeResolution, + CodexSharedRuntimeAuthProvenance, + CodexSharedRuntimeAuthProvenanceFile, + CodexSharedRuntimeAuthProvenanceStatus, + CodexSystemDefaultSnapshot +} from './runtime-home-service-types' +import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' +import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' +import type { CodexAccountSelectionTarget } from './runtime-selection' + +/** Shared state and method contracts for the focused runtime-home layers. */ +export abstract class CodexRuntimeHomeState { + // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. + protected lastSyncedAccountId: string | null = null + // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). + protected lastWrittenAuthJson: string | null = null + // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. + protected readonly lastWrittenWslAuthJsonByDistro = new Map() + protected readonly lastSyncedWslAccountIdByDistro = new Map() + protected readonly wslRuntimeHomePathByDistro = new Map() + protected skipNextReadBackForAccountId: string | null = null + // Why: a managed host account refreshes auth in its own home. Remember that provenance so a later deselect never adopts stale shared bytes. + protected lastHostAccountUsedSelfContainedHome = false + protected sharedAuthRefreshBlockedByManagedTransition = false + // Why: transient auth.json read/parse failures must not deselect an account. + protected readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() + protected hostSystemDefaultSessionMigrationPending = false + protected pendingHostSystemDefaultSessionMigrationNeedsFullScan = false + protected pendingHostSystemDefaultSessionMigrationTarget: string | null = null + + protected constructor(protected readonly store: Store) {} + + protected abstract initializeLastSyncedState(): void + abstract prepareForCodexLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + options?: { unavailableManagedHomePath?: string } + ): string | null + abstract beginHostSystemDefaultSessionMigrationLaunch( + codexHomePath: string | null, + options?: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } + ): boolean | null + abstract isHostSystemDefaultSessionMigrationEligible(): boolean + abstract prepareHostSystemDefaultSessionMigrationPass( + scanDates?: readonly CodexSessionBackfillDate[] + ): boolean + abstract finishHostSystemDefaultSessionMigrationPass(): void + + protected abstract getSelfContainedManagedHostAccount(): CodexManagedAccount | null + protected abstract getManagedAccountHomesForSessionDiscovery(): string[] + protected abstract getManagedHostAccountHomesForSessionDiscovery(): string[] + protected abstract prepareSelfContainedManagedHomeForLaunch( + account: CodexManagedAccount, + unavailableManagedHomePath?: string + ): string | null + protected abstract startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void + protected abstract getSelfContainedSessionBridgeSourceHomes(): string[] + protected abstract syncSelfContainedManagedSelection(account: CodexManagedAccount): void + protected abstract resolveSelfContainedManagedHome( + account: CodexManagedAccount + ): CodexSelfContainedManagedHomeResolution + protected abstract getTrustedSelfContainedManagedHomePath( + account: CodexManagedAccount + ): string | null + protected abstract clearSelfContainedManagedSelection( + account: CodexManagedAccount, + reason?: string + ): void + protected abstract invalidateBackfillAfterManagedSystemDefaultLaunch( + launchEnv?: NodeJS.ProcessEnv + ): boolean | null + protected abstract startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + + abstract getHostCodexHomePathsForSessionDiscovery(): string[] + abstract getSelectedHostAccountCodexHomePath(): string | null + abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null + abstract resolveCodexManagedAccountHomeForInactiveFetch( + account: CodexManagedAccount + ): { kind: 'ready'; homePath: string } | { kind: 'skip' } + abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute + abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] + abstract setRealHomeLaneGate(gate: () => boolean): void + abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean + abstract isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean + abstract reconcileLegacySharedHomeForRetainedPanes(): void + abstract syncActiveWslSelectionsBeforeRestart(): void + + protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null + protected abstract syncWslConfigAndGlobalInstructionsForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void + abstract prepareForRateLimitFetch( + target?: CodexAccountSelectionTarget + ): CodexRateLimitHomeResolution + abstract syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void + abstract clearLastWrittenAuthJson(accountId?: string | null): void + + protected abstract readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult + protected abstract resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } + protected abstract safeSyncForCurrentSelection(): void + protected abstract safeRecoverInterruptedRuntimeAuthOperation(): void + protected abstract getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null + protected abstract getWslManagedHomePath(account: CodexManagedAccount | null): string | null + protected abstract getPreparedWslRateLimitHomePath( + target: CodexAccountSelectionTarget + ): string | null + protected abstract syncWslRuntimeForCurrentSelection( + target: CodexAccountSelectionTarget + ): string | null + protected abstract getWslRuntimeHomePath(distro: string): string | null + protected abstract safeReadBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void + protected abstract readBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void + protected abstract safeMigrateLegacyWslActiveHomePointer( + distro: string, + runtimeHomePath: string + ): void + protected abstract migrateLegacyWslActiveHomePointer( + distro: string, + runtimeHomePath: string + ): void + protected abstract dirnameLinuxPath(value: string): string + protected abstract joinWslPath(basePath: string, ...segments: string[]): string + protected abstract resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget + protected abstract getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null + protected abstract seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void + protected abstract findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string + ): CodexReadBackMatch + protected abstract runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean + protected abstract runtimeAuthIsFresher( + runtimeAuthContents: string, + managedAuthContents: string + ): boolean + + protected abstract safeMigrateLegacySharedAuth(): void + protected abstract safeMigrateLegacyManagedState(): void + protected abstract safeMigrateLegacyActiveHomePointer(): void + protected abstract getRuntimeHomePath(): string + abstract getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus + protected abstract getRuntimeAuthPath(): string + protected abstract getSystemDefaultSnapshotPath(): string + protected abstract getRuntimeLogoutMarkerPath(): string + protected abstract getSharedRuntimeAuthProvenancePath(): string + protected abstract getRuntimeMetadataDir(): string + protected abstract getLegacyHostActiveHomePath(): string + protected abstract getMigrationMarkerPath(): string + protected abstract getMigrationDiagnosticsPath(): string + protected abstract getManagedAccountsRoot(): string + protected abstract repointLegacyActiveHomePointer( + activeHomePath: string, + runtimeHomePath: string + ): void + protected abstract activeHomeAlreadyPointsToRuntimeHome( + activeHomePath: string, + runtimeHomePath: string + ): boolean + protected abstract linkTargetsMatch( + linkTarget: string, + linkPath: string, + expectedTargetPath: string + ): boolean + protected abstract legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean + protected abstract legacyActiveHomePathExists(activeHomePath: string): boolean + protected abstract removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void + protected abstract isWindowsReadableLink(targetPath: string): boolean + protected abstract migrateLegacyManagedStateIfNeeded(): void + protected abstract getLegacyManagedHomes(): string[] + protected abstract migrateLegacyHistory(managedHomePath: string): void + protected abstract migrateLegacySessions(managedHomePath: string, accountId: string): void + protected abstract listFilesRecursively(rootPath: string): string[] + protected abstract appendListedFiles(target: string[], source: readonly string[]): void + protected abstract getPreservedLegacySessionPath( + runtimeFilePath: string, + accountId: string + ): string + protected abstract appendMigrationDiagnostic(record: Record): void + + protected abstract captureSystemDefaultSnapshot(options: { force: boolean }): void + protected abstract syncRuntimeAuthWithSystemDefault(): void + protected abstract syncLegacySharedSystemDefaultAuthForRetainedPanes(): void + protected abstract restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void + protected abstract writeSystemDefaultAuth(contents: string): void + protected abstract clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void + protected abstract readSystemDefaultAuth(): string | null + protected abstract writeRuntimeAuth( + contents: string, + owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, + options?: { expectedContents: string | null } + ): boolean + protected abstract writeRuntimeAuthAtPath(authPath: string, contents: string): void + protected abstract compareFileContents(targetPath: string, contents: string): boolean | null + protected abstract fileContentsEqual(targetPath: string, contents: string): boolean + protected abstract fileContentsMatchExpected( + targetPath: string, + expectedContents: string | null + ): boolean + protected abstract ensureOwnerOnlyMode(targetPath: string): void + protected abstract getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus + protected abstract persistRuntimeLogoutMarker(systemDefaultAuthJson?: string | null): void + protected abstract readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null + protected abstract clearRuntimeLogoutMarker(): void + protected abstract persistSharedRuntimeAuthProvenance( + provenance: CodexSharedRuntimeAuthProvenanceFile + ): void + protected abstract markSharedRuntimeAuthManaged(accountId: string): void + protected abstract getUntouchedSystemDefaultBaseline( + status: CodexSharedRuntimeAuthProvenanceStatus, + runtimeAuthJson: string | null + ): { authJson: string | null } | null + protected abstract restoreUntouchedSystemDefaultProvenance( + provenance: Extract + ): Extract | null + protected abstract sharedRuntimeAuthProvenanceMatches( + status: CodexSharedRuntimeAuthProvenanceStatus, + expected: CodexSharedRuntimeAuthProvenance + ): boolean + protected abstract resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus + protected abstract parseSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthProvenance | null + protected abstract parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null + protected abstract parsePendingSharedRuntimeAuthProvenance( + value: unknown + ): CodexSharedRuntimeAuthPendingProvenance | null + protected abstract readRuntimeAuthForProvenance(): string | null + protected abstract readSystemDefaultSnapshot( + snapshotPath: string + ): CodexSystemDefaultSnapshot | null + abstract clearSystemDefaultSnapshot(): void +} diff --git a/src/main/codex-accounts/runtime-home-service-sync.ts b/src/main/codex-accounts/runtime-home-service-sync.ts new file mode 100644 index 00000000000..382a519b7d7 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-sync.ts @@ -0,0 +1,216 @@ +import { existsSync, readFileSync } from 'node:fs' +import { + normalizeCodexRuntimeSelection, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { writeFileAtomically, recoverInterruptedGuardedFileOperation } from './fs-utils' +import type { + CodexSharedRuntimeAuthProvenanceStatus, + CodexReadBackResult +} from './runtime-home-service-types' +import { codexAuthIsMonotonicallyFresher } from './runtime-home-service-auth-sync-identity' +import { CodexRuntimeHomeWsl } from './runtime-home-service-wsl' + +export abstract class CodexRuntimeHomeSync extends CodexRuntimeHomeWsl { + syncForCurrentSelection( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv + ): void { + if (target?.runtime === 'wsl') { + this.syncWslRuntimeForCurrentSelection(target) + return + } + + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + // Why: self-contained managed homes hold their own auth, so the shared + // runtime home's snapshot/hot-swap/read-back machinery below must not run. + this.syncSelfContainedManagedSelection(selfContainedAccount) + return + } + const settings = this.store.getSettings() + if (this.lastHostAccountUsedSelfContainedHome) { + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. + this.lastHostAccountUsedSelfContainedHome = false + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + if (this.isHostSystemDefaultRealHome(launchEnv)) { + return + } + } + if (this.isHostSystemDefaultRealHome(launchEnv)) { + // Why: retained daemon panes may own shared auth from a managed launch; + // compatibility reconciliation runs later with durable provenance. + if (this.lastSyncedAccountId !== null) { + this.sharedAuthRefreshBlockedByManagedTransition = true + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + } + return + } + const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) + if (this.lastSyncedAccountId === null) { + this.captureSystemDefaultSnapshot({ force: false }) + } + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + normalizeCodexRuntimeSelection(settings).host + ) + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. + this.lastSyncedAccountId = null + this.lastWrittenAuthJson = null + this.skipNextReadBackForAccountId = null + return + } + if (normalizeCodexRuntimeSelection(settings).host) { + this.store.updateSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { + ...normalizeCodexRuntimeSelection(settings), + host: null + } + }) + } + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else { + this.persistRuntimeLogoutMarker() + } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() + } + } + + // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. + clearLastWrittenAuthJson( + accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + ): void { + if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { + this.lastWrittenAuthJson = null + } + this.skipNextReadBackForAccountId = accountId + } + + protected readBackRefreshedTokensFromPath( + runtimeAuthPath: string, + options: { + updateLastWrittenAuthJson: boolean + lastWrittenAuthJson?: string | null + setLastWrittenAuthJson?: (contents: string) => void + expectedAccountId?: string + } + ): CodexReadBackResult { + try { + if (!existsSync(runtimeAuthPath)) { + return 'unchanged' + } + + const lastWrittenAuthJson = + options.lastWrittenAuthJson === undefined + ? this.lastWrittenAuthJson + : options.lastWrittenAuthJson + const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') + if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { + return 'unchanged' + } + + const match = this.findManagedAccountForRuntimeAuth( + runtimeContents, + options.expectedAccountId + ) + if (match.kind !== 'matched') { + if (match.kind === 'ambiguous') { + console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') + } + return 'rejected' + } + // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. + if ( + lastWrittenAuthJson === null && + !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) + ) { + return 'rejected' + } + + writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) + if (options.updateLastWrittenAuthJson) { + if (options.setLastWrittenAuthJson) { + options.setLastWrittenAuthJson(runtimeContents) + } else { + this.lastWrittenAuthJson = runtimeContents + } + } + return 'persisted' + } catch (error) { + // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. + console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) + return 'rejected' + } + } + + // Why: which ~/.codex bytes the mirror was seeded from, and whether the system + // default can be proven to own the mirror at all. + protected resolveSystemDefaultMirrorClaim( + runtimeAuth: string, + provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus + ): { ownershipProven: boolean; mirroredAuthJson: string | null } { + const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null + const snapshotAuth = + this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null + const preProvenanceRuntimeRefreshProven = + provenanceStatus.kind === 'missing' && + snapshotAuth !== null && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && + codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) + return { + ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, + mirroredAuthJson: + provenance?.owner === 'system-default' + ? provenance.authJson + : provenanceStatus.kind === 'missing' + ? (this.lastWrittenAuthJson ?? snapshotAuth) + : null + } + } + + protected safeSyncForCurrentSelection(): void { + try { + this.syncForCurrentSelection() + } catch (error) { + console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) + } + } + + protected safeRecoverInterruptedRuntimeAuthOperation(): void { + try { + recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) + } catch (error) { + console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service-types.ts b/src/main/codex-accounts/runtime-home-service-types.ts new file mode 100644 index 00000000000..71f15cfef5f --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-types.ts @@ -0,0 +1,64 @@ +import type { CodexManagedAccount } from '../../shared/managed-account-types' + +export type CodexSystemDefaultSnapshot = { + authJson: string | null +} + +export type CodexRuntimeLogoutMarker = { + systemDefaultAuthJson: string | null + loggedOutAt: number +} + +export type CodexSharedRuntimeAuthProvenance = + | { owner: 'system-default'; authJson: string | null } + | { + owner: 'managed' + accountId: string + systemDefaultBaseline?: { authJson: string | null } + } + +export type CodexSharedRuntimeAuthPendingProvenance = { + owner: 'pending' + next: CodexSharedRuntimeAuthProvenance + runtimeAuthJson: string | null +} + +export type CodexSharedRuntimeAuthProvenanceFile = + | CodexSharedRuntimeAuthProvenance + | CodexSharedRuntimeAuthPendingProvenance + | { owner: 'fenced' } + +export type CodexSharedRuntimeAuthProvenanceStatus = + | { kind: 'missing' | 'fenced' } + | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } + +export type CodexRuntimeLogoutMarkerStatus = + | { kind: 'missing' } + | { kind: 'applies' } + | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } + +export type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' + +export type CodexReadBackMatch = + | { + kind: 'matched' + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + } + | { kind: 'none' | 'ambiguous' } + +export type CodexSelfContainedManagedHomeResolution = + | { kind: 'owned'; homePath: string } + | { kind: 'untrusted' } + | { kind: 'indeterminate' } + +/** Status used by the config-sync surface; `unavailable` is not a healthy null lane. */ +export type CodexMirroredHomeStatus = + | { kind: 'ready'; homePath: string | null } + | { kind: 'unavailable' } + +/** Result used by quota polling, where `skip` means no process should be spawned. */ +export type CodexRateLimitHomeResolution = + | { kind: 'ready'; codexHomePath: string | null } + | { kind: 'skip' } diff --git a/src/main/codex-accounts/runtime-home-service-wsl-core.ts b/src/main/codex-accounts/runtime-home-service-wsl-core.ts new file mode 100644 index 00000000000..c8ec80b2532 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl-core.ts @@ -0,0 +1,96 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + codexAuthCouldBelongToManagedAccount, + codexAuthMatchesManagedAccount, + codexAuthMatchesSystemDefaultIdentity, + codexAuthIsFresher +} from './codex-auth-identity' +import { parseWslUncPath } from '../../shared/wsl-paths' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import type { CodexReadBackMatch } from './runtime-home-service-types' +import { CodexRuntimeHomeAuthProvenance } from './runtime-home-service-auth-provenance' + +export abstract class CodexRuntimeHomeWslCore extends CodexRuntimeHomeAuthProvenance { + protected getActiveAccount( + accounts: CodexManagedAccount[], + activeAccountId: string | null + ): CodexManagedAccount | null { + if (!activeAccountId) { + return null + } + return accounts.find((account) => account.id === activeAccountId) ?? null + } + + protected getWslManagedHomePath(account: CodexManagedAccount | null): string | null { + if (!account) { + return null + } + if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { + return account.managedHomePath + } + return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null + } + + protected findManagedAccountForRuntimeAuth( + runtimeAuthContents: string, + expectedAccountId?: string + ): CodexReadBackMatch { + const matches: { + account: CodexManagedAccount + managedAuthPath: string + managedAuthContents: string + }[] = [] + let unreadableHomeCouldOwnRuntimeAuth = false + for (const account of this.store.getSettings().codexManagedAccounts) { + if (expectedAccountId && account.id !== expectedAccountId) { + continue + } + const managedAuthPath = join(account.managedHomePath, 'auth.json') + if (!existsSync(managedAuthPath)) { + continue + } + let managedAuthContents: string + try { + managedAuthContents = readFileSync(managedAuthPath, 'utf-8') + } catch { + // Why: an unreadable home can never be compared, but letting the read + // throw abandons the scan for every other account — dropping a refresh + // the runtime home holds for one of them. Only its record can rule it + // out as the owner; when it cannot, the scan is no longer unambiguous. + if ( + !expectedAccountId && + codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) + ) { + unreadableHomeCouldOwnRuntimeAuth = true + } + continue + } + if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { + matches.push({ account, managedAuthPath, managedAuthContents }) + } + } + + if (unreadableHomeCouldOwnRuntimeAuth) { + return { kind: 'ambiguous' } + } + if (matches.length === 1) { + return { kind: 'matched', ...matches[0] } + } + return { kind: matches.length === 0 ? 'none' : 'ambiguous' } + } + + protected runtimeAuthMatchesSystemDefaultIdentity( + runtimeAuthContents: string, + systemDefaultAuthContents: string + ): boolean { + return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) + } + + protected runtimeAuthIsFresher( + runtimeAuthContents: string, + managedAuthContents: string + ): boolean { + return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) + } +} diff --git a/src/main/codex-accounts/runtime-home-service-wsl-seed-config.ts b/src/main/codex-accounts/runtime-home-service-wsl-seed-config.ts new file mode 100644 index 00000000000..42db9e58b7e --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl-seed-config.ts @@ -0,0 +1,13 @@ +import { parseWslUncPath } from '../../shared/wsl-paths' +import { prepareSystemConfigForFreshRuntimeMirror } from '../codex/codex-config-mirror' + +// Codex resolves relative config paths from its Linux-side home in WSL. +export function prepareWslRuntimeSeedConfig( + configContents: string, + sourceHomePath: string +): string { + return prepareSystemConfigForFreshRuntimeMirror( + configContents, + parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath + ) +} diff --git a/src/main/codex-accounts/runtime-home-service-wsl.ts b/src/main/codex-accounts/runtime-home-service-wsl.ts new file mode 100644 index 00000000000..3b006bdce19 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-service-wsl.ts @@ -0,0 +1,287 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs' +import { join, win32 as pathWin32 } from 'node:path' +import { quotePosixShell } from '../../shared/wsl-login-shell-command' +import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { + getWslSelectionKey, + getSelectedCodexAccountIdForTarget, + normalizeCodexRuntimeSelection, + setSelectedCodexAccountIdForTarget, + type CodexAccountSelectionTarget +} from './runtime-selection' +import { getDefaultWslDistro, getWslHome } from '../wsl' +import { writeFileAtomically } from './fs-utils' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core' +import { prepareWslRuntimeSeedConfig } from './runtime-home-service-wsl-seed-config' + +export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore { + protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { + const distro = target.wslDistro?.trim() + if (distro) { + const settings = this.store.getSettings() + const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) + if (selectedAccountId === null) { + // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. + return this.getWslSystemCodexHomePath(target) + } + const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if ( + cachedRuntimeHomePath && + this.lastSyncedWslAccountIdByDistro.has(distro) && + this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId + ) { + // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. + return cachedRuntimeHomePath + } + } + return this.syncWslRuntimeForCurrentSelection(target) + } + + protected syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { + if (process.platform !== 'win32') { + return null + } + + const wslTarget = this.resolveWslDefaultTarget(target) + const settings = this.store.getSettings() + const activeAccount = this.getActiveAccount( + settings.codexManagedAccounts, + getSelectedCodexAccountIdForTarget(settings, wslTarget) + ) + const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() + if (!distro) { + return null + } + + const runtimeHomePath = this.getWslRuntimeHomePath(distro) + if (!runtimeHomePath) { + return null + } + this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) + + mkdirSync(runtimeHomePath, { recursive: true }) + this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) + + const runtimeAuthPath = join(runtimeHomePath, 'auth.json') + const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null + if (previousWslAccountId) { + if (this.skipNextReadBackForAccountId === previousWslAccountId) { + this.skipNextReadBackForAccountId = null + } else { + const previousWslAccount = this.getActiveAccount( + settings.codexManagedAccounts, + previousWslAccountId + ) + if (previousWslAccount) { + this.readBackRefreshedTokensFromPath(runtimeAuthPath, { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: previousWslAccount.id + }) + } + } + } + + const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null + if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { + const activeAuth = readFileSync(activeAuthPath, 'utf-8') + this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) + return runtimeHomePath + } + if (activeAccount && activeAuthPath) { + console.warn( + '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' + ) + this.store.updateSettings({ + activeCodexManagedAccountId: settings.activeCodexManagedAccountId, + activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( + normalizeCodexRuntimeSelection(settings), + null, + wslTarget + ) + }) + } + + const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) + if (systemAuthPath && existsSync(systemAuthPath)) { + const systemAuth = readFileSync(systemAuthPath, 'utf-8') + const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null + const runtimeAuth = existsSync(runtimeAuthPath) + ? readFileSync(runtimeAuthPath, 'utf-8') + : null + if ( + runtimeAuth !== null && + runtimeAuth !== systemAuth && + this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && + ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || + (mirroredSystemDefaultAuth === null && + this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) + ) { + // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. + this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) + this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + rmSync(runtimeAuthPath, { force: true }) + this.lastWrittenWslAuthJsonByDistro.set(distro, null) + this.lastSyncedWslAccountIdByDistro.set(distro, null) + return runtimeHomePath + } + + protected getWslRuntimeHomePath(distro: string): string | null { + const home = getWslHome(distro) + return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null + } + + protected safeReadBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + try { + this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) + } catch (error) { + console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) + } + } + + protected readBackActiveWslAccountBeforeRestart( + account: CodexManagedAccount, + selectedDistroKey: string + ): void { + const distro = + selectedDistroKey === getWslSelectionKey(null) + ? account.wslDistro?.trim() + : selectedDistroKey.trim() || account.wslDistro?.trim() + if (!distro) { + return + } + + const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) + if (!runtimeHomePath) { + return + } + + this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { + updateLastWrittenAuthJson: true, + lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, + setLastWrittenAuthJson: (contents) => { + this.lastWrittenWslAuthJsonByDistro.set(distro, contents) + }, + expectedAccountId: account.id + }) + } + + protected safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + try { + this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) + } catch (error) { + console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) + } + } + + protected migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { + const runtimeWsl = parseWslUncPath(runtimeHomePath) + if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { + return + } + const activeLinuxPath = runtimeWsl.linuxPath.replace( + /\/codex-runtime-home\/home$/, + '/codex-runtime-home/active/wsl/home' + ) + const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` + const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) + // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. + execFileSync( + 'wsl.exe', + [ + '-d', + distro, + '--exec', + 'bash', + '-lc', + [ + 'set -e', + `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, + 'else', + `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, + `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, + `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, + `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, + 'fi' + ].join('\n') + ], + // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes + // a conhost and steals foreground for up to the timeout (#10488). + { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } + ) + } + + protected dirnameLinuxPath(value: string): string { + const index = value.lastIndexOf('/') + return index > 0 ? value.slice(0, index) : '/' + } + + protected joinWslPath(basePath: string, ...segments: string[]): string { + return parseWslUncPath(basePath) + ? pathWin32.join(basePath, ...segments) + : join(basePath, ...segments) + } + + protected resolveWslDefaultTarget( + target: CodexAccountSelectionTarget + ): CodexAccountSelectionTarget { + if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { + return target + } + const defaultDistro = getDefaultWslDistro() + return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target + } + + protected getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { + const home = this.getWslSystemCodexHomePath(target) + return home ? this.joinWslPath(home, 'auth.json') : null + } + + protected seedWslRuntimeHome( + runtimeHomePath: string, + activeAccount: CodexManagedAccount | null, + distro: string + ): void { + const runtimeConfigPath = join(runtimeHomePath, 'config.toml') + if (existsSync(runtimeConfigPath)) { + return + } + + const candidateHomes = [ + activeAccount?.managedHomePath, + this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) + ].filter((value): value is string => Boolean(value)) + for (const homePath of candidateHomes) { + const configPath = join(homePath, 'config.toml') + if (existsSync(configPath)) { + writeFileAtomically( + runtimeConfigPath, + prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) + ) + return + } + } + } +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index cf3a0105d63..4f0dab6de0f 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -1,216 +1,15 @@ -/* eslint-disable max-lines -- Why: keeps Codex's whole runtime-home contract in one place so account-switch semantics don't drift across launch/login/quota paths. */ -import { quotePosixShell } from '../../shared/wsl-login-shell-command' -import { - appendFileSync, - copyFileSync, - existsSync, - chmodSync, - lstatSync, - mkdirSync, - readlinkSync, - readdirSync, - readFileSync, - renameSync, - rmdirSync, - rmSync, - statSync, - symlinkSync, - unlinkSync -} from 'node:fs' -import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' -import { execFileSync } from 'node:child_process' -import { - dirname, - extname, - isAbsolute, - join, - parse, - relative, - resolve, - win32 as pathWin32 -} from 'node:path' -import { app } from 'electron' -import type { CodexManagedAccount } from '../../shared/managed-account-types' -import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' import type { Store } from '../persistence' -import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' -import { - recoverInterruptedGuardedFileOperation, - removeFileAtomicallyIfUnchanged, - writeFileAtomically, - writeFileAtomicallyIfUnchanged -} from './fs-utils' -import { - getOrcaManagedCodexHomePath, - getOrcaUserDataPath, - getSystemCodexHomePath, - resolveOrcaManagedCodexHomePath, - syncCodexGlobalInstructionsIntoManagedHome, - syncSystemCodexResourcesIntoManagedHome -} from '../codex/codex-home-paths' -import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge' -import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' -import { - resolveHostCodexSessionSourceHome, - resolveWslCodexSessionSourceHome -} from '../codex/codex-session-source-home' -import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' -import { - prepareSystemConfigForFreshRuntimeMirror, - syncSystemConfigIntoManagedCodexHome -} from '../codex/codex-config-mirror' -import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' -import { - getWslSelectionKey, - getSelectedCodexAccountIdForTarget, - normalizeCodexRuntimeSelection, - setSelectedCodexAccountIdForTarget, - type CodexAccountSelectionTarget -} from './runtime-selection' -import { getDefaultWslDistro, getWslHome } from '../wsl' -import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' -import { - hasCompletedCodexSessionBackfillMarker, - markCodexSessionBackfillMarkerPending -} from '../codex/codex-session-backfill-marker' -import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates' -import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill' -import { - ManagedCodexHomeTemporarilyUnavailableError, - resolveHostCodexManagedHomeVerdict -} from './host-codex-managed-home-ownership' -import { - codexAuthCouldBelongToManagedAccount, - codexAuthIsFresher, - codexAuthMatchesManagedAccount, - codexAuthMatchesSystemDefaultIdentity -} from './codex-auth-identity' -import { migrateLegacySharedAuthToPerAccountHome } from './legacy-shared-auth-migration' -import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace' -import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' -import { - getCodexPaneAccount, - hasRecordedLegacySharedCodexPane, - type CodexPaneHomeRoute -} from '../codex/codex-pane-account-registry' -import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { CodexRuntimeHomeAuthSync } from './runtime-home-service-auth-sync' +export { prepareWslRuntimeSeedConfig } from './runtime-home-service-wsl-seed-config' -type CodexSystemDefaultSnapshot = { - authJson: string | null -} +export type { + CodexMirroredHomeStatus, + CodexRateLimitHomeResolution +} from './runtime-home-service-types' -type CodexRuntimeLogoutMarker = { - systemDefaultAuthJson: string | null - loggedOutAt: number -} - -type CodexSharedRuntimeAuthProvenance = - | { owner: 'system-default'; authJson: string | null } - | { - owner: 'managed' - accountId: string - systemDefaultBaseline?: { authJson: string | null } - } -type CodexSharedRuntimeAuthPendingProvenance = { - owner: 'pending' - next: CodexSharedRuntimeAuthProvenance - runtimeAuthJson: string | null -} -type CodexSharedRuntimeAuthProvenanceFile = - | CodexSharedRuntimeAuthProvenance - | CodexSharedRuntimeAuthPendingProvenance - | { owner: 'fenced' } -type CodexSharedRuntimeAuthProvenanceStatus = - | { kind: 'missing' | 'fenced' } - | { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance } - -type CodexRuntimeLogoutMarkerStatus = - | { kind: 'missing' } - | { kind: 'applies' } - | { kind: 'system-default-changed'; systemDefaultAuthJson: string | null } - -type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected' -type CodexReadBackMatch = - | { - kind: 'matched' - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - } - | { kind: 'none' | 'ambiguous' } - -function readCodexLastRefresh(authJson: string): number | null { - try { - const parsed = JSON.parse(authJson) as unknown - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null - } - const value = (parsed as Record).last_refresh - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null - } - if (typeof value !== 'string' || !value.trim()) { - return null - } - const timestamp = Date.parse(value) - return Number.isFinite(timestamp) ? timestamp : null - } catch { - return null - } -} - -function codexAuthIsMonotonicallyFresher( - candidateAuthJson: string, - baselineAuthJson: string -): boolean { - const candidateLastRefresh = readCodexLastRefresh(candidateAuthJson) - const baselineLastRefresh = readCodexLastRefresh(baselineAuthJson) - if (candidateLastRefresh !== null || baselineLastRefresh !== null) { - return ( - candidateLastRefresh !== null && - baselineLastRefresh !== null && - candidateLastRefresh > baselineLastRefresh - ) - } - return codexAuthIsFresher(candidateAuthJson, baselineAuthJson) -} - -/** - * Why: a skipped Codex quota poll must be distinguishable from "use the - * system-default home". `null` inside `ready` still means the system lane; - * `skip` means do not fetch at all this cycle (#STA-4422). - */ -/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */ -export type CodexMirroredHomeStatus = - | { kind: 'ready'; homePath: string | null } - | { kind: 'unavailable' } - -export type CodexRateLimitHomeResolution = - | { kind: 'ready'; codexHomePath: string | null } - | { kind: 'skip' } - -export class CodexRuntimeHomeService { - // Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account. - private lastSyncedAccountId: string | null = null - // Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt). - private lastWrittenAuthJson: string | null = null - // Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage. - private readonly lastWrittenWslAuthJsonByDistro = new Map() - private readonly lastSyncedWslAccountIdByDistro = new Map() - private readonly wslRuntimeHomePathByDistro = new Map() - private skipNextReadBackForAccountId: string | null = null - // Why: a managed host account refreshes auth in its own home. Remember that - // provenance so a later deselect never adopts stale shared bytes. - private lastHostAccountUsedSelfContainedHome = false - private sharedAuthRefreshBlockedByManagedTransition = false - // Why: transient auth.json read/parse failures must not deselect an account. - private readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace() - private hostSystemDefaultSessionMigrationPending = false - private pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - private pendingHostSystemDefaultSessionMigrationTarget: string | null = null - - constructor(private readonly store: Store) { +export class CodexRuntimeHomeService extends CodexRuntimeHomeAuthSync { + constructor(store: Store) { + super(store) this.safeRecoverInterruptedRuntimeAuthOperation() this.safeMigrateLegacySharedAuth() this.safeMigrateLegacyManagedState() @@ -218,2193 +17,4 @@ export class CodexRuntimeHomeService { this.initializeLastSyncedState() this.safeSyncForCurrentSelection() } - - private initializeLastSyncedState(): void { - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - // Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched. - this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount) - ? null - : normalizeCodexRuntimeSelection(settings).host - } - - /** - * Materializes the runtime home needed before launching the CLI. - * - * Historical session bridging is requested in the background so launch setup - * returns as soon as the active runtime home is ready. - */ - prepareForCodexLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - options?: { unavailableManagedHomePath?: string } - ): string | null { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) - this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath) - const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) - return runtimeHomePath - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch( - selfContainedAccount, - options?.unavailableManagedHomePath - ) - if (perAccountHome) { - return perAccountHome - } - // Why: only an untrusted home clears the selection; fall through to the - // system default without injecting a path Orca cannot prove it owns. - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: the system default runs Codex on the user's own ~/.codex. - // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; - // the retired mirror is refreshed only for pre-rollout PTYs. - this.reconcileLegacySharedHomeForRetainedPanes() - return null - } - this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv) - this.syncForCurrentSelection(target, launchEnv) - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - // Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk. - void startSystemCodexSessionBridgeInBackground( - {}, - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - return this.getRuntimeHomePath() - } - - beginHostSystemDefaultSessionMigrationLaunch( - codexHomePath: string | null, - options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {} - ): boolean | null { - if ( - !this.isHostSystemDefaultSessionMigrationEligible() || - (!codexHomePath && !options.reattached) || - (codexHomePath && - normalizeRuntimePathForComparison(codexHomePath) !== - normalizeRuntimePathForComparison(this.getRuntimeHomePath())) - ) { - return null - } - // Why: an older pass can clear launch preparation while PTY spawn awaits recovery. - return this.invalidateBackfillAfterManagedSystemDefaultLaunch( - options.reattached && !codexHomePath ? undefined : options.launchEnv - ) - } - - isHostSystemDefaultSessionMigrationEligible(): boolean { - return ( - normalizeCodexRuntimeSelection(this.store.getSettings()).host === null && - !hasCustomCodexHomeOverrideForLaunch() - ) - } - - prepareHostSystemDefaultSessionMigrationPass( - scanDates: readonly CodexSessionBackfillDate[] = [] - ): boolean { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot) - if ( - this.hostSystemDefaultSessionMigrationPending && - this.pendingHostSystemDefaultSessionMigrationTarget !== target - ) { - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true - this.pendingHostSystemDefaultSessionMigrationTarget = target - } - // Why: the launch creates rollouts for these dates; record them durably so a - // force-quit recovers a bounded window instead of re-walking all history. - const markerOwesFullScan = markCodexSessionBackfillMarkerPending( - paths.markerPath, - paths.systemSessionsRoot, - scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()] - ) - // Why: the marker is the only place an overflowed pending window survives a - // restart, so its demand has to reach this pass rather than die in the file. - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan - return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan - } - - finishHostSystemDefaultSessionMigrationPass(): void { - this.hostSystemDefaultSessionMigrationPending = false - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false - this.pendingHostSystemDefaultSessionMigrationTarget = null - } - - // Why: a managed HOST account runs against its own self-contained CODEX_HOME - // (codex-accounts//home) rather than the shared runtime mirror. Its - // auth.json lives there and codex refreshes it in place, so two accounts never - // race one auth.json. WSL accounts keep their per-distro lane. - private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { - const settings = this.store.getSettings() - const account = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (!account || this.getWslManagedHomePath(account)) { - return null - } - return account - } - - // Why: session discovery must surface every account's own rollouts wherever they live. - private getManagedAccountHomesForSessionDiscovery(): string[] { - const settings = this.store.getSettings() - const homes: string[] = [] - for (const account of settings.codexManagedAccounts) { - const wslHome = this.getWslManagedHomePath(account) - if (wslHome) { - homes.push(wslHome) - continue - } - const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome) { - homes.push(trustedHome) - } - } - return homes - } - - private getManagedHostAccountHomesForSessionDiscovery(): string[] { - return this.getManagedAccountHomesForSessionDiscovery().filter( - (home) => parseWslUncPath(home) === null - ) - } - - private prepareSelfContainedManagedHomeForLaunch( - account: CodexManagedAccount, - unavailableManagedHomePath?: string - ): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: refuse the launch rather than silently falling through to the - // system default, which would run a different account behind a UI still - // showing this one. The selection stays put; a later read may succeed. - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(account) - return null - } - const perAccountHome = resolved.homePath - if ( - unavailableManagedHomePath && - normalizeRuntimePathForComparison(unavailableManagedHomePath) === - normalizeRuntimePathForComparison(perAccountHome) - ) { - const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json')) - if (absence.state !== 'present' && absence.durable) { - this.clearSelfContainedManagedSelection(account, 'credential remained unavailable') - return null - } - // Why: a transient missing/unreadable auth.json is usually codex rotating - // it; keep the selection and launch — the CLI re-reads the settled file. - } - // Why: link the user's real ~/.codex resources and mirror config into THIS - // home (never symlinking into or mutating ~/.codex), so the per-account home - // is a complete CODEX_HOME. Hooks/trust are installed by the launch caller. - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - syncSystemCodexResourcesIntoManagedHome(perAccountHome) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath: perAccountHome, - systemHomePath: getSystemCodexHomePath() - }) - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return perAccountHome - } - - // Why: Codex's own `/resume` picker only lists rollouts under the launch - // CODEX_HOME, so a self-contained account home starts out with no history at - // all. Hardlink every other Orca-visible home's rollouts in — after launch, - // since history trees can be large — so switching accounts no longer hides - // the user's conversations. - private startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void { - void startCodexAccountSessionBridgeInBackground({ - targetCodexHomePath: perAccountHome, - sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes() - }) - } - - private getSelfContainedSessionBridgeSourceHomes(): string[] { - return [ - // Why: history-only override lets custom-CODEX_HOME users bridge from the - // home they actually record sessions in; falls back to the real ~/.codex. - resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(), - // Why: path only — a per-account install must not materialize the mirror. - resolveOrcaManagedCodexHomePath(), - ...this.getManagedHostAccountHomesForSessionDiscovery() - ] - } - - // Why: the per-account home is both the launch CODEX_HOME and the credential - // store, so codex reads/refreshes auth.json in place — there is no shared-home - // hot-swap or token read-back to reconcile. A trusted home remains selected - // while Codex atomically replaces auth.json. - private syncSelfContainedManagedSelection(account: CodexManagedAccount): void { - const resolved = this.resolveSelfContainedManagedHome(account) - if (resolved.kind === 'indeterminate') { - // Why: a sync runs on every app start, exactly when antivirus is busiest. - // An unreadable home must not deselect the account (#STA-4422). - return - } - const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null - if (perAccountHome) { - this.lastSyncedAccountId = account.id - this.lastHostAccountUsedSelfContainedHome = true - this.sharedAuthRefreshBlockedByManagedTransition = true - this.markSharedRuntimeAuthManaged(account.id) - // Why: selection runs well before the user restarts a pane, so history is - // already linked in by the time the newly launched Codex opens /resume. - this.startSelfContainedSessionBridgeForLaunch(perAccountHome) - return - } - this.clearSelfContainedManagedSelection(account) - } - - /** - * Why: an unreadable home and an untrustworthy one demand opposite responses. - * Only `untrusted` may clear the user's selection; `indeterminate` means we - * could not tell, so callers refuse the operation and leave durable state - * alone (#STA-4422). - */ - private resolveSelfContainedManagedHome( - account: CodexManagedAccount - ): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } { - const verdict = resolveHostCodexManagedHomeVerdict({ - candidatePath: account.managedHomePath, - managedAccountsRoot: this.getManagedAccountsRoot(), - systemCodexHomePath: getSystemCodexHomePath(), - expectedAccountId: account.id - }) - if (verdict.kind === 'owned') { - // Preserve the persisted path spelling (notably /var vs /private/var on - // macOS) so injected CODEX_HOME stays stable across the rollout. - return { kind: 'owned', homePath: account.managedHomePath } - } - if (verdict.kind === 'untrusted') { - console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason) - return { kind: 'untrusted' } - } - console.warn( - '[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:', - verdict.error - ) - return { kind: 'indeterminate' } - } - - /** Read-only callers that mutate nothing and simply skip an unusable home. */ - private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null { - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' ? resolved.homePath : null - } - - private clearSelfContainedManagedSelection( - account: CodexManagedAccount, - reason = 'home is invalid' - ): void { - console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`) - const settings = this.store.getSettings() - if (normalizeCodexRuntimeSelection(settings).host !== account.id) { - return - } - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - this.lastSyncedAccountId = null - this.lastHostAccountUsedSelfContainedHome = false - } - - private invalidateBackfillAfterManagedSystemDefaultLaunch( - launchEnv?: NodeJS.ProcessEnv - ): boolean | null { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - hasCustomCodexHomeOverrideForLaunch(launchEnv) - ) { - return null - } - if (!this.hostSystemDefaultSessionMigrationPending) { - const paths = resolveCodexSessionBackfillPaths( - resolveHostCodexSessionSourceHome(this.store.getSettings()) - ) - this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = - !hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot) - this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison( - paths.systemSessionsRoot - ) - this.hostSystemDefaultSessionMigrationPending = true - } - return this.prepareHostSystemDefaultSessionMigrationPass() - } - - private startWslSessionBridgeForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (process.platform !== 'win32' || !runtimeHomePath) { - return - } - const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) - const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() - if (!distro) { - return - } - // Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to /.codex. - const systemCodexHomePath = - resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ?? - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { - return - } - // Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way. - void startWslCodexSessionBridgeInBackground({ - distro, - systemCodexHomePath, - managedCodexHomePath: runtimeHomePath - }) - } - - getHostCodexHomePathsForSessionDiscovery(): string[] { - const homes = [this.getRuntimeHomePath()] - if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) { - // Why: nested Orca processes can retain an ambient managed CODEX_HOME. - // Per-account lanes no longer bridge real-home history into the shared - // mirror, so include the real root for both directly-routed host lanes. - homes.push(getSystemCodexHomePath()) - } - // Why: account-scoped rollouts live in each account's own home, including WSL. - for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) { - homes.push(perAccountHome) - } - return homes.filter((home, index) => homes.indexOf(home) === index) - } - - /** - * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or a WSL - * account, whose home lives inside the distro). - * - * Read-only on purpose: session discovery ranks homes with this before any - * launch prep, so it must create no directories and sync no auth. - */ - getSelectedHostAccountCodexHomePath(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - return selfContainedAccount - ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) - : null - } - - /** - * Same selection, but an unreadable home refuses instead of collapsing to - * `null`. Session resume must not read "no managed selection" out of a failed - * marker stat: another account's readable alias would then win the legacy - * rescan and the pane would resume under that account's credentials while the - * UI still shows this one (#STA-4422). - */ - resolveSelectedHostAccountCodexHomePathForResume(): string | null { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (!selfContainedAccount) { - return null - } - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - throw new ManagedCodexHomeTemporarilyUnavailableError() - } - if (resolved.kind === 'untrusted') { - this.clearSelfContainedManagedSelection(selfContainedAccount) - return null - } - return resolved.homePath - } - - /** Trust-gates host previews without changing WSL routing or durable account state. */ - resolveCodexManagedAccountHomeForInactiveFetch( - account: CodexManagedAccount - ): { kind: 'ready'; homePath: string } | { kind: 'skip' } { - if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) { - return { kind: 'ready', homePath: account.managedHomePath } - } - const resolved = this.resolveSelfContainedManagedHome(account) - return resolved.kind === 'owned' - ? { kind: 'ready', homePath: resolved.homePath } - : { kind: 'skip' } - } - - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { - const settings = this.store.getSettings() - const homes = new Map() - for (const ptyId of ptyIds) { - const record = getCodexPaneAccount(ptyId) - if (!record || record.selectionKey !== 'host') { - continue - } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { - const homePath = this.getRuntimeHomePath() - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - continue - } - if (record.homeRoute !== 'account-home' || !record.accountId) { - continue - } - const account = settings.codexManagedAccounts.find( - (candidate) => candidate.id === record.accountId - ) - if (!account || this.getWslManagedHomePath(account)) { - continue - } - const homePath = this.getTrustedSelfContainedManagedHomePath(account) - if (homePath) { - homes.set(normalizeRuntimePathForComparison(homePath), homePath) - } - } - return [...homes.values()] - } - - // Why: the real-home hook installer flips this gate off when the trust-grant - // client reports the host incapable, keeping that host byte-identical to the - // managed lane instead of shipping status-blind panes. - private realHomeLaneGate: () => boolean = () => true - - setRealHomeLaneGate(gate: () => boolean): void { - this.realHomeLaneGate = gate - } - - // Why: real-home routing applies only to the host system-default selection. - // Managed accounts run in their own homes; Windows (no shell-startup probe) - // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked - // across old homes. - isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { - const settings = this.store.getSettings() - if ( - normalizeCodexRuntimeSelection(settings).host !== null || - !isShellStartupEnvProbeSupported() - ) { - return false - } - return !hasCustomCodexHomeOverrideForLaunch(launchEnv) - } - - isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean { - return this.isHostSystemDefaultRealHomeSelected(launchEnv) && this.realHomeLaneGate() - } - - reconcileLegacySharedHomeForRetainedPanes(): void { - if (!this.isHostSystemDefaultRealHome() || !hasRecordedLegacySharedCodexPane()) { - return - } - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - syncLegacySharedCodexConfigForRetainedPanes() - } - - syncActiveWslSelectionsBeforeRestart(): void { - if (process.platform !== 'win32') { - return - } - - const settings = this.store.getSettings() - for (const [selectedDistroKey, accountId] of Object.entries( - normalizeCodexRuntimeSelection(settings).wsl - )) { - if (!accountId) { - continue - } - const account = this.getActiveAccount(settings.codexManagedAccounts, accountId) - if (!account || account.managedHomeRuntime !== 'wsl') { - continue - } - this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } - } - - private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - const distro = target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return null - } - const home = getWslHome(distro) - return home ? this.joinWslPath(home, '.codex') : null - } - - private syncWslConfigAndGlobalInstructionsForLaunch( - target: CodexAccountSelectionTarget, - runtimeHomePath: string | null - ): void { - if (!runtimeHomePath) { - return - } - const distro = - parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - return - } - const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - if (!systemHomePath || systemHomePath === runtimeHomePath) { - return - } - // Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions. - syncCodexGlobalInstructionsIntoManagedHome({ - systemHomePath, - managedHomePath: runtimeHomePath - }) - syncSystemConfigIntoManagedCodexHome({ - runtimeHomePath, - systemHomePath, - systemConfigDir: toLinuxPath(systemHomePath) - }) - } - - // Why: `null` is a real value here — it means "use the system-default lane". - // A skipped poll needs its own channel or the fetcher silently retargets the - // user's real ~/.codex (#STA-4422). - prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution { - if (target?.runtime === 'wsl') { - const wslTarget = this.resolveWslDefaultTarget(target) - const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget) - return { - kind: 'ready', - codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget) - } - } - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'owned') { - // Why: the quota fetch reads the account's own auth.json in place; no - // shared-home hot-swap or per-poll resource relink (that is launch prep). - return { kind: 'ready', codexHomePath: resolved.homePath } - } - if (resolved.kind === 'indeterminate') { - // Why: returning null here would NOT skip — the fetcher maps null to - // ~/.codex and would probe the user's real home with a token-refreshing - // app-server. Skip the poll outright and keep the selection. - return { kind: 'skip' } - } - this.clearSelfContainedManagedSelection(selfContainedAccount) - } - if (this.isHostSystemDefaultRealHome()) { - // Why: null lets the fetcher fall back to the main process's inherited - // CODEX_HOME before ~/.codex. Nested Orca launches can inherit the - // managed home, restarting the background OAuth conflict (#5370), so - // pin this non-interactive lane to the native home explicitly. - if (hasRecordedLegacySharedCodexPane()) { - this.syncLegacySharedSystemDefaultAuthForRetainedPanes() - } - return { kind: 'ready', codexHomePath: getSystemCodexHomePath() } - } - this.syncForCurrentSelection() - syncSystemCodexResourcesIntoManagedHome() - syncSystemConfigIntoManagedCodexHome() - return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() } - } - - syncForCurrentSelection( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv - ): void { - if (target?.runtime === 'wsl') { - this.syncWslRuntimeForCurrentSelection(target) - return - } - - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - // Why: self-contained managed homes hold their own auth, so the shared - // runtime home's snapshot/hot-swap/read-back machinery below must not run. - this.syncSelfContainedManagedSelection(selfContainedAccount) - return - } - const settings = this.store.getSettings() - if (this.lastHostAccountUsedSelfContainedHome) { - // Why: the account's auth is already canonical in its own home. Reset the - // legacy mirror baseline without reading it; a real-home deselect needs no - // further sync, and the mirror lane below re-seeds from canonical storage. - this.lastHostAccountUsedSelfContainedHome = false - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - if (this.isHostSystemDefaultRealHome(launchEnv)) { - return - } - } - if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why: retained daemon panes may own shared auth from a managed launch; - // compatibility reconciliation runs later with durable provenance. - if (this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = true - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - } - return - } - const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath()) - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: false }) - } - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - normalizeCodexRuntimeSelection(settings).host - ) - if (activeAccount) { - // Why: only a WSL-managed account can reach here — every host account was - // routed to its own self-contained home above. Its auth lives in the - // distro-local runtime home, so the host mirror only drops its baseline. - this.lastSyncedAccountId = null - this.lastWrittenAuthJson = null - this.skipNextReadBackForAccountId = null - return - } - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - } - - // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. - clearLastWrittenAuthJson( - accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - ): void { - if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) { - this.lastWrittenAuthJson = null - } - this.skipNextReadBackForAccountId = accountId - } - - private readBackRefreshedTokensFromPath( - runtimeAuthPath: string, - options: { - updateLastWrittenAuthJson: boolean - lastWrittenAuthJson?: string | null - setLastWrittenAuthJson?: (contents: string) => void - expectedAccountId?: string - } - ): CodexReadBackResult { - try { - if (!existsSync(runtimeAuthPath)) { - return 'unchanged' - } - - const lastWrittenAuthJson = - options.lastWrittenAuthJson === undefined - ? this.lastWrittenAuthJson - : options.lastWrittenAuthJson - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) { - return 'unchanged' - } - - const match = this.findManagedAccountForRuntimeAuth( - runtimeContents, - options.expectedAccountId - ) - if (match.kind !== 'matched') { - if (match.kind === 'ambiguous') { - console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back') - } - return 'rejected' - } - // Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage. - if ( - lastWrittenAuthJson === null && - !this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents) - ) { - return 'rejected' - } - - writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 }) - if (options.updateLastWrittenAuthJson) { - if (options.setLastWrittenAuthJson) { - options.setLastWrittenAuthJson(runtimeContents) - } else { - this.lastWrittenAuthJson = runtimeContents - } - } - return 'persisted' - } catch (error) { - // Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle. - console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error) - return 'rejected' - } - } - - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system - // default can be proven to own the mirror at all. - private resolveSystemDefaultMirrorClaim( - runtimeAuth: string, - provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus - ): { ownershipProven: boolean; mirroredAuthJson: string | null } { - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const snapshotAuth = - this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null - const preProvenanceRuntimeRefreshProven = - provenanceStatus.kind === 'missing' && - snapshotAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) && - codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth) - return { - ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven, - mirroredAuthJson: - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshotAuth) - : null - } - } - - private safeSyncForCurrentSelection(): void { - try { - this.syncForCurrentSelection() - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error) - } - } - - private safeRecoverInterruptedRuntimeAuthOperation(): void { - try { - recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error) - } - } - - private getActiveAccount( - accounts: CodexManagedAccount[], - activeAccountId: string | null - ): CodexManagedAccount | null { - if (!activeAccountId) { - return null - } - return accounts.find((account) => account.id === activeAccountId) ?? null - } - - private getWslManagedHomePath(account: CodexManagedAccount | null): string | null { - if (!account) { - return null - } - if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) { - return account.managedHomePath - } - return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null - } - - private getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null { - const distro = target.wslDistro?.trim() - if (distro) { - const settings = this.store.getSettings() - const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target) - if (selectedAccountId === null) { - // Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy. - return this.getWslSystemCodexHomePath(target) - } - const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if ( - cachedRuntimeHomePath && - this.lastSyncedWslAccountIdByDistro.has(distro) && - this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId - ) { - // Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe. - return cachedRuntimeHomePath - } - } - return this.syncWslRuntimeForCurrentSelection(target) - } - - private syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null { - if (process.platform !== 'win32') { - return null - } - - const wslTarget = this.resolveWslDefaultTarget(target) - const settings = this.store.getSettings() - const activeAccount = this.getActiveAccount( - settings.codexManagedAccounts, - getSelectedCodexAccountIdForTarget(settings, wslTarget) - ) - const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro() - if (!distro) { - return null - } - - const runtimeHomePath = this.getWslRuntimeHomePath(distro) - if (!runtimeHomePath) { - return null - } - this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath) - - mkdirSync(runtimeHomePath, { recursive: true }) - this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro) - - const runtimeAuthPath = join(runtimeHomePath, 'auth.json') - const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null - if (previousWslAccountId) { - if (this.skipNextReadBackForAccountId === previousWslAccountId) { - this.skipNextReadBackForAccountId = null - } else { - const previousWslAccount = this.getActiveAccount( - settings.codexManagedAccounts, - previousWslAccountId - ) - if (previousWslAccount) { - this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: previousWslAccount.id - }) - } - } - } - - const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null - if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) { - const activeAuth = readFileSync(activeAuthPath, 'utf-8') - this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id) - return runtimeHomePath - } - if (activeAccount && activeAuthPath) { - console.warn( - '[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default' - ) - this.store.updateSettings({ - activeCodexManagedAccountId: settings.activeCodexManagedAccountId, - activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget( - normalizeCodexRuntimeSelection(settings), - null, - wslTarget - ) - }) - } - - const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro }) - if (systemAuthPath && existsSync(systemAuthPath)) { - const systemAuth = readFileSync(systemAuthPath, 'utf-8') - const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null - const runtimeAuth = existsSync(runtimeAuthPath) - ? readFileSync(runtimeAuthPath, 'utf-8') - : null - if ( - runtimeAuth !== null && - runtimeAuth !== systemAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) && - ((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) || - (mirroredSystemDefaultAuth === null && - this.runtimeAuthIsFresher(runtimeAuth, systemAuth))) - ) { - // Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex. - this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth) - this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenWslAuthJsonByDistro.set(distro, null) - this.lastSyncedWslAccountIdByDistro.set(distro, null) - return runtimeHomePath - } - - private getWslRuntimeHomePath(distro: string): string | null { - const home = getWslHome(distro) - return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null - } - - private safeReadBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - try { - this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey) - } catch (error) { - console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error) - } - } - - private readBackActiveWslAccountBeforeRestart( - account: CodexManagedAccount, - selectedDistroKey: string - ): void { - const distro = - selectedDistroKey === getWslSelectionKey(null) - ? account.wslDistro?.trim() - : selectedDistroKey.trim() || account.wslDistro?.trim() - if (!distro) { - return - } - - const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro) - if (!runtimeHomePath) { - return - } - - this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), { - updateLastWrittenAuthJson: true, - lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null, - setLastWrittenAuthJson: (contents) => { - this.lastWrittenWslAuthJsonByDistro.set(distro, contents) - }, - expectedAccountId: account.id - }) - } - - private safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - try { - this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error) - } - } - - private migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void { - const runtimeWsl = parseWslUncPath(runtimeHomePath) - if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) { - return - } - const activeLinuxPath = runtimeWsl.linuxPath.replace( - /\/codex-runtime-home\/home$/, - '/codex-runtime-home/active/wsl/home' - ) - const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}` - const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath) - // Why: login-shell cleanup turns `exit 0` into status 1, so fall through. - execFileSync( - 'wsl.exe', - [ - '-d', - distro, - '--exec', - 'bash', - '-lc', - [ - 'set -e', - `if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - `elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`, - 'else', - `mkdir -p ${quotePosixShell(activeLinuxParentPath)}`, - `rm -rf -- ${quotePosixShell(nextLinuxPath)}`, - `ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`, - `mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`, - 'fi' - ].join('\n') - ], - // wsl.exe is console-subsystem: without this a GUI-launched Orca flashes - // a conhost and steals foreground for up to the timeout (#10488). - { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true } - ) - } - - private dirnameLinuxPath(value: string): string { - const index = value.lastIndexOf('/') - return index > 0 ? value.slice(0, index) : '/' - } - - private joinWslPath(basePath: string, ...segments: string[]): string { - return parseWslUncPath(basePath) - ? pathWin32.join(basePath, ...segments) - : join(basePath, ...segments) - } - - private resolveWslDefaultTarget( - target: CodexAccountSelectionTarget - ): CodexAccountSelectionTarget { - if (target.runtime !== 'wsl' || target.wslDistro?.trim()) { - return target - } - const defaultDistro = getDefaultWslDistro() - return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target - } - - private getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null { - const home = this.getWslSystemCodexHomePath(target) - return home ? this.joinWslPath(home, 'auth.json') : null - } - - private seedWslRuntimeHome( - runtimeHomePath: string, - activeAccount: CodexManagedAccount | null, - distro: string - ): void { - const runtimeConfigPath = join(runtimeHomePath, 'config.toml') - if (existsSync(runtimeConfigPath)) { - return - } - - const candidateHomes = [ - activeAccount?.managedHomePath, - this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro }) - ].filter((value): value is string => Boolean(value)) - for (const homePath of candidateHomes) { - const configPath = join(homePath, 'config.toml') - if (existsSync(configPath)) { - writeFileAtomically( - runtimeConfigPath, - prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath) - ) - return - } - } - } - - private findManagedAccountForRuntimeAuth( - runtimeAuthContents: string, - expectedAccountId?: string - ): CodexReadBackMatch { - const matches: { - account: CodexManagedAccount - managedAuthPath: string - managedAuthContents: string - }[] = [] - let unreadableHomeCouldOwnRuntimeAuth = false - for (const account of this.store.getSettings().codexManagedAccounts) { - if (expectedAccountId && account.id !== expectedAccountId) { - continue - } - const managedAuthPath = join(account.managedHomePath, 'auth.json') - if (!existsSync(managedAuthPath)) { - continue - } - let managedAuthContents: string - try { - managedAuthContents = readFileSync(managedAuthPath, 'utf-8') - } catch { - // Why: an unreadable home can never be compared, but letting the read - // throw abandons the scan for every other account — dropping a refresh - // the runtime home holds for one of them. Only its record can rule it - // out as the owner; when it cannot, the scan is no longer unambiguous. - if ( - !expectedAccountId && - codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account) - ) { - unreadableHomeCouldOwnRuntimeAuth = true - } - continue - } - if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) { - matches.push({ account, managedAuthPath, managedAuthContents }) - } - } - - if (unreadableHomeCouldOwnRuntimeAuth) { - return { kind: 'ambiguous' } - } - if (matches.length === 1) { - return { kind: 'matched', ...matches[0] } - } - return { kind: matches.length === 0 ? 'none' : 'ambiguous' } - } - - private runtimeAuthMatchesSystemDefaultIdentity( - runtimeAuthContents: string, - systemDefaultAuthContents: string - ): boolean { - return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents) - } - - private runtimeAuthIsFresher(runtimeAuthContents: string, managedAuthContents: string): boolean { - return codexAuthIsFresher(runtimeAuthContents, managedAuthContents) - } - - private safeMigrateLegacySharedAuth(): void { - const settings = this.store.getSettings() - try { - migrateLegacySharedAuthToPerAccountHome({ - activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, - hostAccounts: settings.codexManagedAccounts.filter( - (account) => !this.getWslManagedHomePath(account) - ), - managedAccountsRoot: this.getManagedAccountsRoot(), - metadataDir: this.getRuntimeMetadataDir(), - sharedRuntimeHome: this.getRuntimeHomePath(), - systemCodexHome: getSystemCodexHomePath() - }) - } catch (error) { - // Why: an inconclusive identity, ownership, or filesystem result must - // leave the marker absent so the next startup can retry safely. - console.warn('[codex-runtime-home] Failed to migrate legacy shared Codex auth:', error) - } - } - - private safeMigrateLegacyManagedState(): void { - try { - this.migrateLegacyManagedStateIfNeeded() - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy managed Codex state:', error) - } - } - - private safeMigrateLegacyActiveHomePointer(): void { - try { - const activeHomePath = this.getLegacyHostActiveHomePath() - if (!this.legacyActiveHomePathExists(activeHomePath)) { - return - } - this.repointLegacyActiveHomePointer(activeHomePath, this.getRuntimeHomePath()) - } catch (error) { - console.warn('[codex-runtime-home] Failed to migrate legacy active Codex home:', error) - } - } - - private getRuntimeHomePath(): string { - return getOrcaManagedCodexHomePath() - } - - /** - * Resolves the managed home the config mirror actually targets for the - * current HOST selection, or null when no mirror runs for it. - * - * Read-only on purpose: unlike the launch and quota-fetch paths this prepares - * nothing and creates no directories, so surfacing sync health cannot alter - * the state it is reporting on. Returns null for the system default on the - * real-home lane, which runs Codex directly against ~/.codex — there is no - * mirror there, so there is nothing that can fall behind. - */ - getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus { - const selfContainedAccount = this.getSelfContainedManagedHostAccount() - if (selfContainedAccount) { - const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount) - if (resolved.kind === 'indeterminate') { - // Why: `null` here is a positive claim that no mirror exists, which the - // status channel reports as healthy. An unreadable home is not that. - return { kind: 'unavailable' } - } - return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null } - } - if (this.isHostSystemDefaultRealHome()) { - return { kind: 'ready', homePath: null } - } - return { - kind: 'ready', - homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') - } - } - - private getRuntimeAuthPath(): string { - return join(this.getRuntimeHomePath(), 'auth.json') - } - - private getSystemDefaultSnapshotPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-auth.json') - } - - private getRuntimeLogoutMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'system-default-runtime-logout.json') - } - - private getSharedRuntimeAuthProvenancePath(): string { - return join(this.getRuntimeMetadataDir(), 'shared-runtime-auth-provenance.json') - } - - private getRuntimeMetadataDir(): string { - const metadataDir = join(app.getPath('userData'), 'codex-runtime-home') - mkdirSync(metadataDir, { recursive: true }) - return metadataDir - } - - private getLegacyHostActiveHomePath(): string { - return join(this.getRuntimeMetadataDir(), 'active', 'host', 'home') - } - - private getMigrationMarkerPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-v1.json') - } - - private getMigrationDiagnosticsPath(): string { - return join(this.getRuntimeMetadataDir(), 'migration-diagnostics.jsonl') - } - - private getManagedAccountsRoot(): string { - return join(app.getPath('userData'), 'codex-accounts') - } - - private repointLegacyActiveHomePointer(activeHomePath: string, runtimeHomePath: string): void { - if (this.activeHomeAlreadyPointsToRuntimeHome(activeHomePath, runtimeHomePath)) { - return - } - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - return - } - - mkdirSync(runtimeHomePath, { recursive: true }) - mkdirSync(dirname(activeHomePath), { recursive: true }) - const nextLinkPath = `${activeHomePath}.next-${process.pid}-${Date.now()}` - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - try { - symlinkSync( - runtimeHomePath, - nextLinkPath, - process.platform === 'win32' && lstatSync(runtimeHomePath).isDirectory() - ? 'junction' - : undefined - ) - try { - renameSync(nextLinkPath, activeHomePath) - } catch (error) { - if (!this.legacyActiveHomeLinkIsReplaceable(activeHomePath)) { - throw error - } - this.removeLegacyActiveHomeLinkIfOwned(activeHomePath) - renameSync(nextLinkPath, activeHomePath) - } - } finally { - this.removeLegacyActiveHomeLinkIfOwned(nextLinkPath) - } - } - - private activeHomeAlreadyPointsToRuntimeHome( - activeHomePath: string, - runtimeHomePath: string - ): boolean { - try { - return this.linkTargetsMatch(readlinkSync(activeHomePath), activeHomePath, runtimeHomePath) - } catch { - return false - } - } - - private linkTargetsMatch( - linkTarget: string, - linkPath: string, - expectedTargetPath: string - ): boolean { - const resolvedLinkTarget = isAbsolute(linkTarget) - ? resolve(linkTarget) - : resolve(dirname(linkPath), linkTarget) - return resolvedLinkTarget === resolve(expectedTargetPath) - } - - private legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean { - try { - const stat = lstatSync(activeHomePath) - return stat.isSymbolicLink() || this.isWindowsReadableLink(activeHomePath) - } catch { - return true - } - } - - private legacyActiveHomePathExists(activeHomePath: string): boolean { - try { - lstatSync(activeHomePath) - return true - } catch { - return false - } - } - - private removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void { - try { - const stat = lstatSync(activeHomePath) - if (stat.isSymbolicLink()) { - unlinkSync(activeHomePath) - } else if (this.isWindowsReadableLink(activeHomePath)) { - rmdirSync(activeHomePath) - } - } catch { - // Missing or inaccessible temporary links are handled by the caller. - } - } - - private isWindowsReadableLink(targetPath: string): boolean { - if (process.platform !== 'win32') { - return false - } - try { - readlinkSync(targetPath) - return true - } catch { - return false - } - } - - private migrateLegacyManagedStateIfNeeded(): void { - if (existsSync(this.getMigrationMarkerPath())) { - return - } - - const managedHomes = this.getLegacyManagedHomes() - for (const managedHomePath of managedHomes) { - const accountId = parse(relative(this.getManagedAccountsRoot(), managedHomePath)).dir.split( - /[\\/]/ - )[0] - if (!accountId) { - continue - } - this.migrateLegacyHistory(managedHomePath) - this.migrateLegacySessions(managedHomePath, accountId) - } - - // Why: migration is one-shot; re-importing every startup would replay stale managed-home state into the shared runtime. - writeFileAtomically( - this.getMigrationMarkerPath(), - `${JSON.stringify({ completedAt: Date.now(), migratedHomeCount: managedHomes.length })}\n` - ) - } - - private getLegacyManagedHomes(): string[] { - const managedAccountsRoot = this.getManagedAccountsRoot() - if (!existsSync(managedAccountsRoot)) { - return [] - } - - const accountEntries = readdirSync(managedAccountsRoot, { withFileTypes: true }) - const managedHomes: string[] = [] - for (const entry of accountEntries) { - if (!entry.isDirectory()) { - continue - } - const managedHomePath = join(managedAccountsRoot, entry.name, 'home') - if (existsSync(join(managedHomePath, '.orca-managed-home'))) { - managedHomes.push(managedHomePath) - } - } - return managedHomes.sort() - } - - private migrateLegacyHistory(managedHomePath: string): void { - const legacyHistoryPath = join(managedHomePath, 'history.jsonl') - if (!existsSync(legacyHistoryPath)) { - return - } - - const runtimeHistoryPath = join(this.getRuntimeHomePath(), 'history.jsonl') - const existingLines = existsSync(runtimeHistoryPath) - ? readFileSync(runtimeHistoryPath, 'utf-8').split('\n').filter(Boolean) - : [] - const mergedLines = [...existingLines] - const seenLines = new Set(existingLines) - for (const line of readFileSync(legacyHistoryPath, 'utf-8').split('\n')) { - if (!line || seenLines.has(line)) { - continue - } - seenLines.add(line) - mergedLines.push(line) - } - - if (mergedLines.length === 0) { - return - } - writeFileAtomically(runtimeHistoryPath, `${mergedLines.join('\n')}\n`) - } - - private migrateLegacySessions(managedHomePath: string, accountId: string): void { - const legacySessionsRoot = join(managedHomePath, 'sessions') - if (!existsSync(legacySessionsRoot)) { - return - } - - const runtimeSessionsRoot = join(this.getRuntimeHomePath(), 'sessions') - mkdirSync(runtimeSessionsRoot, { recursive: true }) - for (const legacyFilePath of this.listFilesRecursively(legacySessionsRoot)) { - const relativePath = relative(legacySessionsRoot, legacyFilePath) - const runtimeFilePath = join(runtimeSessionsRoot, relativePath) - mkdirSync(dirname(runtimeFilePath), { recursive: true }) - if (!existsSync(runtimeFilePath)) { - copyFileSync(legacyFilePath, runtimeFilePath) - continue - } - - const legacyContents = readFileSync(legacyFilePath) - const runtimeContents = readFileSync(runtimeFilePath) - if (runtimeContents.equals(legacyContents)) { - continue - } - - const preservedPath = this.getPreservedLegacySessionPath(runtimeFilePath, accountId) - copyFileSync(legacyFilePath, preservedPath) - this.appendMigrationDiagnostic({ - type: 'session-conflict', - accountId, - runtimeFilePath, - preservedPath - }) - } - } - - private listFilesRecursively(rootPath: string): string[] { - const stat = statSync(rootPath) - if (!stat.isDirectory()) { - return [rootPath] - } - - const files: string[] = [] - for (const entry of readdirSync(rootPath, { withFileTypes: true })) { - const childPath = join(rootPath, entry.name) - if (entry.isDirectory()) { - this.appendListedFiles(files, this.listFilesRecursively(childPath)) - continue - } - if (entry.isFile()) { - files.push(childPath) - } - } - return files.sort() - } - - private appendListedFiles(target: string[], source: readonly string[]): void { - // Why: tolerate directories larger than V8's argument limit for spread calls. - for (const filePath of source) { - target.push(filePath) - } - } - - private getPreservedLegacySessionPath(runtimeFilePath: string, accountId: string): string { - const extension = extname(runtimeFilePath) - const basename = runtimeFilePath.slice(0, runtimeFilePath.length - extension.length) - return `${basename}.orca-legacy-${accountId}${extension}` - } - - private appendMigrationDiagnostic(record: Record): void { - const diagnosticsPath = this.getMigrationDiagnosticsPath() - try { - appendFileSync(diagnosticsPath, `${JSON.stringify(record)}\n`, { encoding: 'utf-8' }) - } catch (error) { - // Why: diagnostics must not fail the one-shot migration after the session file is already preserved. - console.warn('[codex-runtime-home] Failed to append migration diagnostic:', error) - } - } - - private captureSystemDefaultSnapshot(options: { force: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - if (!options.force && existsSync(snapshotPath)) { - return - } - - const runtimeAuthPath = join(getSystemCodexHomePath(), 'auth.json') - const snapshot: CodexSystemDefaultSnapshot = { - authJson: existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null - } - writeFileAtomically(snapshotPath, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) - } - - private syncRuntimeAuthWithSystemDefault(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (!existsSync(runtimeAuthPath)) { - return - } - - try { - const runtimeAuth = readFileSync(runtimeAuthPath, 'utf-8') - const provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - if (provenance?.owner === 'managed') { - this.captureSystemDefaultSnapshot({ force: true }) - if (!existsSync(systemDefaultAuthPath)) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - this.writeRuntimeAuth(readFileSync(systemDefaultAuthPath, 'utf-8'), { - owner: 'system-default' - }) - return - } - const { - ownershipProven: systemDefaultOwnershipProven, - mirroredAuthJson: mirroredSystemDefaultAuth - } = this.resolveSystemDefaultMirrorClaim(runtimeAuth, provenanceStatus) - if (!existsSync(systemDefaultAuthPath)) { - if (mirroredSystemDefaultAuth !== null && runtimeAuth === mirroredSystemDefaultAuth) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - this.clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath) - } - return - } - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - if (runtimeAuth === systemDefaultAuth) { - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - if ( - systemDefaultOwnershipProven && - mirroredSystemDefaultAuth !== null && - systemDefaultAuth === mirroredSystemDefaultAuth && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, mirroredSystemDefaultAuth) - ) { - // Why: Codex refreshes tokens in the runtime CODEX_HOME; read that back to ~/.codex so the next sync won't clobber fresh creds with stale ones. - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(runtimeAuth, { owner: 'system-default' }) - return - } - // Why: mirror external logins/logouts into Orca's runtime home so unmanaged Codex sessions keep matching the current system-default state. - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - } catch (error) { - console.warn('[codex-runtime-home] Failed to sync system-default auth:', error) - } - } - - private syncLegacySharedSystemDefaultAuthForRetainedPanes(): void { - if (this.sharedAuthRefreshBlockedByManagedTransition || this.lastSyncedAccountId !== null) { - this.sharedAuthRefreshBlockedByManagedTransition = false - return - } - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - let provenanceStatus = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'managed' - ) { - const restoredProvenance = this.restoreUntouchedSystemDefaultProvenance( - provenanceStatus.provenance - ) - if (restoredProvenance) { - provenanceStatus = { kind: 'committed', provenance: restoredProvenance } - } - } - if ( - provenanceStatus.kind === 'fenced' || - (provenanceStatus.kind === 'committed' && provenanceStatus.provenance.owner === 'managed') - ) { - return - } - const systemAuth = this.readSystemDefaultAuth() - if (!existsSync(runtimeAuthPath)) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const knownSystemAuthBaseline = - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' - ? provenanceStatus.provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson) - : undefined - if (systemAuth === null) { - if ( - provenanceStatus.kind === 'committed' && - provenanceStatus.provenance.owner === 'system-default' && - provenanceStatus.provenance.authJson === null && - logoutMarkerStatus.kind === 'applies' && - snapshot?.authJson === null - ) { - this.lastWrittenAuthJson = null - return - } - // Why: commit a crashed logout before a managed transition can discard its recovery baseline. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if ( - logoutMarkerStatus.kind === 'system-default-changed' || - (knownSystemAuthBaseline !== undefined && knownSystemAuthBaseline !== systemAuth) - ) { - const replaced = this.writeRuntimeAuth( - systemAuth, - { - owner: 'system-default' - }, - { expectedContents: null } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } - return - } - const runtimeAuthBeforeSync = readFileSync(runtimeAuthPath, 'utf-8') - const snapshot = this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath()) - const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null - const knownSharedAuth = - provenance?.owner === 'system-default' - ? provenance.authJson - : provenanceStatus.kind === 'missing' - ? (this.lastWrittenAuthJson ?? snapshot?.authJson ?? null) - : null - // Why: only bytes Orca can prove it wrote belong to the compatibility - // mirror; retained Codex or a managed transition owns every other value. - if (knownSharedAuth === null) { - return - } - const sharedAuthOwnedBySystemDefault = - runtimeAuthBeforeSync === knownSharedAuth || - (provenance?.owner === 'system-default' && - systemAuth === null && - this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthBeforeSync, knownSharedAuth)) - if (!sharedAuthOwnedBySystemDefault) { - return - } - if (systemAuth === null) { - removeFileAtomicallyIfUnchanged(runtimeAuthPath, runtimeAuthBeforeSync) - if (existsSync(runtimeAuthPath)) { - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - return - } - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - return - } - if (runtimeAuthBeforeSync !== knownSharedAuth) { - return - } - const replaced = this.writeRuntimeAuth( - systemAuth, - { owner: 'system-default' }, - { expectedContents: runtimeAuthBeforeSync } - ) - if (replaced) { - this.captureSystemDefaultSnapshot({ force: true }) - } - } catch (error) { - console.warn('[codex-runtime-home] Failed to refresh retained-pane auth:', error) - } - } - - private restoreSystemDefaultSnapshot(options: { detectExternalLogin: boolean }): void { - const snapshotPath = this.getSystemDefaultSnapshotPath() - const runtimeAuthPath = this.getRuntimeAuthPath() - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - if (existsSync(systemDefaultAuthPath)) { - const systemDefaultAuth = readFileSync(systemDefaultAuthPath, 'utf-8') - this.captureSystemDefaultSnapshot({ force: true }) - this.writeRuntimeAuth(systemDefaultAuth, { owner: 'system-default' }) - return - } - - if (options.detectExternalLogin && !existsSync(runtimeAuthPath)) { - // Why: with Orca owning CODEX_HOME, a deleted runtime auth.json is a local logout, not a cue to restore the user's real ~/.codex snapshot. - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (options.detectExternalLogin) { - // Why: if ~/.codex/auth.json vanished while a managed account was selected, switching back must preserve that external system-default logout. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - - if (!existsSync(snapshotPath)) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - const snapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!snapshot) { - console.warn('[codex-runtime-home] Ignoring invalid system-default auth snapshot') - rmSync(snapshotPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - const refreshedSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (!refreshedSnapshot) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - if (refreshedSnapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(refreshedSnapshot.authJson, { owner: 'system-default' }) - return - } - if (snapshot.authJson === null) { - rmSync(runtimeAuthPath, { force: true }) - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ owner: 'system-default', authJson: null }) - return - } - this.writeRuntimeAuth(snapshot.authJson, { owner: 'system-default' }) - } - - private writeSystemDefaultAuth(contents: string): void { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - mkdirSync(dirname(systemDefaultAuthPath), { recursive: true }) - writeFileAtomically(systemDefaultAuthPath, contents, { mode: 0o600 }) - this.ensureOwnerOnlyMode(systemDefaultAuthPath) - } - - private clearRuntimeAuthAfterSystemDefaultLogout(runtimeAuthPath: string): void { - // Why: a vanished ~/.codex auth means external logout for unmanaged sessions, even if runtime auth already refreshed in Orca's CODEX_HOME. - rmSync(runtimeAuthPath, { force: true }) - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker() - this.lastWrittenAuthJson = null - this.persistSharedRuntimeAuthProvenance({ - owner: 'system-default', - authJson: null - }) - } - - private readSystemDefaultAuth(): string | null { - const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json') - return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null - } - - private writeRuntimeAuth( - contents: string, - owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string }, - options?: { expectedContents: string | null } - ): boolean { - // Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it. - const runtimeAuthPath = this.getRuntimeAuthPath() - if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) { - return false - } - const provenance: CodexSharedRuntimeAuthProvenance = - owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner - const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents) - if (runtimeAuthComparison === null) { - // Why: an unreadable runtime auth.json may hold a token Codex rotated a - // moment ago. Treating "could not read" as "differs" sent execution to the - // unconditional write below, consuming that rotation and logging the user - // out for good. Refuse; the next sync retries. - return false - } - const runtimeAuthAlreadyMatches = runtimeAuthComparison - if ( - runtimeAuthAlreadyMatches && - this.sharedRuntimeAuthProvenanceMatches( - this.resolveSharedRuntimeAuthProvenanceStatus(), - provenance - ) - ) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.clearRuntimeLogoutMarker() - return true - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson: contents - }) - if (runtimeAuthAlreadyMatches) { - this.ensureOwnerOnlyMode(runtimeAuthPath) - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - const replaced = options - ? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, { - mode: 0o600 - }) - : (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true) - if (!replaced) { - return false - } - this.lastWrittenAuthJson = contents - this.persistSharedRuntimeAuthProvenance(provenance) - this.clearRuntimeLogoutMarker() - return true - } - - private writeRuntimeAuthAtPath(authPath: string, contents: string): void { - if (this.fileContentsEqual(authPath, contents)) { - this.ensureOwnerOnlyMode(authPath) - return - } - mkdirSync(dirname(authPath), { recursive: true }) - writeFileAtomically(authPath, contents, { mode: 0o600 }) - } - - /** - * `true`/`false` only when the bytes were actually read; `null` when the file - * could not be read at all. The old `catch { return false }` reported "these - * differ" for a file nobody could open, and every caller reads that as - * permission to write. - */ - private compareFileContents(targetPath: string, contents: string): boolean | null { - try { - return readFileSync(targetPath, 'utf-8') === contents - } catch (error) { - return isDefinitiveAbsence(error) ? false : null - } - } - - private fileContentsEqual(targetPath: string, contents: string): boolean { - return this.compareFileContents(targetPath, contents) === true - } - - private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean { - if (expectedContents === null) { - // Why: `!existsSync` does report `true` for a locked file, but this branch - // is not where that matters — the write it guards is - // `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the - // real file and refuses on its own. Classifying here would be a guard no - // test can drive. - return !existsSync(targetPath) - } - return this.fileContentsEqual(targetPath, expectedContents) - } - - private ensureOwnerOnlyMode(targetPath: string): void { - if (process.platform === 'win32') { - return - } - try { - chmodSync(targetPath, 0o600) - } catch { - /* Best effort: the next atomic write will set the restrictive mode. */ - } - } - - private getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus { - const marker = this.readRuntimeLogoutMarker() - if (!marker) { - return { kind: 'missing' } - } - const systemDefaultAuthJson = this.readSystemDefaultAuth() - if (systemDefaultAuthJson === marker.systemDefaultAuthJson) { - return { kind: 'applies' } - } - this.clearRuntimeLogoutMarker() - return { kind: 'system-default-changed', systemDefaultAuthJson } - } - - private persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void { - const marker: CodexRuntimeLogoutMarker = { - systemDefaultAuthJson, - loggedOutAt: Date.now() - } - writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, { - mode: 0o600 - }) - } - - private readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null { - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown - } catch { - return null - } - if ( - !parsed || - typeof parsed !== 'object' || - Array.isArray(parsed) || - !('systemDefaultAuthJson' in parsed) || - !('loggedOutAt' in parsed) - ) { - return null - } - const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown } - if ( - (marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') || - typeof marker.loggedOutAt !== 'number' - ) { - return null - } - return marker as CodexRuntimeLogoutMarker - } - - private clearRuntimeLogoutMarker(): void { - rmSync(this.getRuntimeLogoutMarkerPath(), { force: true }) - } - - private persistSharedRuntimeAuthProvenance( - provenance: CodexSharedRuntimeAuthProvenanceFile - ): void { - writeFileAtomically( - this.getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify(provenance, null, 2)}\n`, - { mode: 0o600 } - ) - } - - private markSharedRuntimeAuthManaged(accountId: string): void { - const status = this.resolveSharedRuntimeAuthProvenanceStatus() - if ( - status.kind === 'committed' && - status.provenance.owner === 'managed' && - status.provenance.accountId === accountId - ) { - return - } - const runtimeAuthJson = this.readRuntimeAuthForProvenance() - const systemDefaultBaseline = this.getUntouchedSystemDefaultBaseline(status, runtimeAuthJson) - const provenance: CodexSharedRuntimeAuthProvenance = { - owner: 'managed', - accountId, - ...(systemDefaultBaseline ? { systemDefaultBaseline } : {}) - } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: provenance, - runtimeAuthJson - }) - if (this.readRuntimeAuthForProvenance() === runtimeAuthJson) { - this.persistSharedRuntimeAuthProvenance(provenance) - } - } - - private getUntouchedSystemDefaultBaseline( - status: CodexSharedRuntimeAuthProvenanceStatus, - runtimeAuthJson: string | null - ): { authJson: string | null } | null { - if (status.kind !== 'committed') { - return null - } - const baseline = - status.provenance.owner === 'system-default' - ? { authJson: status.provenance.authJson } - : status.provenance.systemDefaultBaseline - return baseline && runtimeAuthJson === baseline.authJson ? baseline : null - } - - private restoreUntouchedSystemDefaultProvenance( - provenance: Extract - ): Extract | null { - const baseline = provenance.systemDefaultBaseline - if (!baseline || this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - const restored = { owner: 'system-default' as const, authJson: baseline.authJson } - this.persistSharedRuntimeAuthProvenance({ - owner: 'pending', - next: restored, - runtimeAuthJson: baseline.authJson - }) - if (this.readRuntimeAuthForProvenance() !== baseline.authJson) { - return null - } - this.persistSharedRuntimeAuthProvenance(restored) - return restored - } - - private sharedRuntimeAuthProvenanceMatches( - status: CodexSharedRuntimeAuthProvenanceStatus, - expected: CodexSharedRuntimeAuthProvenance - ): boolean { - if (status.kind !== 'committed' || status.provenance.owner !== expected.owner) { - return false - } - return expected.owner === 'system-default' - ? status.provenance.owner === 'system-default' && - status.provenance.authJson === expected.authJson - : status.provenance.owner === 'managed' && status.provenance.accountId === expected.accountId - } - - private resolveSharedRuntimeAuthProvenanceStatus(): CodexSharedRuntimeAuthProvenanceStatus { - const provenancePath = this.getSharedRuntimeAuthProvenancePath() - if (!existsSync(provenancePath)) { - return { kind: 'missing' } - } - let parsed: unknown - try { - parsed = JSON.parse(readFileSync(provenancePath, 'utf-8')) as unknown - } catch { - return { kind: 'fenced' } - } - const committed = this.parseSharedRuntimeAuthProvenance(parsed) - if (committed) { - return { kind: 'committed', provenance: committed } - } - const pending = this.parsePendingSharedRuntimeAuthProvenance(parsed) - if (!pending || this.readRuntimeAuthForProvenance() !== pending.runtimeAuthJson) { - return { kind: 'fenced' } - } - try { - this.persistSharedRuntimeAuthProvenance(pending.next) - return { kind: 'committed', provenance: pending.next } - } catch { - return { kind: 'fenced' } - } - } - - private parseSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const provenance = value as Record - if ( - provenance.owner === 'system-default' && - (typeof provenance.authJson === 'string' || provenance.authJson === null) - ) { - return { owner: 'system-default', authJson: provenance.authJson } - } - if ( - provenance.owner !== 'managed' || - typeof provenance.accountId !== 'string' || - provenance.accountId.length === 0 - ) { - return null - } - const baseline = this.parseSystemDefaultBaseline(provenance.systemDefaultBaseline) - if ('systemDefaultBaseline' in provenance && !baseline) { - return null - } - return { - owner: 'managed', - accountId: provenance.accountId, - ...(baseline ? { systemDefaultBaseline: baseline } : {}) - } - } - - private parseSystemDefaultBaseline(value: unknown): { authJson: string | null } | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const baseline = value as Record - return typeof baseline.authJson === 'string' || baseline.authJson === null - ? { authJson: baseline.authJson } - : null - } - - private parsePendingSharedRuntimeAuthProvenance( - value: unknown - ): CodexSharedRuntimeAuthPendingProvenance | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - const pending = value as Record - const next = this.parseSharedRuntimeAuthProvenance(pending.next) - return pending.owner === 'pending' && - next && - (typeof pending.runtimeAuthJson === 'string' || pending.runtimeAuthJson === null) - ? { owner: 'pending', next, runtimeAuthJson: pending.runtimeAuthJson } - : null - } - - private readRuntimeAuthForProvenance(): string | null { - try { - return readFileSync(this.getRuntimeAuthPath(), 'utf-8') - } catch { - return null - } - } - - private readSystemDefaultSnapshot(snapshotPath: string): CodexSystemDefaultSnapshot | null { - let rawContents: string - try { - rawContents = readFileSync(snapshotPath, 'utf-8') - } catch { - return null - } - try { - const parsed = JSON.parse(rawContents) as unknown - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - 'authJson' in parsed && - (typeof (parsed as { authJson: unknown }).authJson === 'string' || - (parsed as { authJson: unknown }).authJson === null) - ) { - return parsed as CodexSystemDefaultSnapshot - } - // Why: pre-PR snapshots stored raw auth.json; treat objects lacking an authJson wrapper as legacy so upgraders don't lose their auth. - if ( - parsed && - typeof parsed === 'object' && - !Array.isArray(parsed) && - !('authJson' in parsed) - ) { - return { authJson: rawContents } - } - } catch { - return null - } - return null - } - - clearSystemDefaultSnapshot(): void { - rmSync(this.getSystemDefaultSnapshotPath(), { force: true }) - } -} - -// Why: Codex reads this config inside WSL, so relative path settings must anchor to the Linux-side home (verbatim copy breaks load, os error 2). -export function prepareWslRuntimeSeedConfig( - configContents: string, - sourceHomePath: string -): string { - return prepareSystemConfigForFreshRuntimeMirror( - configContents, - parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath - ) } diff --git a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts index 0cb6c02cf3a..1982facd5f3 100644 --- a/src/main/crash-reporting/gpu-crash-diagnostics.test.ts +++ b/src/main/crash-reporting/gpu-crash-diagnostics.test.ts @@ -226,16 +226,26 @@ describe('GpuCrashDiagnosticsRecorder', () => { describe('GPU crash diagnostics production wiring', () => { it('starts diagnostics without delaying safe-graphics fallback', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const listenerStart = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-preflight.ts'), + 'utf8' + ) + const listenerSource = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const listenerStart = listenerSource.indexOf(" app.on('child-process-gone'") expect(listenerStart).toBeGreaterThan(0) - const listener = source.slice(listenerStart, source.indexOf('\n })', listenerStart)) + const listener = listenerSource.slice( + listenerStart, + listenerSource.indexOf('\n })', listenerStart) + ) expect(source).toMatch( /recordBreadcrumb: \(data\) =>\s*recordDurableCrashBreadcrumb\('gpu_crash_hardware', data\)/ ) expect(listener).toMatch( - /const crashedAt = performance\.now\(\)[\s\S]*?void gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ + /const crashedAt = performance\.now\(\)[\s\S]*?void state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?void handleGpuChildCrash\(details\.reason, details\.exitCode \?\? null, crashedAt\)/ ) - expect(listener).not.toMatch(/gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) + expect(listener).not.toMatch(/state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?\.then\(/) }) }) diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts index aec22e2e446..43441250708 100644 --- a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -40,10 +40,13 @@ const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } ] -/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */ +/** Ready-phase `child-process-gone` listener body — the wiring these claims rest on. */ function readChildProcessGoneListener(): string { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const start = source.indexOf("app.on('child-process-gone'") + const source = readFileSync( + join(__dirname, '..', 'startup', 'main-process-ready-runtime.ts'), + 'utf8' + ) + const start = source.indexOf(" app.on('child-process-gone'") expect(start).toBeGreaterThan(0) return source.slice(start, source.indexOf('\n })', start)) } @@ -89,7 +92,7 @@ describe('1.4.190 win32 GPU-child crash cluster', () => { expect(guardStart).toBeGreaterThan(0) expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) expect(listener).toMatch( - /isGpuFallbackCrashCandidate\([\s\S]*?gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ + /isGpuFallbackCrashCandidate\([\s\S]*?state\.gpuCrashDiagnostics\?\.record\(\)[\s\S]*?handleGpuChildCrash\(/ ) // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which // re-couples recovery to the suppression decision, so pin the guard to that check alone. diff --git a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts index 3c93f7992ff..23707754ff8 100644 --- a/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts +++ b/src/main/crash-reporting/gpu-fallback-recovered-launch.test.ts @@ -108,11 +108,18 @@ describe('handleGpuFallbackRecoveredLaunch', () => { describe('recovered safe-graphics production wiring', () => { it('prompts only after the recovered window is shown and persists both consent states', () => { - const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - expect(source).toMatch( + const windowSource = readFileSync( + join(__dirname, '..', 'startup', 'main-window-controller.ts'), + 'utf8' + ) + const lifecycleSource = readFileSync( + join(__dirname, '..', 'startup', 'gpu-lifecycle.ts'), + 'utf8' + ) + expect(windowSource).toMatch( /window\.once\('show',[\s\S]*?presentGpuFallbackRecoveredLaunchPrompt\(window\)/ ) - expect(source).toMatch( + expect(lifecycleSource).toMatch( /persistMarker:[\s\S]*?userConfirmed: false[\s\S]*?confirmMarker:[\s\S]*?userConfirmed: true/ ) }) diff --git a/src/main/headless-automation-dispatcher-source-boundary.test.ts b/src/main/headless-automation-dispatcher-source-boundary.test.ts index d8cc23c28a3..5f7a10fedec 100644 --- a/src/main/headless-automation-dispatcher-source-boundary.test.ts +++ b/src/main/headless-automation-dispatcher-source-boundary.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-automations.ts'), 'utf8') function sourceBetween(startPattern: string, endPattern: string): string { const start = source.indexOf(startPattern) diff --git a/src/main/index.ts b/src/main/index.ts index 0780aefd9b9..c66191c82b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3800 +1,63 @@ -/* eslint-disable max-lines -- main-process entry point; owns app lifecycle, service wiring, window creation, and hook/daemon startup with no cleaner split seam. */ -import { existsSync, statSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { isAbsolute, join } from 'node:path' -import os from 'node:os' -import { - app, - BrowserWindow, - dialog, - ipcMain, - nativeTheme, - powerMonitor, - type Tray, - session -} from 'electron' -import { applyMacPressAndHoldDefaultAtStartup } from './macos-press-and-hold-default' -import { initTccPromptNotice, stopTccPromptNotice } from './macos-tcc-prompt-notice' -import { electronApp, is } from '@electron-toolkit/utils' -import { - Store, - initDataPath, - getCanonicalUserDataPath, - migrateMobilePairingDataToCanonicalUserDataPath -} from './persistence' -import { setAppEnvironment } from '../shared/app-environment' -import { ElectronAppEnvironment } from './host/electron-app-environment' -import { setPtyHostBindings } from './ipc/pty-host-bindings' -import { electronRuntimeDesktopSurface } from './host/electron-runtime-desktop-surface' -import { setRuntimeDesktopSurface } from './runtime/runtime-desktop-surface' -import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-commands' -import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory' -import { electronHttpClient } from './host/electron-http-client' -import { setMainHttpClient } from './network/http-client' -import { electronSpeechServiceFactories } from './host/electron-speech-services' -import { setSpeechServiceFactories } from './speech/speech-runtime-service' -import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' -import { setSecretStore } from '../shared/secret-store' -import { ElectronSecretStore } from './host/electron-secret-store' -import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' -import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' -import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' -import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' -import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' -import { applyAppIcon } from './app-icon' -import { relaunchApp } from './app-relaunch' -import { StatsCollector, initStatsPath } from './stats/collector' -import { initSshHostKeyStoreFile } from './ssh/ssh-host-key-store' -import { AgentSessionTransitionRecorder } from './stats/agent-session-transition-recorder' -import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store' -import { CodexUsageStore, initCodexUsagePath } from './codex-usage/store' -import { OpenCodeUsageStore, initOpenCodeUsagePath } from './opencode-usage/store' -import { - killAllPty, - clearProviderPtyState, - getPtyIdForPaneKey, - registerPaneKeyTeardownListener, - getLocalPtyProvider, - getSshPtyProvider, - registerHeadlessPtyRuntime, - type CodexHomeLaunchContext -} from './ipc/pty' -import { - initDaemonPtyProvider, - disconnectDaemon, - getDaemonProvider, - listLiveDaemonPtyIds, - shutdownDaemon -} from './daemon/daemon-init' -import { - type CodexPaneHomeRoute, - getCodexPaneAccount, - hasRecordedManagedHostCodexPane, - isCodexPaneHomeRouteProvenAwayFromSharedHome, - reconcileCodexPaneAccountsWithLivePtys -} from './codex/codex-pane-account-registry' -import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher' -import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher' -import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade' -import { registerCoreHandlers } from './ipc/register-core-handlers/register-core-handlers' -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 { recordManagedHookInstallFailure } from './agent-hooks/install-telemetry' -import { - indexPersistedPaneKeyPtyIds, - isLocalExecutionHost, - resolveAgentWorkspaceExecutionHostId, - sweepRestoredSubagentsWithoutLiveAgent -} from './agent-hooks/restored-subagent-liveness-sweep' -import { - installManagedAgentHooks, - isAgentStatusHooksEnabled, - removeManagedAgentHooksAsync, - resolveStartupManagedHookAction, - shouldInstallStartupManagedAgentHook, - shouldContinueManagedHookStartup -} from './agent-hooks/managed-agent-hook-controls' -import { initCohortClassifier } from './telemetry/cohort-classifier' -import { initOnboardingCohortClassifier } from './telemetry/onboarding-cohort-classifier' -import { resolveConsent } from './telemetry/consent' -import { triggerStartupNotificationRegistration } from './ipc/startup-notification-registration' -import { OrcaRuntimeService, type RuntimeWorktreeLifecycleEvent } from './runtime/orca-runtime' -import { ArtifactCloudService } from './artifacts/artifact-cloud-service' -import { SkillCloudService } from './skills/skill-cloud-service' -import { recoverPendingSkillTransactions } from './skills/skill-transaction-startup-recovery' -import { isArtifactSharingEnabled } from '../shared/artifact-sharing-gate' -import { loadAgentSessionClaimSigner } from './runtime/agent-session-claim-identity' -import { - fingerprintOrchestrationPeer, - type OrchestrationEnvironmentTransport -} from './runtime/orchestration/environment-transport' -import { callRuntimeEnvironment } from './ipc/runtime-environment-transport-routing' -import { resolveEnvironment } from '../shared/runtime-environment-store' -import { getPreferredPairingOffer } from '../shared/runtime-environments' -import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' -import { - recordRuntimeRpcStartFailure, - showRuntimeRpcStartupFailureDialog -} from './runtime/runtime-rpc-startup-failure' -import { resolveAdvertisedPairingEndpoint } from './runtime/pairing-endpoint' -import { ServeReadinessPublisher } from './server/serve-readiness' -import { reserveServeStdoutForReadiness } from './server/serve-stdout-boundary' -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, - registerAppMenu, - rebuildAppMenu -} from './menu/register-app-menu' -import { createGpuAccelerationAboutPanelOptions } from './menu/gpu-acceleration-about-panel' -import { - checkForRemoteServerUpdate, - checkForUpdatesFromMenu, - downloadRemoteServerUpdate, - getRemoteServerUpdaterSnapshot, - installRemoteServerUpdate, - isQuittingForUpdate, - resolveUpdateInstallMode -} from './updater' -import { configureRemoteServerUpdater } from './runtime/remote-server-updater' -import type { UpdateCheckOptions } from '../shared/update-status-types' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { - installServeSupervisorDisconnectQuit, - notifyServeSupervisorReady -} from './serve-update-handoff' -import { - configureElectronNetworkCompatibility, - configureDevUserDataPath, - configureOrcaUserDataPathEnv, - disableUnsupportedChromiumFeatures, - optOutOfHiddenPageWakeUpThrottling, - enableMainProcessGpuFeatures, - installDevParentDisconnectQuit, - installDevParentSignalQuit, - installDevParentWatchdog, - isDevParentShutdownRequested, - patchPackagedProcessPath, - shouldInstallManagedHooks -} from './startup/configure-process' -import { - installUncaughtPipeErrorGuard, - installUnhandledRejectionLogging -} from './startup/main-process-error-guards' -import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' -import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' -import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' -import { - clearGpuFallbackMarker, - readActiveGpuFallbackMarker, - writeGpuFallbackMarker, - type GpuFallbackMarker, - type GpuFallbackEnvironment, - type WindowsGpuFallbackEnvironment -} from './startup/gpu-fallback-marker' -import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' -import { - DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, - DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - GpuCrashFallbackTracker, - isGpuFallbackCrashCandidate -} from './crash-reporting/gpu-crash-fallback-decision' -import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt' -import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement' -import { GpuCrashDiagnosticsRecorder } from './crash-reporting/gpu-crash-diagnostics' -import { - handleGpuFallbackRecoveredLaunch, - promptForGpuFallbackRecoveredLaunch -} from './crash-reporting/gpu-fallback-recovered-launch' -import { - shouldSuppressDevEducation, - suppressDevEducationForStore -} from './startup/dev-education-suppression' -import { maybeRedirectAppImageCliLaunch } from './startup/appimage-cli-redirect' -import { maybeRedirectPackagedCliEntryLaunch } from './startup/packaged-cli-entry-redirect' -import { startFirstWindowStartupServices } from './startup/first-window-startup-services' -import { recoverLegacyWorkerTerminalsForRendererStartup } from './startup/legacy-worker-renderer-recovery' -import { createWslCliReconciliationStartupBarrier } from './startup/wsl-cli-reconciliation-startup-barrier' -import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './startup/dev-instance-identity' -import { hydrateShellPath, mergePathSegments } from './startup/hydrate-shell-path' -import { createWindowsShellPathHydration } from './startup/windows-shell-path-hydration' -import { - startWindowsDesktopBeforeShellPathReady, - type WindowsDesktopStartupServices -} from './startup/windows-desktop-shell-path-startup' -import { - acquireSingleInstanceLock, - logSingleInstanceLockBypass, - logSingleInstanceLockFailure, - shouldActivateDesktopForSecondInstance, - shouldBypassSingleInstanceLock, - shouldSkipSingleInstanceLock, - SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE -} from './startup/single-instance-lock' -import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' -import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' -import { settledDiffCache } from './git/source-control/git-read-cache-invalidation' +import { app, type BrowserWindow } from 'electron' import { parseSkillShareId } from '../shared/skill-share-link' -import { SkillShareDeepLinkState } from './startup/skill-share-deep-link-state' -import { - isStartupDiagnosticsEnabled, - logStartupDiagnostic, - logStartupMilestone -} from './startup/startup-diagnostics' -import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' -import { probeWindowsInstallDirAcl } from './startup/windows-install-dir-acl-probe' -import { neutralizeLegacyTerminalShimDir } from './pty/legacy-terminal-shim-dir' -import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' -import { registerServeSignalHandlers } from './startup/serve-signal-handlers' -import { - createServeDesktopActivationGate, - settleServeDesktopActivation as settleServeDesktopActivationGate -} from './startup/serve-desktop-activation' -import { RateLimitService } from './rate-limits/service' -import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' -import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' -import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target' -import { getKimiRuntimeTarget, resolveKimiHome } from './kimi/kimi-runtime-home' -import { createAccountRuntimeTargetSettingsSync } from './rate-limits/account-runtime-target-sync' -import { - attachMainWindowServices, - ensureAutoUpdaterConfigured -} from './window/attach-main-window-services' -import { createMainWindow, loadMainWindow } from './window/createMainWindow' -import { shutdownPairedRuntimeBrowserClientHosts } from './browser/paired-runtime-browser-client-host-runtime' -import { - getDashboardPopoutWindow, - zoomDashboardPopoutIfFocused -} from './window/dashboard-popout-window' -import { - createSystemTray, - destroySystemTray, - setMacMenuBarIconVisible, - setTrayAttention, - type SystemTrayOptions -} from './tray/system-tray' import { createMacAppActivationHandler } from './window/macos-app-activation' -import { focusExistingMainWindow, safelyRevealWindow } from './window/focus-existing-window' -import { applyBackgroundActivationPolicy } from './window/foreground-activation-policy' -import { notifyMainWindowBecameVisible } from './window/main-window-visibility' -import { CodexAccountService } from './codex-accounts/service' -import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service' -import { markCodexProjectTrusted } from './agent-trust-presets' import { - normalizeCodexRuntimeSelection, - type CodexAccountSelectionTarget -} from './codex-accounts/runtime-selection' -import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' -import { codexHookService, setSystemCodexHomeHookSweepSuppressed } from './codex/hook-service' -import { reconcileRetainedCodexHookHomes } from './codex/retained-codex-hook-state' -import { - ensureRealHomeCodexHookState, - isRealHomeCodexHookLaneUsable -} from './codex/codex-real-home-hook-install' -import { setCodexTrustGrantTelemetry } from './codex/codex-trust-grant-telemetry' -import { startCodexSessionBackfillInBackground } from './codex/codex-session-backfill' -import { startCodexSessionIndexHealInBackground } from './codex/codex-session-index-heal' -import { - startCodexStateDbBackfillRecoveryInBackground, - stopCodexStateDbBackfillRecoveries -} from './codex/codex-state-db-backfill-recovery' -import { createCodexSessionMigrationScheduler } from './codex/codex-session-migration-scheduler' -import { prepareCodexAiVaultSessionResume } from './codex/codex-ai-vault-session-resume' -import { prepareLegacySharedCodexSessionResume } from './codex/codex-legacy-session-resume' -import { ManagedCodexHomeTemporarilyUnavailableError } from './codex-accounts/host-codex-managed-home-ownership' -import { resolveHostCodexSessionSourceHome } from './codex/codex-session-source-home' -import type { CodexSessionResumePreparation } from './codex/codex-session-resume-home' -import { prepareCodexSessionResume } from './codex/codex-session-resume-preparation' -import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' -import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' -import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' -import { getDefaultWslDistro } from './wsl' -import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash' -import { ClaudeAccountService } from './claude-accounts/service' -import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' -import { - attachClaudeLivePtyPersistence, - onLiveClaudePtysDrained, - seedLiveClaudePtysFromPersistence -} from './claude-accounts/live-pty-gate' -import { StarNagService } from './star-nag/service' -import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' -import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' -import { createHookStatusSessionTabsInvalidator } from './agent-hooks/hook-status-session-tabs-invalidation' -import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' -import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' -import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' -import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename' -import { moveWorktree } from './git/worktree' -import { - configureWindowsHostGitEnvironmentReadiness, - setDefaultWslDistroOverride -} from './git/runner' -import { getRepoIdFromWorktreeId } from '../shared/worktree/id' -import { parseWorkspaceKey } from '../shared/workspace-scope' -import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state' -import { AgentBrowserBridge } from './browser/agent-browser-bridge' -import { configureBrowserClientPageAutomationRuntime } from './browser/browser-client-page-automation-runtime' -import { BrowserClientPageCommandError } from './browser/browser-client-page-command-failure' -import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserCertificateTrustController, browserManager } from './browser/browser-manager' -import { RpcDispatcher } from './runtime/rpc/dispatcher' -import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' -import { browserSessionRegistry } from './browser/browser-session-registry' -import { - applyBrowserSessionProxies, - setBrowserNetworkProxySettingsResolver -} from './browser/browser-session-proxy' -import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' -import { - installDocPreviewProtocolHandler, - registerDocPreviewSchemePrivileges -} from './browser/doc-preview-protocol' -import { registerDocPreviewGrantHandlers } from './ipc/doc-preview-grant-ipc' -import { initializeBrowserClientHostId } from './browser/browser-client-host-id' -import { setUnreadDockBadgeCount } from './dock/unread-badge' -import { AutomationService } from './automations/service' -import { createHeadlessAutomationOutputSnapshotBuffer } from './automations/headless-dispatch' -import { buildHeadlessAutomationWorktreeCreateArgs } from './automations/headless-workspace-create' -import { createRuntimeAutomationRunTerminalObserver } from './automations/runtime-terminal-run-observer' -import { AgentAwakeService } from './agent-awake-service' -import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' -import { registerSystemResumeBroadcast } from './system-resume-broadcast' -import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' -import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' -import { quitTeardownStartGate } from './quit-teardown-start-gate' -import { beginSshShutdown } from './ipc/ssh-shutdown-drain' -import { PluginService } from './plugins/plugin-service' -import { PluginKillListService } from './plugins/plugin-kill-list-service' -import { getPluginsDataDir } from './plugins/plugin-discovery' -import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' -import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' -import { PluginBundledBootstrapCoordinator } from './plugins/plugin-bundled-bootstrap-coordinator' -import { resolveBundledPluginRoot } from './plugins/plugin-bundled-bootstrap' -import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' -import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' -import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' -import { - normalizePluginConsents, - normalizePluginIdList -} from '../shared/plugins/plugin-consent-state' -import { - recordCoalescedCrashBreadcrumb, - recordCrashBreadcrumb -} from './crash-reporting/crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './crash-reporting/durable-crash-breadcrumb' -import { installMainThreadHangWatchdog } from './hang-watchdog/main-thread-hang-watchdog' -import { - consumeHangDetectionMarker, - hangDetectionMarkerPath -} from './hang-watchdog/hang-detection-marker' -import { getMainProcessLifecycleIdentity } from './crash-reporting/main-process-lifecycle-identity' -import { CrashReportStore } from './crash-reporting/crash-report-store' -import { - shouldRecoverRendererAfterProcessGone, - type ExpectedTeardownScope -} from './crash-reporting/process-gone-classification' -import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from './crash-reporting/process-gone-recorder' -import { startCrashpadCapture } from './crash-reporting/crashpad-capture' -import { startPreGoneProcessMetricsSampling } from './crash-reporting/process-gone-diagnostics' -import { resolveExpectedTeardownScope } from './crash-reporting/expected-teardown-state' -import { - advanceSyntheticTitleSpinnerEntries, - getSyntheticTitleSpinnerPaneKeyToStop, - type SyntheticTitleSpinnerEntry -} from './synthetic-title-spinner' -import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' -import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' -import { - getSyntheticAgentTitleProfile, - shouldDriveSyntheticAgentTitleFromHook, - type SyntheticAgentTitleProfile -} from '../shared/synthetic-agent-title' -import type { AgentStatusState } from '../shared/agent-status-types' -import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' -import { isAskUserQuestionTool } from '../shared/agent-question-answered-intent' -import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' -import { - HEADLESS_RUNTIME_WINDOW_ID, - type RuntimeDesktopWindowStatus -} from '../shared/runtime-types' -import { LocalPtyProvider } from './providers/local-pty-provider' -import { KeybindingService } from './keybindings/keybinding-service' -import { - applyElectronProxySettings, - setDefaultProxySessionResolver -} from './network/proxy-settings' -import { handleElectronProxyLogin } from './network/electron-proxy-credentials' -import { installElectronProxyRequestGuard } from './network/electron-proxy-request-guard' -import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' -import { CliInstaller } from './cli/cli-installer' -import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher' -import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation' + focusExistingWindow as focusExistingWindowAction, + setMainWindowOpener +} from './startup/main-window-actions' +import { openMainWindow as openMainWindowController } from './startup/main-window-controller' +import { mainProcessState as state } from './startup/main-process-state' +import { runMainProcessPreflight } from './startup/main-process-preflight' +import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-bootstrap' +import { initializeMainProcessReady } from './startup/main-process-ready' +import { installMainProcessQuitHandlers } from './startup/main-process-quit' +import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock' -let mainWindow: BrowserWindow | null = null -/** Whether a manual app.quit() (Cmd+Q) is in progress; lets the close handler skip the running-process confirmation and go straight to close. */ -let isQuitting = false -let store: Store | null = null -let stats: StatsCollector | null = null -let claudeUsage: ClaudeUsageStore | null = null -let codexUsage: CodexUsageStore | null = null -let openCodeUsage: OpenCodeUsageStore | null = null -let codexAccounts: CodexAccountService | null = null -let codexRuntimeHome: CodexRuntimeHomeService | null = null -let codexSessionMigration: ReturnType | null = null -let claudeAccounts: ClaudeAccountService | null = null -let claudeRuntimeAuth: ClaudeRuntimeAuthService | null = null -let runtime: OrcaRuntimeService | null = null -let rateLimits: RateLimitService | null = null -let runtimeRpc: OrcaRuntimeRpcServer | null = null -const serveReadinessPublisher = new ServeReadinessPublisher() -let desktopRelayService: DesktopRelayService | null = null -let desktopRelayStatus: RelayBrokerStatus = 'offline' -let pendingUnpairedDeviceAuthFailure = false -// Why: gates whether headless serve installs the offscreen browser backend (and advertises browser pane support). -let headlessBrowserDisplayAvailable = false - -let starNag: StarNagService | null = null -let agentAwakeService: AgentAwakeService | null = null -let crashReports: CrashReportStore | null = null -let unsubscribeAgentAwakeStatusChanges: (() => void) | null = null -let unsubscribeSystemResumeBroadcast: (() => void) | null = null -let watcherShutdownPromise: Promise | null = null -let watcherShutdownDone = false -let automations: AutomationService | null = null -let pluginService: PluginService | null = null -let pluginKillListService: PluginKillListService | null = null -let pluginMarketplaceService: PluginMarketplaceService | null = null -let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null -let keybindings: KeybindingService | null = null - -function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { - pluginService?.emitEvent( - event.kind === 'created' ? 'worktree.created' : 'worktree.removed', - event.kind === 'created' - ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } - : { worktreeId: event.worktreeId, path: event.path } - ) -} -// Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). -const expectedRendererReload = createWebContentsTimedFlag() -const recoveryReloadInFlight = createWebContentsTimedFlag() -// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount. -const pendingOpenSettings = createWebContentsTimedFlag() -const skillShareDeepLinks = new SkillShareDeepLinkState() -let firstWindowStartupServicesReady: Promise = Promise.resolve() -let managedWslCliReconciliationReady: Promise = Promise.resolve() -let managedWslCliStartupBarrierReady: Promise = Promise.resolve() -// Why: the serve barrier fails open, so this state tells headless clients a WSL PTY launch may still race an un-migrated registration ('settled' = off-Windows no-op). -let managedWslCliReconciliationStatus: 'pending' | 'settled' | 'failed' = 'settled' -const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ - windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, - threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD -}) -let activeGpuFallbackMarker: GpuFallbackMarker | null = null -let gpuFallbackActiveThisLaunch = false -let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null -const gpuCrashDiagnostics = - process.platform === 'win32' - ? new GpuCrashDiagnosticsRecorder({ - provider: { - getGPUInfo: (infoType) => app.getGPUInfo(infoType), - getGPUFeatureStatus: () => app.getGPUFeatureStatus() - }, - recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) - }) - : null -let localPtyStartupReady: Promise = Promise.resolve() -let localPtyProviderStartupReady: Promise = Promise.resolve() -const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 - -function handleCodexHomePtySpawned(args: { - id: string - codexHomePath: string | null - reattached?: boolean - reattachedHomeRoute?: CodexPaneHomeRoute | null - launchEnv?: NodeJS.ProcessEnv - startedAt?: Date - startedSequence?: number -}): void { - // Why: only shared or ambiguous retained shells can create rollout logs that still need publication. - if (args.reattached && args.startedSequence !== undefined) { - const paneAccount = getCodexPaneAccount(args.id) - const homeRoute = - args.reattachedHomeRoute !== undefined - ? (args.reattachedHomeRoute ?? undefined) - : paneAccount?.homeRoute - if (codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { - codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) - return - } - } - const fullScanRequired = - codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { - reattached: args.reattached, - launchEnv: args.launchEnv - }) ?? null - if (fullScanRequired !== null) { - codexSessionMigration?.beginLaunch( - args.id, - args.reattached === true || fullScanRequired, - args.startedAt, - args.startedSequence - ) - } +function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + return openMainWindowController(options) } -function handlePtyExit(id: string, exitSequence: number): void { - codexSessionMigration?.finishLaunch(id, exitSequence) -} -// Why: on Windows a CLI launch that lost ELECTRON_RUN_AS_NODE would boot the GUI and exit silently; redirect to node mode before the lock gate below. -// Both redirects run before the serve-argv rewrite so they still match on the launch argv verbatim. -// It is load-bearing for the AppImage one: rewriting first replaces the `serve` positional, so its -// command-name lookup finds a port number and strands the launch in an in-process serve. The -// packaged-CLI one matches on the entry path instead, so order cannot affect it either way. -const packagedCliEntryRedirect = maybeRedirectPackagedCliEntryLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (packagedCliEntryRedirect.redirected) { - app.exit(packagedCliEntryRedirect.status) -} -const appImageCliRedirect = maybeRedirectAppImageCliLaunch({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - execPath: process.execPath -}) -if (appImageCliRedirect.redirected) { - app.exit(appImageCliRedirect.status) -} -// Why: extracted AppRun / binary launches can land CLI-form `serve` args on the -// Electron process without the CLI rewrite that injects `--serve` (#12677). -// Guarded so a normal GUI launch keeps its original argv array identity. -if (argvRequestsServeMode(process.argv)) { - process.argv = normalizeServeModeArgv(process.argv) -} -const isServeMode = process.argv.includes('--serve') - -function updateGpuAccelerationAboutPanel(): void { - app.setAboutPanelOptions( - createGpuAccelerationAboutPanelOptions({ - appName: app.name, - appVersion: app.getVersion(), - platform: process.platform, - gpuFallbackActive: gpuFallbackActiveThisLaunch, - gpuFeatureStatus - }) - ) -} - -app.on('gpu-info-update', () => { - gpuFeatureStatus = app.getGPUFeatureStatus() - gpuCrashDiagnostics?.warm() - if (app.isReady()) { - updateGpuAccelerationAboutPanel() - } -}) -if (isServeMode) { - reserveServeStdoutForReadiness() -} -const desktopActivationGate = createServeDesktopActivationGate({ - initialState: isServeMode ? 'initializing' : 'ready', - activateWindow: () => { - // Why: an updater replacement must not resurrect the old app bundle. - if (!isQuittingForUpdate()) { - focusExistingWindow() - } - }, - onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) -}) - -// Kill switch for the first-work on-disk folder rename; the renderer reconciles the id change (migrateWorktreeIdentity) so it isn't mistaken for a deletion. -const ENABLE_FIRST_WORK_FOLDER_RENAME = false - -// Why: inject the index.ts store/runtime singletons so the rename orchestrator stays module-state-free and unit-testable. -function maybeAutoRenameBranchOnFirstWorkFromHook(event: { - paneKey: string - tabId: string | undefined - worktreeId: string | undefined - payload: { state: string; prompt?: string; lastAssistantMessage?: string } - isReplay: boolean | undefined -}): void { - const currentStore = store - const currentRuntime = runtime - if (!currentStore || !currentRuntime) { - return - } - void maybeAutoRenameBranchOnFirstWork( - { - paneKey: event.paneKey, - tabId: event.tabId, - worktreeId: event.worktreeId, - state: event.payload.state, - prompt: event.payload.prompt, - assistantMessage: event.payload.lastAssistantMessage, - isReplay: event.isReplay - }, - { - getSettings: () => currentStore.getSettings(), - getRepo: (repoId) => currentStore.getRepo(repoId), - getAgentEnvResolvers: () => currentRuntime.getCommitMessageAgentEnvironmentResolvers(), - getCurrentDisplayName: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.name - } - return currentStore.getWorktreeMeta(worktreeId)?.displayName - }, - getFolderWorkspacePath: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - return scope?.type === 'folder' - ? currentStore.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath - : undefined - }, - isPendingFirstAgentMessageRename: (worktreeId) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - return ( - currentStore.getFolderWorkspace(scope.folderWorkspaceId) - ?.pendingFirstAgentMessageRename === true - ) - } - return currentStore.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true - }, - canRenameOrcaCreatedBranch: (worktreeId) => { - const meta = currentStore.getWorktreeMeta(worktreeId) - // Why: a user branch could coincidentally match a creature name; only Orca-stamped worktrees are safe to auto-rename. - return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true - }, - setDisplayName: (worktreeId, displayName) => { - rememberBranchRenameFailureOutput(worktreeId, null) - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - name: displayName, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentStore.setWorktreeMeta(worktreeId, { - displayName, - pendingFirstAgentMessageRename: false, - // Success clears the failure badge (redundant with the explicit setRenameError(null)). - firstAgentMessageRenameError: null - }) - }, - renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME - ? (worktreeId, newLeaf) => - renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getSettings: () => currentStore.getSettings(), - migrateWorktreeIdentity: (oldId, newId) => - currentStore.migrateWorktreeIdentity(oldId, newId), - notifyWorktreeRenamed: (repoId, oldId, newId) => - currentRuntime.notifyWorktreeFolderRenamed(repoId, oldId, newId), - pathExists: async (candidate) => existsSync(candidate), - moveWorktree - }) - : undefined, - setRenameError: (worktreeId, error, failureOutput) => { - // Refresh the full-output capture before the dedupe below — a repeat error string is still a fresh run. - rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) - // Skip the write + push when unchanged — most settled worktrees never had an error to clear. - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const current = currentStore.getFolderWorkspace( - scope.folderWorkspaceId - )?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.updateFolderWorkspace(scope.folderWorkspaceId, { - firstAgentMessageRenameError: error - }) - currentRuntime.notifyFolderWorkspaceChanged() - return - } - const current = currentStore.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError - if ((current ?? null) === (error ?? null)) { - return - } - currentStore.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) - // Why: the hook only knows the worktreeId, so derive the repoId notifyBranchRenamed expects. - currentRuntime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) - }, - resolveWorktreeIdForTab: (tabId) => currentStore.getWorktreeIdForTab(tabId), - onRenamed: (repoIdOrWorktreeId) => { - if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { - currentRuntime.notifyFolderWorkspaceChanged() - return - } - currentRuntime.notifyBranchRenamed(repoIdOrWorktreeId) - } - } - ) -} - -const devInstanceIdentity = getDevInstanceIdentity(is.dev) -const devAgentHookEndpointNamespace = devInstanceIdentity.isDev - ? devInstanceIdentity.appUserModelId - : undefined - -installUncaughtPipeErrorGuard() -// Why (issue #9441): without this, one rejected background promise during startup restore kills main silently (exit 1, no crash report). -installUnhandledRejectionLogging() -// Why: expose the app version via process.env so main and the forked daemon can set TERM_PROGRAM_VERSION without importing electron. -process.env.ORCA_APP_VERSION = app.getVersion() -configureRemoteServerUpdater({ - getSnapshot: getRemoteServerUpdaterSnapshot, - check: checkForRemoteServerUpdate, - download: downloadRemoteServerUpdate, - install: installRemoteServerUpdate -}) -patchPackagedProcessPath() -// Why: the sync seed above covers early IPC (homebrew/nix); the async login-shell probe below (packaged only) then adds the user's rc PATH. -if (app.isPackaged && process.platform !== 'win32') { - void hydrateShellPath().then((result) => { - if (result.ok) { - mergePathSegments(result.segments) - return - } - // Why: on failure the seeded fallbacks stay in front. For an nvm user that is - // now their `default` version rather than the newest install, so it is usually - // survivable — but it is still not what their shell would have resolved. Name - // the reason so it shows up in a log bundle instead of as a missing CLI. - console.warn( - `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` - ) - }) -} -configureDevUserDataPath(is.dev) -configureOrcaUserDataPathEnv() -installServeSupervisorDisconnectQuit(isServeMode) - -// Why: just past createMainWindow's 10s ready-to-show fallback, so a window revealed that way still gets its tray icon. -const TRAY_CREATE_FALLBACK_MS = 12_000 - -const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('before-single-instance-lock', { - version: app.getVersion(), - packaged: app.isPackaged, - platform: process.platform, - osRelease: os.release(), - userData: app.getPath('userData'), - e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - }) - startEventLoopStallProbe() -} -// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; runs the whole session to catch steady-state churn (issue #7576). -// Why the diff-cache counters ride along: a stamp the filesystem reports unstably makes the cache -// look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. -startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) +setMainWindowOpener(openMainWindow) function focusExistingWindow(): void { - focusExistingMainWindow({ - app, - getWindow: () => mainWindow, - openWindow: openMainWindow, - warn: console.warn - }) + focusExistingWindowAction() } function requestDesktopActivation(argv: readonly string[] = []): void { - skillShareDeepLinks.capture(argv, (shareId) => { - mainWindow?.webContents.send('ui:openSkillShare', shareId) + state.skillShareDeepLinks.capture(argv, (shareId) => { + state.mainWindow?.webContents.send('ui:openSkillShare', shareId) }) - // Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935). if (!shouldActivateDesktopForSecondInstance(argv)) { return } - desktopActivationGate.requestActivation() + state.desktopActivationGate?.requestActivation() } -app.on('open-url', (event, url) => { - if (!parseSkillShareId(url)) { - return - } - event.preventDefault() - requestDesktopActivation([url]) -}) - -skillShareDeepLinks.capture(process.argv) - const handleMacAppActivation = createMacAppActivationHandler({ - getWindow: () => mainWindow, + getWindow: () => state.mainWindow, requestActivation: requestDesktopActivation }) -function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { - const state = desktopActivationGate.getState() - return state === 'ready' ? 'openable' : state -} - -function settleServeDesktopActivation(): void { - settleServeDesktopActivationGate(desktopActivationGate, { - hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) - }) -} - -// Why: webContents-scoped auto-expiring flag so an intent can't leak to a later renderer load; `consume` clears on match for one-shot signals. -function createWebContentsTimedFlag(defaultDurationMs = 10_000): { - mark: (webContentsId: number, durationMs?: number) => void - clear: (webContentsId?: number) => void - matches: (webContentsId: number, options?: { consume?: boolean }) => boolean -} { - let state: { webContentsId: number; until: number } | null = null - return { - mark(webContentsId, durationMs = defaultDurationMs) { - state = { webContentsId, until: Date.now() + durationMs } - }, - clear(webContentsId) { - if (webContentsId === undefined || state?.webContentsId === webContentsId) { - state = null - } - }, - matches(webContentsId, options) { - if (!state || Date.now() > state.until) { - state = null - return false - } - if (state.webContentsId !== webContentsId) { - return false - } - if (options?.consume) { - state = null - } - return true - } - } -} - -function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { - expectedRendererReload.mark(webContentsId, durationMs) -} - -function clearExpectedRendererReload(webContentsId?: number): void { - expectedRendererReload.clear(webContentsId) -} - -function getExpectedTeardownScope( - webContentsId?: number, - includeSystemSessionEnd = true -): ExpectedTeardownScope { - return resolveExpectedTeardownScope({ - isQuitting, - isQuittingForUpdate: isQuittingForUpdate(), - isExpectedRendererReload: - webContentsId !== undefined && expectedRendererReload.matches(webContentsId), - includeSystemSessionEnd - }) -} - -function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { - recoveryReloadInFlight.mark(webContentsId, durationMs) -} - -function isRecoveryReloadInFlight(webContentsId: number): boolean { - // Why: consume on read — the recovery reload fires exactly one did-finish-load, so a later genuine reload still sweeps orphaned PTYs. - return recoveryReloadInFlight.matches(webContentsId, { consume: true }) -} - -function recordAgentStateCrashBreadcrumb(agentType: string, state: string): void { - // Why: hook pings arrive many times/sec; coalesce so identical state pings don't fill all 30 breadcrumbs, leaving room for renderer errors. - recordCoalescedCrashBreadcrumb({ - name: 'agent_state_changed', - data: { agentType, state }, - coalesceKey: `agent:${agentType}:${state}`, - minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS - }) -} - -// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. -// Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). -const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ - isDev: is.dev, - isServeMode -}) -if (bypassSingleInstanceLock) { - // Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist. - logSingleInstanceLockBypass() -} -const hasSingleInstanceLock = skipSingleInstanceLock - ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, requestDesktopActivation) -if (startupDiagnosticsEnabled) { - logStartupDiagnostic('single-instance-lock-result', { - acquired: hasSingleInstanceLock, - bypassed: bypassSingleInstanceLock, - skippedForDev: skipSingleInstanceLock - }) -} -if (!hasSingleInstanceLock) { - // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() - // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). - app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) -} - -// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData. -if (hasSingleInstanceLock) { - // Why first: both accessors throw until installed, and everything below this line - // may resolve a path or read a credential. Neither constructor touches `app` or - // `safeStorage` — they resolve lazily per call — so installing here changes no - // timing, in particular not the pre-ready Keychain service-name resolution and - // the app.setName ordering the userData captures below depend on. - setAppEnvironment(new ElectronAppEnvironment()) - setSecretStore(new ElectronSecretStore()) - // Why at process level, not per-window: pty.ts registers against injected surfaces so - // it can load without electron, and an Electron main process always has ipcMain — - // whether a window exists is irrelevant. Installing this in attachMainWindowServices - // meant `orca serve` registered its PTY handlers against no-ops before any window - // attached, so a paired desktop owner never received them. - setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) - // Why also at process level: the runtime's notification, window-lookup and - // tab-create-reply channel are desktop-only. A Node host installs none and the - // runtime routes notifications to paired clients instead. - setRuntimeDesktopSurface(electronRuntimeDesktopSurface) - // Why here: constructing RuntimeBrowserCommands is what pulls the Chromium browser - // cluster into the graph. The desktop installs it; a Node host installs none and every - // browser RPC rejects, which capability filtering already tells clients about. - setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) - // Why here: proxy-settings only needed electron for `session.defaultSession`. The - // desktop supplies it; a Node host has no Chromium proxy config to consult, so the - // environment variables are the whole answer there. - setDefaultProxySessionResolver(() => session.defaultSession) - // Why here: integrations use Chromium's network stack on the desktop. A Node host - // falls back to the platform default, which is a real behavioural difference (proxy - // read from the environment, Node's user agent) rather than a transparent swap. - setMainHttpClient(electronHttpClient) - // Why here: constructing the speech services is what pulls Electron's streaming net - // request in. A host without them rejects speech calls rather than pretending. - setSpeechServiceFactories(electronSpeechServiceFactories) - setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) - // Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime. - const shouldCoupleToDevParent = is.dev && !isServeMode - installDevParentDisconnectQuit(shouldCoupleToDevParent) - installDevParentWatchdog(shouldCoupleToDevParent) - installDevParentSignalQuit(shouldCoupleToDevParent) - // Why: run after configureDevUserDataPath but before app.setName('Orca') (whenReady), which changes the resolved path on case-sensitive filesystems. - initDataPath() - // Why here: initDataPath above gives the canonical userData path for the record file; the write - // itself lands for the next launch (see macos-press-and-hold-default.ts). - applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) - // Why: use the canonical userData path — late app.getPath('userData') can resolve differently across restarts, defeating persistence. - initSessionParseCachePersistence({ - filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), - appVersion: app.getVersion() - }) - initOrcaProfilePaths() - // Why: same timing as initDataPath — capture userData before app.setName changes it. See persistence.ts:20-28. - initStatsPath() - initClaudeUsagePath() - initCodexUsagePath() - initOpenCodeUsagePath() - // Why: Electron resolves the macOS safeStorage Keychain service name - // (" Safe Storage") before `ready`, so the setName in whenReady is - // too late to move it — dev otherwise lands on the package.json name. Dev-only - // so a packaged build keeps deriving the key from its own CFBundleName. - // Safe here: dev always pins userData via app.setPath (configure-process.ts), - // so setName cannot shift the paths captured just above. - if (shouldApplyPreReadyAppName(devInstanceIdentity)) { - app.setName(devInstanceIdentity.appName) - } - // Why: Electron freezes the privileged scheme table at ready, so the doc-preview - // scheme must be declared here or its webview loses fetch/secure-origin privileges. - registerDocPreviewSchemePrivileges() - // Why: must precede app.whenReady() so Crashpad is installed before the - // first renderer spawns; a CHECK before this point is still exit-code-only. - startCrashpadCapture() - crashReports = CrashReportStore.fromUserData() - recordCrashBreadcrumb('app_started', { - packaged: app.isPackaged, - platform: process.platform, - ...getMainProcessLifecycleIdentity() - }) - disableUnsupportedChromiumFeatures() - // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. - optOutOfHiddenPageWakeUpThrottling() - configureElectronNetworkCompatibility() - enableRendererHeapHeadroom() - maybeApplyGpuFallbackForThisLaunch() - if (!gpuFallbackActiveThisLaunch) { - enableMainProcessGpuFeatures() - } - // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. - headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) -} - -ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) +const preflightReady = runMainProcessPreflight({ + focusExistingWindow, + requestDesktopActivation }) -ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { - await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) - await runtime?.prepareStructuredAgentSessionStartupRestoration() -}) - -ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady, - managedWslCliStartupBarrierReady, - localPtyProviderStartupReady, - reconcile: async () => { - await runtime?.refreshRestoredOrchestrationAuthority() - return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - }, - onDeferredRecoveryError: (error) => { - console.warn('[orchestration] legacy worker provider-ready recovery failed', error) - } - }) -) - -// Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. -ipcMain.handle('ui:consumePendingOpenSettings', (event) => - pendingOpenSettings.matches(event.sender.id, { consume: true }) -) - -ipcMain.handle('ui:consumePendingSkillShare', () => { - return skillShareDeepLinks.consume() -}) - -ipcMain.handle( - 'app:startupDiagnostic', - (_event, event: string, details?: Record) => { - if (!startupDiagnosticsEnabled || !event.startsWith('renderer-')) { +if (preflightReady) { + app.on('open-url', (event, url) => { + if (!parseSkillShareId(url)) { return } - logStartupMilestone(event, details && typeof details === 'object' ? details : {}) - } -) - -/** A PTY that dies while Orca is down never runs the teardown that clears pane - * state, so hydrate can rebuild a Claude subagent roster that no later hook can - * retire — pinning the pane 'working' and locking its agent out of hibernation - * for good. Once provider and hook hydration settle, targeted PTY liveness can - * retire only rows whose local owner is proven gone. */ -async function reapRestoredSubagentsWithoutLiveAgent(): Promise { - const currentStore = store - if (!currentStore) { - return - } - const provider = getDaemonProvider() - if (!provider) { - return - } - const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( - currentStore.getWorkspaceSession().terminalLayoutsByTabId ?? {} - ) - await sweepRestoredSubagentsWithoutLiveAgent({ - probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), - isLocalExecutionHost: (worktreeId) => - isLocalExecutionHost( - resolveAgentWorkspaceExecutionHostId(worktreeId, { - getRepo: (repoId) => currentStore.getRepo(repoId), - getWorktreeMeta: (resolvedWorktreeId) => currentStore.getWorktreeMeta(resolvedWorktreeId), - getFolderWorkspace: (folderWorkspaceId) => - currentStore.getFolderWorkspace(folderWorkspaceId), - getProjectGroups: () => currentStore.getProjectGroups() - }) - ), - getBoundPtyIdForPaneKey: getPtyIdForPaneKey, - getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), - reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => - agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( - isLocalHost, - isLocalPaneAgentLive, - isLocalPaneLivenessEvidenceCurrent - ) + event.preventDefault() + requestDesktopActivation([url]) }) -} - -function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { - logStartupMilestone('first-window-startup-services-start') - const startupServices = startFirstWindowStartupServices({ - // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. - startDaemonPtyProvider: async (signal) => { - logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - // Why: only GUI-spawned macOS daemons watch for login-session death; a headless - // serve daemon must survive its spawning session ending (SSH disconnect). - await initDaemonPtyProvider(signal, { - macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode - }) - // Why: a retained shell keeps its launch-time Codex home even when the current routing lane changes. - if (codexRuntimeHome && hasRecordedManagedHostCodexPane()) { - const livePtyIds = await listLiveDaemonPtyIds() - if (livePtyIds) { - reconcileCodexPaneAccountsWithLivePtys(livePtyIds) - const settings = store?.getSettings() - // Why (#16441): each retained home can run a codex app-server grant - // session. Awaiting them here delayed the first window by N sessions; - // a retained shell cannot invoke Codex before this provider serves. - void reconcileRetainedCodexHookHomes({ - hookService: codexHookService, - hooksEnabled: - isAgentStatusHooksEnabled(settings) && - settings?.disabledTuiAgents.includes('codex') !== true, - runtimeHomePaths: codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) - }).catch((error: unknown) => { - console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) - }) - } - } - // Why: retained shells can invoke Codex immediately after the startup gate. - codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() - logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) - }, - // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. - startAgentHookServer: async () => { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) - // Why (#11217): the hook listener fails open on every request error, so an IDS resetting - // loopback POSTs mid-body stops agent status for every runtime with no symptom but staleness. - // Log + telemetry (the daemon_start_failed pattern) so it is diagnosable without a packet capture. - agentHookServer.setTransportInterferenceListener((report) => { - track('agent_hook_transport_blocked', { count: report.count }) - }) - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - // Why: hooks source this endpoint file at invocation time so old PTY env reaches the current process after restart; dev namespaces it (worktrees share `orca-dev`). - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) - }, - onDaemonError: (error) => { - // Why: daemon failure silently falls back to non-persistent local PTYs; log + telemetry so a fleet-wide outage is observable (was invisible in v1.4.129-rc.1). - const reason = error instanceof Error ? error.message : String(error) - console.error( - `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` - ) - track('daemon_start_failed', classifyError(error)) - }, - onAgentHookServerError: (error) => { - // Why: hook callbacks are sidebar enrichment only; Orca must still boot if the loopback receiver fails. - console.error('[agent-hooks] Failed to start local hook server:', error) - } - }) - void startupServices.firstWindowReady.then(() => { - logStartupMilestone('first-window-startup-services-ready') - }) - void startupServices.localPtyReady.then(() => { - logStartupMilestone('local-pty-startup-ready') - void reapRestoredSubagentsWithoutLiveAgent().catch((error) => { - console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + state.skillShareDeepLinks.capture(process.argv) + registerMainProcessIpcHandlers() + installMainProcessQuitHandlers() + void app.whenReady().then(async () => { + await initializeMainProcessReady({ + openMainWindow, + handleMacAppActivation }) }) - return startupServices } - -function bindTerminalRuntimeStartupServices( - services: Promise -): void { - firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) - localPtyStartupReady = services.then((value) => value.localPtyReady) - localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) -} - -async function prepareCodexRuntimeHomeForLaunch( - target?: CodexAccountSelectionTarget, - launchEnv?: NodeJS.ProcessEnv, - launchContext?: CodexHomeLaunchContext -): Promise { - if ( - target?.runtime !== 'wsl' && - launchContext?.launchAgent === 'codex' && - launchContext.workspacePath - ) { - try { - // Why: renderer quick-launch cannot await trust IPC before its PTY mounts; launch prep runs before every recognized Codex spawn. - await markCodexProjectTrusted(launchContext.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) - } - } - const ensureRealHomeHooksIfSelected = async (): Promise => { - if ( - target?.runtime === 'wsl' || - !codexRuntimeHome!.isHostSystemDefaultRealHomeSelected(launchEnv) - ) { - return false - } - // Why (flag ON, system default): the hook entry must exist — appended last - // and trusted by codex's own app-server grant — in the real ~/.codex before - // the pane spawns. An incapable grant flips the lane gate so the launch - // below falls back to the managed home instead of a status-blind pane. - await ensureRealHomeCodexHookState({ - hooksEnabled: isAgentStatusHooksEnabled(store?.getSettings()), - userDataPath: app.getPath('userData') - }) - return true - } - let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - // Why: a ManagedCodexHomeTemporarilyUnavailableError must escape uncaught — - // the fallbacks below all key off `null`, which means "system default", so - // swallowing the refusal would launch the wrong account (#STA-4422). - let runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - if (runtimeHomePath === null && !realHomeHooksPrepared) { - // Why: launch prep can reject an untrusted managed home and clear its - // selection. Establish hook capability for that newly selected lane, then - // re-resolve if the capability gate rejects it. - realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() - if (realHomeHooksPrepared) { - runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target, launchEnv, { - unavailableManagedHomePath: launchContext?.unavailableManagedHomePath - }) - } - } - if (runtimeHomePath === null && target?.runtime !== 'wsl') { - // Why: Codex runs on the user's real ~/.codex; the managed-home hook - // install below would target a home Codex never reads on this lane. - return null - } - const hookTarget = - target?.runtime === 'wsl' - ? { - runtime: 'wsl' as const, - wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() - } - : target - const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) - try { - // Why: honor the persisted off switch so post-startup launches can't reinstall removed hooks. - const status = hooksEnabled - ? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ?? - // Why: a managed account's launch home is its own self-contained - // CODEX_HOME, so hooks/trust must install there, not the shared mirror. - (await codexHookService.install(runtimeHomePath ?? undefined))) - : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? - (await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))) - if (status.state === 'error') { - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - status.detail - ) - } - } catch (error) { - // Why: hook install is best-effort launch prep; a malformed hooks file must not block Codex from starting. - console.warn( - `[codex-hook-service] failed to ${ - hooksEnabled ? 'refresh' : 'refresh user' - } runtime hooks before launch`, - error - ) - } - return runtimeHomePath -} - -async function prepareCodexSessionResumeForLaunch(args: { - providerSession: AgentProviderSessionMetadata - target: CodexAccountSelectionTarget - launchEnv?: NodeJS.ProcessEnv - workspacePath?: string -}): Promise { - if (args.target.runtime === 'wsl' || !codexRuntimeHome || !store) { - return null - } - const systemHomePath = getSystemCodexHomePath() - // Why: codexSessionSourceHome is import-only; treating it as CODEX_HOME would mutate history sources and bypass account auth. - const trustedHomes = [ - systemHomePath, - ...codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() - ] - const settingsStore = store - // Why: resolved eagerly, once, before any ranking or provenance match. The - // marker read used to be deferred into the ranking thunk so a - // provenance-present resume never paid for it, but that optimisation let an - // unreadable selected home reach the PTY as "no selection": the provenance - // branch simply omits the account from `trustedHomes` and another account's - // readable alias wins. A throw here refuses the whole resume instead - // (#STA-4422). - const selectedAccountCodexHome = - codexRuntimeHome.resolveSelectedHostAccountCodexHomePathForResume() - // Why: a `fresh` outcome must skip migration, trust and hook repair entirely — there is - // no verified origin home to prepare, so the PTY layer drops the resume argv (#10793). - const preparation = await prepareCodexSessionResume({ - sessionId: args.providerSession.id, - transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes, - // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; - // rank it by the current selection so settings insertion order can never decide the account. - getSelectedAccountCodexHome: () => selectedAccountCodexHome, - systemCodexHomePath: systemHomePath, - // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must - // outrank the path-sorted account homes or a system-default selection resumes as an account. - sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), - resolveVerifiedResumeHome: async (sessionSource) => { - let migrated = { useRealCodexHome: false } - try { - migrated = await prepareLegacySharedCodexSessionResume( - { - agent: 'codex', - executionHostId: 'local', - filePath: sessionSource.transcriptPath, - codexHome: sessionSource.homePath - }, - { - isHostSystemDefaultRealHome: () => codexRuntimeHome!.isHostSystemDefaultRealHome(), - systemCodexHomePath: systemHomePath - } - ) - } catch (error) { - // Why: this launch path pins CODEX_HOME to the account that OWNS the - // rollout and deliberately refuses to repin onto whichever account is - // selected now (#10793), so it does not wire - // getSelectedHostAccountCodexHomePath and this branch cannot fire today. - // It stays as a contract guard: the blanket catch below must never - // silently swallow a typed refusal if that ever changes. - if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { - throw error - } - // Why: migration is a compatibility repair; its failure must not prevent the PTY from resuming from its trusted origin home. - console.warn( - '[codex-session-resume] Legacy rollout migration failed; using origin home:', - error - ) - } - const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath - - if (args.workspacePath) { - try { - await markCodexProjectTrusted(args.workspacePath) - } catch (error) { - console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) - } - } - const isSystemHome = - normalizeRuntimePathForComparison(resumeHome) === - normalizeRuntimePathForComparison(systemHomePath) - const hooksEnabled = isAgentStatusHooksEnabled(settingsStore.getSettings()) - try { - if (isSystemHome) { - await ensureRealHomeCodexHookState({ - hooksEnabled, - userDataPath: app.getPath('userData') - }) - } else if (hooksEnabled) { - await codexHookService.install(resumeHome) - } else { - await codexHookService.refreshRuntimeUserHooks(resumeHome) - } - } catch (error) { - // Why: hook repair is best-effort; session provenance must still win over the currently selected home. - console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) - } - return resumeHome - } - }) - return preparation.outcome === 'resume' - ? { - ...preparation, - reconcileSharedRuntimeAuth: - normalizeRuntimePathForComparison(preparation.codexHomePath) === - normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) - } - : preparation -} - -// Why: restore the window the close handler may have hidden to tray, or reopen it (dock-reactivation style) if fully torn down. -function showMainWindowFromTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - safelyRevealWindow(mainWindow) - return - } - if (!isQuittingForUpdate()) { - openMainWindow() - } -} - -function openSettingsFromSystemMenu(): void { - showMainWindowFromTray() - const targetWindow = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null - if (!targetWindow) { - return - } - recordCrashBreadcrumb('settings_opened') - - // Why: no signal proves the renderer listener is attached — push, and also leave a one-shot intent the unmounted renderer pulls at mount. - targetWindow.webContents.send('ui:openSettings') - // Why: untimed — any TTL can be outrun by a slow cold start; id-scoping + consume-on-read still prevent leaking to a later renderer. - pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) -} - -function quitFromSystemTray(): void { - if (mainWindow && !mainWindow.isDestroyed()) { - // Why: a hidden session may veto shutdown with a save/discard prompt, so make the window visible. - showMainWindowFromTray() - } - // Why: set the quit latch before app.quit() so the 'close' handler tears down instead of re-hiding to tray. - isQuitting = true - app.quit() -} - -// Why: menu/tray are clickable before anything else configures the updater. -function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { - ensureAutoUpdaterConfigured() - checkForUpdatesFromMenu(options) -} - -function getSystemTrayOptions(): SystemTrayOptions | null { - if (!store) { - return null - } - return { - appIcon: store.getSettings().appIcon, - isDevInstance: devInstanceIdentity.isDev, - devInstanceLabel: devInstanceIdentity.devLabel, - onOpen: showMainWindowFromTray, - onOpenSettings: openSettingsFromSystemMenu, - onCheckForUpdates: () => { - // Why: updater status renders in the main window, so a bare check would complete invisibly. - showMainWindowFromTray() - runUserInitiatedUpdateCheck() - }, - onQuit: quitFromSystemTray - } -} - -function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { - if (process.platform !== 'darwin' || isServeMode) { - return null - } - const options = getSystemTrayOptions() - return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null -} - -function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { - logStartupMilestone('open-main-window-start') - if (!store) { - throw new Error('Store must be initialized before opening the main window') - } - if (!runtime) { - throw new Error('Runtime must be initialized before opening the main window') - } - if (!stats) { - throw new Error('Stats must be initialized before opening the main window') - } - if (!claudeUsage) { - throw new Error('Claude usage store must be initialized before opening the main window') - } - if (!codexUsage) { - throw new Error('Codex usage store must be initialized before opening the main window') - } - if (!openCodeUsage) { - throw new Error('OpenCode usage store must be initialized before opening the main window') - } - if (!rateLimits) { - throw new Error('Rate limit service must be initialized before opening the main window') - } - if (!automations) { - throw new Error('Automation service must be initialized before opening the main window') - } - if (!codexAccounts) { - throw new Error('Codex account service must be initialized before opening the main window') - } - if (!codexRuntimeHome) { - throw new Error('Codex runtime home service must be initialized before opening the main window') - } - if (!claudeAccounts) { - throw new Error('Claude account service must be initialized before opening the main window') - } - if (!claudeRuntimeAuth) { - throw new Error( - 'Claude runtime auth service must be initialized before opening the main window' - ) - } - if (!keybindings) { - throw new Error('Keybinding service must be initialized before opening the main window') - } - - // Why: Chromium's BrowserWindow ctor resets userData to a Protected DACL, breaking writes; re-grant ACEs (marker-gated to avoid a ~60s startup stall). - if (process.platform === 'win32') { - logStartupMilestone('acl-grant-start') - ensureWindowsUserDataAclGrant(app.getPath('userData'), { - onDone: (result) => { - logStartupMilestone('acl-grant-done', { mode: result.mode }) - if (result.mode === 'failed') { - console.warn('[win32-acl] userData ACL grant failed:', result.reason) - } - } - }) - // Why here: read-only, and the install DACL is the one thing a 0x80000003 - // child death cannot tell us about itself. See electron/electron#51761. - probeWindowsInstallDirAcl({ isServeMode }) - } - - const window = createMainWindow(store, { - getIsQuitting: () => isQuitting, - onQuitAborted: () => { - isQuitting = false - clearExpectedRendererReload() - }, - onRendererProcessGone: (details, webContentsId) => { - recordProcessGoneCrash( - 'renderer', - 'renderer', - details.reason, - details.exitCode ?? null, - { - processType: 'renderer' - }, - webContentsId - ) - }, - shouldRecoverRenderer: (details, webContentsId) => - shouldRecoverRendererAfterProcessGone({ - reason: details.reason, - expectedTeardown: getExpectedTeardownScope(webContentsId, false) - }), - onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { - recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { - reason: details.reason, - exitCode: details.exitCode ?? null, - recentRecoveryCount - }) - void presentRendererRecoveryPrompt(recentRecoveryCount) - }, - deferLoad: true, - ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), - title: devInstanceIdentity.name, - getKeybindings: () => keybindings?.getOverrides(), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - // Why: the recovery reload re-fires did-finish-load; flag it so the local-PTY orphan sweep skips that reload (#5787). - onBeforeRecoveryReload: (webContentsId) => { - markRecoveryReloadInFlight(webContentsId) - recordDurableCrashBreadcrumb('renderer_recovery_reload') - } - }) - recordCrashBreadcrumb('main_window_created') - logStartupMilestone('window-created') - // Why: Windows Tray construction can block synchronously on Shell_NotifyIcon, so both platforms defer creation to after first paint. - let trayCreated = false - const createSystemTrayDeferred = (): void => { - if (trayCreated || window.isDestroyed() || isQuitting || !store) { - return - } - trayCreated = true - if (process.platform === 'darwin') { - // Why: route through syncMacMenuBarIcon so startup and the live toggle share one serve-mode/visibility policy. - if (syncMacMenuBarIcon(store.getSettings().showMenuBarIcon !== false)) { - logStartupMilestone('tray-created') - } - return - } - const options = getSystemTrayOptions() - if (options && createSystemTray(options)) { - logStartupMilestone('tray-created') - } - } - window.once('ready-to-show', () => { - logStartupMilestone('ready-to-show') - setImmediate(createSystemTrayDeferred) - }) - window.once('show', () => { - logStartupMilestone('window-shown') - void presentGpuFallbackRecoveredLaunchPrompt(window) - }) - const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) - trayCreateFallback.unref?.() - - // Why: telemetry-plan.md anchors default-on app_opened to the first main-window load; this path fires only once consent is already enabled. - const rendererWebContentsId = window.webContents.id - const onFirstWindowLoad = (): void => { - clearExpectedRendererReload(rendererWebContentsId) - recordCrashBreadcrumb('main_window_loaded') - logStartupMilestone('did-finish-load') - if (!store) { - return - } - const consent = resolveConsent(store.getSettings()) - if (consent.effective !== 'enabled') { - return - } - trackAppOpenedOnce() - } - window.webContents.on('did-finish-load', onFirstWindowLoad) - - registerCoreHandlers( - store, - runtime, - stats, - claudeUsage, - codexUsage, - openCodeUsage, - codexAccounts, - claudeAccounts, - rateLimits, - rendererWebContentsId, - automations, - { - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }, - agentAwakeService ?? undefined, - crashReports ?? undefined, - keybindings, - { - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - onBeforeRelaunch: async () => { - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) - }, - onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), - onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - }, - pluginService ?? undefined, - pluginMarketplaceService && pluginMarketplaceInstaller - ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } - : undefined - ) - automations.setWebContents(window.webContents) - automations.start() - attachMainWindowServices( - window, - store, - runtime, - prepareCodexRuntimeHomeForLaunch, - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - { - prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, - awaitLocalPtyStartup: () => localPtyStartupReady, - awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady, - onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { - if (window.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) - }, - // Why: let the PTY layer skip its orphan sweep on the recovery reload that re-fires did-finish-load, so live local sessions survive (#5787). - isRecoveryReloadInFlight, - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit, - onBeforeUpdateQuit: () => - preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode), - onWorktreeLifecycle: emitPluginWorktreeLifecycle - } - ) - // Why: attach the durable renderer pull now, but launch the diagnostic process after first paint. - initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) - rateLimits.attach(window) - // Why: quota probes spawn CLIs and hit network, so don't fetch immediately and compete with first paint; show/focus listeners refresh later. - rateLimits.start({ fetchImmediately: false }) - window.on('closed', () => { - if (mainWindow === window) { - mainWindow = null - } - clearExpectedRendererReload(rendererWebContentsId) - automations?.setWebContents(null) - // Why: detach the hook listener on close so the server never fires into destroyed webContents before reopen, and replay runs only on deliberate recreations. - agentHookServer.setListener(null) - agentHookServer.setPaneStatusClearListener(null) - setMigrationUnsupportedPtyListener(null) - // Why: stop the spinner timer here — it would fire into destroyed webContents, and per-pane teardown may never run for restored-but-untorn panes. - stopAllSyntheticTitleSpinners() - }) - mainWindow = window - window.on('show', resumeSyntheticTitleSpinnerTimer) - window.on('restore', resumeSyntheticTitleSpinnerTimer) - window.on('hide', stopSyntheticTitleSpinnerTimer) - window.on('minimize', stopSyntheticTitleSpinnerTimer) - // Why: visibility-gated pollers (SSH port scanner) park while hidden and resume on this signal; re-wired per window since dock re-activation recreates it. - window.on('show', notifyMainWindowBecameVisible) - window.on('restore', notifyMainWindowBecameVisible) - // Why: user is back on show/restore, so clear the tray attention dot set while hidden (see notifications.ts). - window.on('show', () => setTrayAttention(false)) - window.on('restore', () => setTrayAttention(false)) - agentHookServer.setListener( - ({ - paneKey, - tabId, - worktreeId, - connectionId, - payload, - receivedAt, - stateStartedAt, - launchToken, - providerSession, - providerSessionOnly, - promptInteractionKey, - restoredUnconfirmed, - observation, - isReplay - }) => { - if (mainWindow?.isDestroyed()) { - return - } - if (providerSessionOnly) { - // Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI. - mainWindow?.webContents.send('agentStatus:set', { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(observation ? { observation } : {}), - providerSessionOnly: true - }) - return - } - if (!restoredUnconfirmed) { - maybeAutoRenameBranchOnFirstWorkFromHook({ paneKey, tabId, worktreeId, payload, isReplay }) - } - const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) - const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) - const suppressSyntheticCodexAutoApprovalTitle = - payload.agentType === 'codex' && - (payload.state === 'waiting' || payload.state === 'blocked') - ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ - agentType: payload.agentType, - state: payload.state, - launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) - }) - : false - const statusEvent = { - ...payload, - paneKey, - ...(launchToken ? { launchToken } : {}), - ...(terminalHandle ? { terminalHandle } : {}), - tabId, - worktreeId, - connectionId, - receivedAt, - stateStartedAt, - ...(providerSession ? { providerSession } : {}), - ...(promptInteractionKey ? { promptInteractionKey } : {}), - ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), - ...(observation ? { observation } : {}), - ...(orchestration ? { orchestration } : {}) - } - mainWindow?.webContents.send('agentStatus:set', statusEvent) - if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { - getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) - } - recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) - // Why: native OSC titles miss some idle/permission frames, so inject hook-derived ones to keep the renderer title tracker in sync. - const profile = getSyntheticAgentTitleProfile(payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && - !suppressSyntheticCodexAutoApprovalTitle - ) { - driveSyntheticTitleFromHook(paneKey, payload.state, profile) - } - } - ) - agentHookServer.setPaneStatusClearListener((clear) => { - if (mainWindow?.isDestroyed()) { - return - } - mainWindow?.webContents.send('agentStatus:clear', clear) - getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) - }) - setMigrationUnsupportedPtyListener((event) => { - if (mainWindow?.isDestroyed()) { - return - } - if (event.type === 'set') { - mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) - } else { - mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { - ptyId: event.ptyId - }) - } - }) - logStartupMilestone('load-start') - loadMainWindow(window) - return window -} - -function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openFeatureTour') -} - -function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openSetupGuide') -} - -function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { - const webContents = - targetWindow && !targetWindow.isDestroyed() ? targetWindow.webContents : mainWindow?.webContents - webContents?.send('ui:openCrashReport') -} - -// Why: on renderer crash-loop the breaker stops auto-reloading and the window goes blank, so a main-process dialog is the only retry/quit surface. -async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promise { - if (isQuitting) { - return - } - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - const options = { - type: 'error' as const, - buttons: ['Reload', 'Quit'], - defaultId: 0, - cancelId: 1, - title: 'Orca keeps failing to load', - message: 'The app window crashed repeatedly and stopped reloading automatically.', - detail: `Orca tried to recover ${recentRecoveryCount} times in a row without success. This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.` - } - const { response } = window - ? await dialog.showMessageBox(window, options) - : await dialog.showMessageBox(options) - if (response === 0 && mainWindow && !mainWindow.isDestroyed()) { - recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') - loadMainWindow(mainWindow) - } else if (response === 1) { - isQuitting = true - app.quit() - } -} - -function getGpuFallbackEnvironment(): GpuFallbackEnvironment { - return { - appVersion: app.getVersion(), - electronVersion: process.versions.electron ?? '', - platform: process.platform - } -} - -function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { - const environment = getGpuFallbackEnvironment() - if (environment.platform !== 'win32') { - return null - } - return { ...environment, platform: 'win32' } -} - -// Writes both crash-time and post-recovery consent states through one build-scoped path. -function persistGpuFallbackMarker( - userDataPath: string, - info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } -): boolean { - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return false - } - try { - writeGpuFallbackMarker(userDataPath, info, environment) - return true - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return false - } -} - -// Read before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. -function maybeApplyGpuFallbackForThisLaunch(): void { - if (isServeMode || process.platform !== 'win32') { - return - } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { - return - } - activeGpuFallbackMarker = marker - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) - gpuFallbackActiveThisLaunch = true - // Why: with no GPU child left, child-process-gone can't report a GPU fault, so - // name the applied switches in the trail any later crash report carries. - recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, - switches: appliedSwitches.join(',') - }) -} - -async function presentGpuFallbackRecoveredLaunchPrompt(window: BrowserWindow): Promise { - const marker = activeGpuFallbackMarker - if (!marker || marker.userConfirmed || window.isDestroyed() || isQuitting) { - return - } - // One prompt per process. A failure leaves the on-disk marker unconfirmed so the next launch retries. - activeGpuFallbackMarker = null - const userDataPath = app.getPath('userData') - await handleGpuFallbackRecoveredLaunch({ - isQuitting: () => isQuitting, - prompt: () => promptForGpuFallbackRecoveredLaunch(window), - confirmSafeGraphics: () => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: marker.engagedAt, - crashesInWindow: marker.crashesInWindow, - userConfirmed: true - }) - }, - clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), - onSafeGraphicsKept: () => - recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { - crashesInWindow: marker.crashesInWindow - }), - restartWithHardware: () => { - isQuitting = true - relaunchApp('gpu-fallback', { - mode: 'hardware-retry', - crashesInWindow: marker.crashesInWindow - }) - destroySystemTray() - app.exit(0) - } - }) -} - -// Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. -async function handleGpuChildCrash( - reason: string, - exitCode: number | null, - crashedAt: number -): Promise { - // Software rendering already active or shutting down: nothing more to do. - if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) { - return - } - const result = gpuCrashFallbackTracker.recordGpuCrash(crashedAt) - if (!result.shouldEngageFallback) { - return - } - const fallbackData = { - processReason: reason, - exitCode, - crashesInWindow: result.crashesInWindow - } - const userDataPath = app.getPath('userData') - await engageGpuFallbackAfterCrashBurst( - { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, - { - isQuitting: () => isQuitting, - onEngaged: (engagement) => - recordCrashBreadcrumb('gpu_fallback_engaged', { - reason: engagement.reason, - exitCode: engagement.exitCode, - crashesInWindow: engagement.crashesInWindow - }), - persistMarker: (engagement) => - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: false - }), - confirmMarker: (engagement) => { - persistGpuFallbackMarker(userDataPath, { - engagedAt: engagement.engagedAt, - crashesInWindow: engagement.crashesInWindow, - userConfirmed: true - }) - }, - clearMarker: () => clearGpuFallbackMarker(userDataPath), - promptForRestart: () => - promptForGpuFallbackRestart( - mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - ), - onPromptFailed: (error) => - console.warn('[gpu-fallback] failed to show restart prompt:', error), - onRestartDeferred: () => - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), - restartIntoSafeGraphics: () => { - isQuitting = true - relaunchApp('gpu-fallback', fallbackData) - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() - app.exit(0) - } - } - ) -} - -function recordProcessGoneCrash( - source: 'renderer' | 'child', - processType: string, - reason: string, - exitCode: number | null, - details: Record, - webContentsId?: number -): void { - recordProcessGoneCrashEvent(crashReports, { - source, - processType, - reason, - exitCode, - expectedTeardown: getExpectedTeardownScope(webContentsId), - details, - ...(webContentsId !== undefined ? { webContentsId } : {}) - }) -} - -function shutdownWatchersOnce(): Promise { - if (watcherShutdownDone) { - return Promise.resolve() - } - if (!watcherShutdownPromise) { - // Why: @parcel/watcher tears down native async work on unsubscribe; Electron must await it before Node's environment exits. - stopFolderRepoGitUpgradeWatch() - watcherShutdownPromise = Promise.allSettled([ - closeAllWatchers(), - disposeWorktreeBaseDirectoryWatchers() - ]) - .then((results) => { - for (const result of results) { - if (result.status === 'rejected') { - console.error('[filesystem-watcher] shutdown failed:', result.reason) - } - } - }) - .then(() => { - watcherShutdownDone = true - }) - } - return watcherShutdownPromise -} - -// Why: cursor-agent re-emits its own OSC title on every redraw, overwriting a one-shot frame — so re-assert a working frame on an interval. -// 80ms matches Pi's cadence (smooth but under the IPC budget). opencode needs only one frame but reuses this for consistent animated UX. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] -const SPINNER_INTERVAL_MS = 80 - -const syntheticTitleSpinnerByPaneKey = new Map< - string, - SyntheticTitleSpinnerEntry ->() -let syntheticTitleSpinnerTimer: ReturnType | null = null - -type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} - -function getBundledWebClientRoot(): string | undefined { - const appPath = app.getAppPath() - const roots = [ - join(appPath, 'out', 'web'), - // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. - join(appPath, '..', 'web') - ] - return roots.find((root) => existsSync(join(root, 'web-index.html'))) -} - -async function renderTerminalPairingQr(pairingUrl: string): Promise { - // Why dynamic: qrcode is only reachable from mobile pairing, so launch should - // not parse it for the majority who never pair a device. - const QRCode = await import('qrcode') - try { - return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) - } catch { - try { - return await QRCode.toString(pairingUrl, { type: 'utf8' }) - } catch { - return null - } - } -} - -async function printServeReady(options: ServeOptions): Promise { - if (!runtime || !runtimeRpc) { - throw new Error('Runtime server must be initialized before printing serve readiness') - } - if (options.recipeJson) { - if (!options.projectRoot) { - throw new Error('--serve-recipe-json requires --serve-project-root') - } - if (!isAbsolute(options.projectRoot)) { - throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) - } - const projectRootStats = statSync(options.projectRoot) - if (!projectRootStats.isDirectory()) { - throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) - } - } - const boundEndpoint = runtimeRpc.getWebSocketEndpoint() - const advertised = boundEndpoint - ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) - : null - const pairing = options.noPairing - ? ({ - available: false, - reason: 'disabled_by_operator', - guidance: 'Restart without --no-pairing to create a client pairing offer.' - } as const) - : runtimeRpc.createPairingOffer({ - address: options.pairingAddress, - name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, - scope: options.mobilePairing ? 'mobile' : 'runtime' - }) - const pairingQr = - pairing.available && options.mobilePairing - ? await renderTerminalPairingQr(pairing.pairingUrl) - : null - await serveReadinessPublisher.publish( - { - runtimeId: runtime.getRuntimeId(), - boundEndpoint, - advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, - // Why: the WSL reconciliation barrier fails open, so 'pending' warns a WSL PTY launch may still race a repair. - managedWslCliReconciliation: managedWslCliReconciliationStatus, - pairing: pairing.available - ? { - available: true, - url: pairing.pairingUrl, - endpoint: pairing.endpoint, - deviceId: pairing.deviceId, - webClientUrl: pairing.webClientUrl, - scope: options.mobilePairing ? 'mobile' : 'runtime', - qr: pairingQr - } - : pairing - }, - options.recipeJson - ? { mode: 'recipe-json', projectRoot: options.projectRoot! } - : { mode: options.json ? 'json' : 'human' } - ) - notifyServeSupervisorReady(runtime.getRuntimeId()) -} - -// Why: on PTY teardown drop the spinner entry explicitly, else the shared timer keeps ticking with sendSyntheticTitle no-oping forever. -registerPaneKeyTeardownListener((paneKey) => { - stopSyntheticTitleSpinner(paneKey) -}) - -// Why: the spinner is a stand-in for a live hook status, so it must retire with the row it -// stands in for — otherwise a pane whose status was cleared or dismissed keeps rotating a -// working title long after the agent finished (#13890). Both paths are covered: the -// pane-scoped clear fan-out, and user dismissal, which never routes through it. -agentHookServer.subscribePaneStatusClear((clear) => { - const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) - if (paneKey) { - stopSyntheticTitleSpinner(paneKey) - } -}) -agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) - -function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { - if (!mainWindow || mainWindow.isDestroyed()) { - return - } - // Why: throttle decorative spinner frames (up to 80ms/agent); final/permission frames are forced because they drive BEL. - if ( - !shouldSendSyntheticTitleFrame({ - force: options.force === true, - windowVisible: isSyntheticTitleWindowVisible() - }) - ) { - return - } - // Why: feed the per-PTY tracker directly, never onPtyData — emulator/tails/transcripts/stats must not see fabricated bytes. - runtime?.ingestSyntheticTitleFrame(ptyId, data) - // Why: only the kill-switch-off renderer byte-parses synthetic frames; under main authority the copy mints phantom ACKs (see synthetic-title-frame-routing.ts). - if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { - mainWindow.webContents.send('pty:data', { id: ptyId, data }) - } -} - -function isSyntheticTitleWindowVisible(): boolean { - return ( - mainWindow !== null && - !mainWindow.isDestroyed() && - mainWindow.isVisible() && - !mainWindow.isMinimized() - ) -} - -function canSendDecorativeSyntheticTitle(): boolean { - return shouldSendSyntheticTitleFrame({ - force: false, - windowVisible: isSyntheticTitleWindowVisible() - }) -} - -function stopSyntheticTitleSpinner(paneKey: string): void { - if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { - stopSyntheticTitleSpinnerTimerIfIdle() - } -} - -function stopAllSyntheticTitleSpinners(): void { - syntheticTitleSpinnerByPaneKey.clear() - stopSyntheticTitleSpinnerTimer() -} - -function stopSyntheticTitleSpinnerTimer(): void { - if (!syntheticTitleSpinnerTimer) { - return - } - clearInterval(syntheticTitleSpinnerTimer) - syntheticTitleSpinnerTimer = null -} - -function stopSyntheticTitleSpinnerTimerIfIdle(): void { - if (syntheticTitleSpinnerByPaneKey.size === 0) { - stopSyntheticTitleSpinnerTimer() - } -} - -function tickSyntheticTitleSpinners(): void { - if (!canSendDecorativeSyntheticTitle()) { - stopSyntheticTitleSpinnerTimer() - return - } - const ticks = advanceSyntheticTitleSpinnerEntries({ - entries: syntheticTitleSpinnerByPaneKey, - frameCount: SPINNER_FRAMES.length, - getPtyIdForPaneKey - }) - for (const tick of ticks) { - sendSyntheticTitle( - tick.ptyId, - `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` - ) - } - stopSyntheticTitleSpinnerTimerIfIdle() -} - -function ensureSyntheticTitleSpinnerTimer(): void { - if ( - syntheticTitleSpinnerTimer || - syntheticTitleSpinnerByPaneKey.size === 0 || - !canSendDecorativeSyntheticTitle() - ) { - return - } - // Why: one shared timer for all spinners — per-pane intervals multiplied idle wakeups when several agents were working. - syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) -} - -function resumeSyntheticTitleSpinnerTimer(): void { - ensureSyntheticTitleSpinnerTimer() -} - -function driveSyntheticTitleFromHook( - paneKey: string, - state: AgentStatusState, - profile: SyntheticAgentTitleProfile -): void { - const ptyId = getPtyIdForPaneKey(paneKey) - if (!ptyId) { - return - } - if (state === 'working') { - // Why: emit the first frame immediately so the spinner is visible now, not up to 80ms later at the next interval tick. - const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) - const frame = existing ? existing.frame : 0 - sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) - if (existing) { - // Why: refresh the profile so a mid-pane agent-type change lands on the right idle/permission labels at terminal state. - existing.profile = profile - return - } - syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) - ensureSyntheticTitleSpinnerTimer() - return - } - // Why: stop the spinner first so the next tick can't race the state back to "working", then inject the terminal frame. - // Permission frames add a trailing BEL to light up user-input states; done frames omit it (completion notifications own that attention). - stopSyntheticTitleSpinner(paneKey) - const needsUserInput = state === 'blocked' || state === 'waiting' - const label = needsUserInput ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { - force: true - }) -} - -function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { - agentType: string | null | undefined - state: AgentStatusState - launchConfig: - | { - agentArgs?: string | null - agentEnv?: Record | null - } - | null - | undefined -}): boolean { - if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { - return false - } - if (!args.launchConfig) { - return false - } - return ( - resolveTuiAgentPermissionMode({ - agent: 'codex', - agentArgs: args.launchConfig.agentArgs, - agentEnv: args.launchConfig.agentEnv - }) === 'yolo' - ) -} - -void app.whenReady().then(async () => { - logStartupMilestone('app-ready') - // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. - applyBackgroundActivationPolicy({ warn: console.warn }) - installElectronProxyRequestGuard(session.defaultSession) - app.on('login', (event, webContents, details, authInfo, callback) => { - handleElectronProxyLogin( - event, - webContents, - details, - authInfo, - callback, - session.defaultSession - ) - }) - installMainThreadHangWatchdog({ userDataPath: getCanonicalUserDataPath() }) - const hangDetection = consumeHangDetectionMarker( - hangDetectionMarkerPath(getCanonicalUserDataPath()) - ) - if (hangDetection) { - recordDurableCrashBreadcrumb('main_thread_hang_detected', { - unresponsiveMs: hangDetection.unresponsiveMs, - previousPid: hangDetection.parentPid, - selfRecovered: hangDetection.selfRecovered - }) - } - // Why: install certificate decisions before any webview or headless window issues its first TLS request. - app.on( - 'certificate-error', - (event, webContents, url, error, certificate, callback, isMainFrame) => { - browserCertificateTrustController.handleCertificateError({ - event, - webContents, - url, - error, - certificate, - callback, - isMainFrame - }) - } - ) - electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) - // Why: names the app menu/About panel. Dev already applied this pre-ready (see the - // safeStorage note above); this call stays unconditional so packaged builds keep their - // existing post-ready rename, which lands after the Keychain name is already resolved. - app.setName(devInstanceIdentity.appName) - updateGpuAccelerationAboutPanel() - - // Why: managed WSL launchers live outside the Windows app bundle, so keep their launcher/bridge contract synced across app updates. - managedWslCliReconciliationStatus = 'pending' - managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ - isPackaged: app.isPackaged, - userDataPath: getCanonicalUserDataPath(), - appVersion: app.getVersion() - }) - .then((results) => { - for (const result of results) { - if (result.outcome === 'failed') { - console.warn( - `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` - ) - } else if (result.outcome === 'repaired') { - console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) - } - } - managedWslCliReconciliationStatus = 'settled' - }) - .catch((error) => { - managedWslCliReconciliationStatus = 'failed' - console.warn( - '[wsl-cli] Managed registration reconciliation discovery failed:', - error instanceof Error ? error.message : String(error) - ) - }) - managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( - managedWslCliReconciliationReady - ) - - const activeOrcaProfile = ensureActiveOrcaProfile() - // Why this early: the first window stamps the hosting id into its renderer's argv, so the durable - // read has to have happened by then or the renderer and the browser-host lease disagree. - initializeBrowserClientHostId(activeOrcaProfile.profileDirectory) - store = new Store({ - dataFile: activeOrcaProfile.dataFile, - storageAuthority: isServeMode ? 'runtime' : 'desktop' - }) - // Why: create pending readiness before the guard can observe the default session. - const initialProxyApplication = applyElectronProxySettings(store.getSettings()) - installElectronProxyRequestGuard(session.defaultSession) - // Why armed here and not at install time: the report remembers what it last said, and - // that state lives beside the profile data file, which does not exist until now. - // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux - // and must not gate the first window (STA-5765). - scheduleSecretProtectionGapReport({ - dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', - deferUntilFirstWindow: !isServeMode - }) - // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults - // it. Left unbound it reports nothing trusted, which is safe but silently discards our own - // accept records on every launch. - initSshHostKeyStoreFile(activeOrcaProfile.dataFile) - // Why: must precede PTY handler registration and run in headless serve too, which returns before openMainWindow. - neutralizeLegacyTerminalShimDir(app.getPath('userData')) - const windowsShellPathHydration = createWindowsShellPathHydration() - configureWindowsHostGitEnvironmentReadiness( - process.platform === 'win32' ? windowsShellPathHydration.whenReady : null - ) - if (process.platform === 'win32') { - const settings = store.getSettings() - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - 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) - store.onSettingsChanged((updates, settings) => { - if ('terminalWindowsWslDistro' in updates) { - // Why: synchronize fallback WSL distro updates to runner. - setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) - } - if ( - ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && - process.platform === 'win32' - ) { - if (app.isPackaged) { - void windowsShellPathHydration.hydrate( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } else { - windowsShellPathHydration.configure( - settings.terminalWindowsShell, - settings.terminalWindowsPowerShellImplementation - ) - } - } - if ('showMenuBarIcon' in updates) { - // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. - syncMacMenuBarIcon(settings.showMenuBarIcon !== false) - } - if ('agentStatusHooksEnabled' in updates) { - // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running - // guest process and timers, and on must restart them — otherwise open WSL panes report no - // status until their next spawn. - if (isAgentStatusHooksEnabled(settings)) { - wslHookRelayManager.resumeStoppedRelays() - } else { - wslHookRelayManager.disposeAll({ permanent: false }) - } - } - }) - // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. - attachClaudeLivePtyPersistence(store) - // Why: while a live claude defers the managed OAuth refresh, usage shows - // "Waiting for Claude session"; refetch when the last live PTY exits so the - // error clears immediately instead of after the failure backoff. - onLiveClaudePtysDrained(() => { - void rateLimits?.refreshAfterClaudeLivePtysDrained() - }) - const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() - seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) - if (persistedClaudePtyIds.length > 0) { - console.log( - `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` - ) - } - applyAppIcon(store.getSettings().appIcon) - if (shouldSuppressDevEducation({ isDev: is.dev })) { - suppressDevEducationForStore(store) - } - try { - // Why: Dock/Launchpad launches don't inherit shell proxy env vars, so apply the persisted proxy before any app-owned network fetchers run. - const proxyApplyResult = await initialProxyApplication - if (proxyApplyResult.source === 'invalid-settings') { - // Why (STA-3442): a silent DIRECT fallback made a dead configured proxy undiagnosable. - console.warn('[proxy] persisted proxy settings are invalid; using direct networking') - } - } catch { - console.warn('[proxy] Failed to apply network proxy settings') - } - // Why: the partition installer reads the proxy through this resolver, so register it before sessions materialize. - setBrowserNetworkProxySettingsResolver(() => store!.getSettings()) - // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. - installDocPreviewProtocolHandler() - registerDocPreviewGrantHandlers() - // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. - initializeBrowserSessionsForApp({ - orcaProfileId: activeOrcaProfile.profile.id, - profileDirectory: activeOrcaProfile.profileDirectory, - // Why: local direct-SSH partitions are scoped to targets, and the orphan - // sweep must see the live target list or it would clear their cookie jars. - listLocalSshTargetIds: () => { - if (!store) { - // Why: an empty list would read as "every SSH jar is an orphan"; throwing skips the sweep. - throw new Error('ssh target store unavailable at partition sweep') - } - return store.getSshTargets().map((target) => target.id) - } - }) - try { - // Why: awaited here so the first guest navigation cannot race the installer's fire-and-forget write. - await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) - } catch { - console.warn('[proxy] Failed to apply network proxy settings to browser sessions') - } - unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() - agentAwakeService = new AgentAwakeService() - agentAwakeService.setMode( - normalizeComputerAwakeMode( - store.getSettings().computerAwakeMode, - store.getSettings().keepComputerAwakeWhileAgentsRun - ) - ) - // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. - agentAwakeService.setStatuses([]) - const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() - const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { - const ownedIdentities = identities.map((identity) => ({ - ...identity, - worktreeId: - identity.worktreeId ?? - runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? - undefined - })) - for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { - // Why not `notifyMobileSessionTabsChanged` alone: it re-emits at the unchanged - // `snapshotVersion`, which every client drops on its monotonic gate. - runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) - } - } - const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { - agentAwakeService?.setStatuses(statuses) - }) - const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( - (sessions) => { - // Healthy session.tabs streams need a push when transcript identity changes. - publishProviderSessionChanges(sessions) - } - ) - // Why: hook rows are the only carrier of live agent state on a headless host, and - // nothing else republishes `session.tabs` when one changes — so a paired client - // would keep the pane's last projection until an unrelated PTY touch came along. - const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() - const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { - if (hookStatusChangedSessionTabs(enriched)) { - runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) - } - }) - // Teardown: agent exit, pane close, and the SSH transient-disconnect batch all land - // here. Without it the live state published above becomes a zombie question card. - const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { - const clearedPaneKeys = - 'paneKey' in clear - ? [clear.paneKey] - : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) - for (const paneKey of clearedPaneKeys) { - hookStatusChangedSessionTabs.forgetPane(paneKey) - runtime?.touchMobileSessionTabsForPane(paneKey) - } - }) - unsubscribeAgentAwakeStatusChanges = () => { - unsubscribeStatusChanges() - unsubscribeProviderSessionChanges() - unsubscribeHookStatusSessionTabs() - unsubscribeHookStatusClear() - } - // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. - initTelemetry(store) - // Why: the breadcrumb alone never leaves the machine — it rides crash reports, and a hang is not - // a crash (the app is force-quit, so no report is ever generated). Without this the incidence - // number the watchdog exists to produce would sit unread on the user's disk. Must run after - // initTelemetry: track() drops silently until the client and store are wired. - if (hangDetection) { - track('main_thread_hang_detected', { - unresponsive_ms: Math.round(hangDetection.unresponsiveMs), - self_recovered: hangDetection.selfRecovered - }) - } - // Why: the trust-grant module is bundled into plain-node CLI entries where - // the telemetry client cannot load, so the tracker is injected here instead - // of imported there. - setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { - track('codex_trust_grant', { - outcome, - host_kind: hostKind, - lane, - ...(reason !== undefined ? { fallback_reason: reason } : {}), - ...(errorClass !== undefined ? { error_class: errorClass } : {}), - ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) - }) - }) - // Why: the error-tracking lane (telemetry-error-tracking.md) is its own - // composition root — independent of product telemetry — and must - // initialize before any IPC handler / runtime span is created so the - // tracer's active sink is populated at the moment the first span fires. - // Honors DO_NOT_TRACK / ORCA_TELEMETRY_DISABLED / ORCA_DIAGNOSTICS_DISABLED - // / CI internally; those gates do not need to be re-checked here. - initObservability() - recordDurableCrashBreadcrumb('main_process_lifecycle_started', { - packaged: app.isPackaged, - platform: process.platform - }) - const skillTransactionRecovery = recoverPendingSkillTransactions( - join(app.getPath('userData'), 'skill-installs') - ) - void skillTransactionRecovery - .then((report) => { - if (report.scanned || report.failures.length || report.truncated) { - console.info('[skills] startup transaction recovery:', { - scanned: report.scanned, - recovered: report.recovered, - failures: report.failures.map((failure) => failure.code), - truncated: report.truncated - }) - } - }) - .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) - // Why: cohort-classifier reads repo count synchronously at every emit, so hydrate it here — before any IPC handler or window can trigger track(). - initCohortClassifier(store) - initOnboardingCohortClassifier(store) - stats = new StatsCollector() - // Agent-session stats come from hook status transitions, the same truth the - // sidebar and dashboard read — never from OSC terminal titles, which miss - // hook-only agents and count any spinner TUI as an agent (#10201). - const agentSessionRecorder = new AgentSessionTransitionRecorder(stats) - agentHookServer.subscribeEnrichedStatus((enriched) => { - agentSessionRecorder.onStatus(enriched) - }) - agentHookServer.subscribePaneStatusClear((clear) => { - agentSessionRecorder.onCleared(clear) - }) - claudeUsage = new ClaudeUsageStore(store) - codexUsage = new CodexUsageStore(store) - openCodeUsage = new OpenCodeUsageStore(store) - rateLimits = new RateLimitService() - codexRuntimeHome = new CodexRuntimeHomeService(store) - void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) - // Why: an incapable trust-grant host must fall back to the managed home for - // every consumer (PTY env, rate limits, commit messages) in one place. - codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) - // Why: while the real-home lane owns ~/.codex/hooks.json, the legacy - // system-home sweep inside managed installs would delete the entry the - // real-home installer just appended. Flag OFF, hooks off, or an incapable - // trust lane re-arms the sweep so downgrade, opt-out, and rollback converge. - setSystemCodexHomeHookSweepSuppressed( - () => - codexRuntimeHome !== null && - codexRuntimeHome.isHostSystemDefaultRealHome() && - isAgentStatusHooksEnabled(store?.getSettings()) - ) - codexSessionMigration = createCodexSessionMigrationScheduler({ - isEligible: () => codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, - isQuitting: () => isQuitting, - resolveSystemCodexHomePathOverride: () => - resolveHostCodexSessionSourceHome(store!.getSettings()), - prepareScheduledRun: (scanDates) => - codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), - finishScheduledRun: () => codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), - startBackfill: startCodexSessionBackfillInBackground, - startIndexHeal: startCodexSessionIndexHealInBackground - }) - codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome, { - onHostSystemDefaultSelected: codexSessionMigration.requestRun - }) - // Why: migrate historical shared-home sessions after startup; compatibility - // launches re-arm the non-destructive pass for new rollouts (#4444, #8612, #12480). - codexSessionMigration.scheduleInitialRun() - claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) - claudeAccounts = new ClaudeAccountService(store, rateLimits, claudeRuntimeAuth) - rateLimits.setCodexHomePathResolver((target) => - codexRuntimeHome!.prepareForRateLimitFetch(target) - ) - rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) - // Why: Kimi's CLI refreshes its OAuth token in whichever runtime it runs in, so the - // usage fetch must read the WSL-side credentials when that's the configured runtime (#12370). - rateLimits.setKimiHomeResolver(() => resolveKimiHome(getKimiRuntimeTarget(store!.getSettings()))) - rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) - const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( - rateLimits, - store.getSettings() - ) - store.onSettingsChanged((updates, settings) => { - // Why: auto is a live policy; retarget only providers whose settings-derived runtime changed. - void syncAccountRuntimeTargets(updates, settings).catch((error) => - console.warn('[rate-limits] Failed to apply account runtime target:', error) - ) - }) - rateLimits.setClaudeAuthPreparationResolver((target) => - claudeRuntimeAuth!.prepareForRateLimitFetch(target) - ) - // Why: live Claude sessions stream usage windows through their statusLine command; feeding them here avoids OAuth usage-endpoint polling (and its 429s). - agentHookServer.setClaudeStatusLineListener((event) => { - rateLimits?.ingestLiveClaudeRateLimits(event) - }) - rateLimits.setOpenCodeGoConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: settings.opencodeSessionCookie, - workspaceIdOverride: settings.opencodeWorkspaceId - } - }) - rateLimits.setMiniMaxConfigResolver(() => { - const settings = store!.getSettings() - return { - sessionCookie: readMiniMaxSessionCookie() ?? '', - groupId: settings.minimaxGroupId, - models: settings.minimaxUsageModels - } - }) - rateLimits.setGeminiCliOAuthEnabledResolver(() => store!.getSettings().geminiCliOAuthEnabled) - rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) - keybindings = new KeybindingService({ - homePath: app.getPath('home'), - getLegacyOverrides: () => store!.getSettings().keybindings, - legacyTabSwitchSeed: { - isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending', - markSeeded: () => { - store!.updateSettings({ tabSwitchKeybindingSeed: 'done' }) - } - } - }) - browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) - rateLimits.setInactiveClaudeAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeClaudeRuntimeSelection(settings).host, - ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.claudeManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - managedAuthPath: account.managedAuthPath, - managedAuthRuntime: account.managedAuthRuntime, - wslDistro: account.wslDistro, - wslLinuxAuthPath: account.wslLinuxAuthPath - })) - }) - rateLimits.setInactiveCodexAccountsResolver(() => { - const settings = store!.getSettings() - const activeIds = new Set( - [ - normalizeCodexRuntimeSelection(settings).host, - ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) - ].filter(Boolean) - ) - return settings.codexManagedAccounts - .filter((account) => !activeIds.has(account.id)) - .map((account) => ({ - id: account.id, - resolveHome: () => { - const resolved = codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) - return resolved.kind === 'ready' - ? { kind: 'ready' as const, managedHomePath: resolved.homePath } - : { kind: 'skip' as const } - } - })) - }) - const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { - resolve: (selector) => { - const environment = resolveEnvironment(app.getPath('userData'), selector) - const pairing = getPreferredPairingOffer(environment) - return { - environmentId: environment.id, - name: environment.name, - peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) - } - }, - call: (selector, method, params, timeoutMs, envelope) => - callRuntimeEnvironment( - app.getPath('userData'), - selector, - method, - params, - timeoutMs, - undefined, - envelope - ) - } - const runtimeService = new OrcaRuntimeService(store, stats, { - agentSessionClaimSigner: loadAgentSessionClaimSigner( - getProfileUserDataPath(), - getProfileUserDataPath() - ), - // Why: resolve the PTY provider lazily — a daemon swap happens later, so an eager reference would freeze the pre-daemon provider (design §4.3). - getLocalProvider: () => getLocalPtyProvider(), - // Why: SSH relay providers register after construction and may reconnect, so destructive cleanup must resolve the current generation. - getSshProvider: (connectionId) => getSshPtyProvider(connectionId), - onPtyStopped: clearProviderPtyState, - onTerminalAgentStatus: (event) => { - agentHookServer.ingestTerminalStatus(event) - }, - // Why: serve can be promoted in place, so wire the listener from startup; runtime enables desktop-only scanners only for a ready renderer. - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - }, - getDesktopWindowStatus: getDesktopWindowStatus, - // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. - getAgentStatusSnapshot: () => - agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), - // Why: the filter above hides resume-identity rows from the live-agent views, but - // those rows carry the provider session mobile native chat addresses transcripts - // by — Pi publishes identity that way and would otherwise be unreachable. - getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), - getAgentProviderSessionRowsForPane: (paneKey) => - agentHookServer.getStatusSnapshotForPane(paneKey), - attestAgentHookCompatibilityAuthority: (candidate) => - agentHookServer.attestCompatibilityAuthority(candidate), - retireAgentHookCompatibilityAuthority: (paneKey) => - agentHookServer.retirePaneAuthority(paneKey), - reconcileAgentStatusForEndedProcess: (paneKeys) => { - agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys) - }, - canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, - // Why: evaluated per call, not captured — the RPC server that owns the device registry is - // constructed with this runtime and does not exist yet at this point. - getPairedDeviceName: (pairedDeviceId) => - runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, - // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. - getAdditionalAiVaultCodexHomePaths: () => - codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], - prepareAiVaultSessionResume: (args) => - prepareCodexAiVaultSessionResume(args, { - runtimeHome: codexRuntimeHome, - systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) - }), - prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => - prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { - launchAgent: 'codex', - workspacePath - }), - buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, - orchestrationEnvironmentTransport, - skillTransactionRecovery - }) - runtime = runtimeService - runtimeService.prepareLegacyWorkerTerminalRecovery() - // Why before anything can attach: a client host that reattaches to a restarted runtime is only - // handed its pages back if the runtime found them first. - runtimeService.rehydrateClientHostedBrowserPages() - publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) - browserManager.setBrowserGuestStateChangedListener((worktreeId) => { - runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - automations = new AutomationService(store, { - claudeUsage, - codexUsage, - terminalObserver: createRuntimeAutomationRunTerminalObserver(runtimeService), - onAutomationsChanged: (payload) => runtimeService.notifyAutomationsChanged(payload), - // Why: desktop clients mirror remote-host automations, but only a server process should execute remote_host_service-owned schedules. - allowRemoteHostScheduling: isServeMode, - headlessDispatcher: isServeMode - ? async ({ automation, run, target }) => { - const terminalSnapshotLimit = 2_000 - let terminalHandle: string - let terminalSessionId: string | null = null - let terminalPaneKey: string | null = null - let terminalPtyId: string | null = null - let workspaceId: string - let workspaceDisplayName: string | null = null - - if (automation.workspaceMode === 'new_per_run') { - const created = await runtimeService.createManagedWorktree({ - ...buildHeadlessAutomationWorktreeCreateArgs({ - automation, - run, - repo: target.repo - }) - }) - terminalHandle = created.startupTerminal?.handle ?? '' - terminalSessionId = created.startupTerminal?.tabId ?? null - terminalPaneKey = created.startupTerminal?.paneKey ?? null - terminalPtyId = created.startupTerminal?.ptyId ?? null - workspaceId = created.worktree.id - workspaceDisplayName = created.worktree.displayName ?? null - if (!terminalHandle) { - throw new Error( - created.warning || - 'Automation workspace was created, but no agent terminal started.' - ) - } - } else { - if (!automation.workspaceId) { - throw new Error('The target workspace is no longer available.') - } - const terminal = await runtimeService.launchAgentTerminal( - `id:${automation.workspaceId}`, - { - agent: automation.agentId, - prompt: automation.prompt, - title: run.title - } - ) - terminalHandle = terminal.handle - terminalSessionId = terminal.tabId ?? null - terminalPaneKey = terminal.paneKey ?? null - terminalPtyId = terminal.ptyId ?? null - workspaceId = terminal.worktreeId - const worktree = await runtimeService.showManagedWorktree(`id:${workspaceId}`) - workspaceDisplayName = worktree.displayName ?? null - } - - const completion = (async () => { - const wait = await runtimeService.waitForTerminal(terminalHandle, { - condition: 'tui-idle' - }) - const read = await runtimeService.readTerminal(terminalHandle, { - limit: terminalSnapshotLimit - }) - const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() - snapshotBuffer.append(read.tail.join('\n')) - if (wait.satisfied) { - return { - status: 'completed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: null - } - } - return { - status: 'dispatch_failed' as const, - outputSnapshot: snapshotBuffer.snapshot(), - error: wait.blockedReason - ? `Automation agent is blocked: ${wait.blockedReason}.` - : 'Automation agent did not report completion.' - } - })() - - return { - workspaceId, - workspaceDisplayName, - terminalSessionId, - terminalPaneKey, - terminalPtyId, - completion - } - } - : undefined - }) - runtimeService.setAutomationService(automations) - runtimeService.setArtifactService( - new ArtifactCloudService(app.getPath('userData'), () => - isArtifactSharingEnabled(store?.getSettings()) - ) - ) - runtimeService.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) - runtimeService.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) - runtimeService.setCommitMessageAgentEnvironmentResolvers({ - // Why: Codex hooks/auth live in Orca's managed runtime home even for the default path, so every launch must resolve CODEX_HOME via runtime-home. - prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, - prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) - }) - const pluginSystemStartupStartedAt = performance.now() - pluginKillListService = new PluginKillListService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')) - }) - await pluginKillListService.initialize() - pluginMarketplaceService = new PluginMarketplaceService({ - pluginsDataDir: getPluginsDataDir(app.getPath('userData')), - getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null - }) - const requestOfficialMarketplaceSeed = (): void => { - if (store?.getSettings().pluginSystemEnabled !== true) { - return - } - void pluginMarketplaceService?.seedOfficialSource().catch((error) => { - console.warn('[plugins] failed to configure the official marketplace:', error) - }) - } - pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ - marketplace: pluginMarketplaceService, - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null - }) - pluginService = new PluginService({ - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - // Feature flag: with the setting off, discovery returns nothing and no - // plugin code path runs at all. - isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, - getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), - getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), - getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), - getKeybindings: () => keybindings?.getOverrides() ?? {}, - getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, - hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) - }) - const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ - root: resolveBundledPluginRoot({ - isPackaged: app.isPackaged, - resourcesPath: process.resourcesPath, - appPath: app.getAppPath() - }), - userDataPath: app.getPath('userData'), - hostVersion: app.getVersion(), - isEnabled: () => store?.getSettings().pluginSystemEnabled === true, - blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, - refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() - }) - const requestBundledPluginBootstrap = (): void => { - void bundledPluginBootstrap - .request() - .then((result) => { - for (const failure of result?.errors ?? []) { - console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) - } - }) - .catch((error) => { - console.warn('[plugins] failed to bootstrap bundled plugins:', error) - }) - } - pluginKillListService.onChanged(() => { - void pluginService?.reconcileActivationState().catch((error) => { - console.warn('[plugins] failed to apply plugin safety-list refresh:', error) - }) - }) - store.onSettingsChanged((updates) => { - if (updates.pluginSystemEnabled === true) { - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - } - if (app.isPackaged && updates.pluginSystemEnabled === true) { - void pluginKillListService?.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - }) - // Why: headless `orca serve` clients reach plugins through the runtime RPC - // methods, which resolve the service via this module-level setter. Consent - // over RPC uses the same hash-keyed write path as the desktop dialog. - setPluginServiceForRpc(pluginService, { - applyConsent: (request) => - applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), - applyEnablement: (pluginKey, enabled) => - applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) - }) - // Lazy kernel: initialize() only discovers manifests — no worker forks, no - // panel reads. Zero plugin code runs before an explicit trigger. - void pluginService - .initialize() - .then(() => { - logStartupMilestone('plugin-system-initialized', { - durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), - installedPlugins: pluginService?.getDiscovered().length ?? 0 - }) - }) - .catch((error) => { - console.warn('[plugins] failed to initialize plugin service:', error) - }) - if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { - void pluginKillListService.refresh().catch((error) => { - console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) - }) - } - pluginService.onChanged((event) => { - if ( - event.contentPacksChanged && - setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) - ) { - void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) - } - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send('plugins:changed', event) - } - } - }) - requestBundledPluginBootstrap() - requestOfficialMarketplaceSeed() - // v0 plugin event seams: agent status (hook pipeline tap) + worktree - // lifecycle (runtime tap). Server-side filtered per plugin subscription. - agentHookServer.subscribeEnrichedStatus((enriched) => { - // Why: plugins may automate on `working`; restored rows are historical claims, not fresh activity. - if (enriched.restoredUnconfirmed) { - return - } - pluginService?.emitEvent('agent.status.changed', { - worktreeId: enriched.worktreeId ?? null, - paneKey: enriched.paneKey, - state: enriched.payload.state, - receivedAt: enriched.receivedAt - }) - }) - runtimeService.onWorktreeLifecycle((event) => { - emitPluginWorktreeLifecycle(event) - }) - starNag = new StarNagService(store, stats) - starNag.start() - starNag.registerIpcHandlers() - const agentBrowserBridge = new AgentBrowserBridge(browserManager, { - onTabsChanged: (worktreeId) => runtimeService.notifyMobileSessionTabsChanged(worktreeId) - }) - runtimeService.setAgentBrowserBridge(agentBrowserBridge) - // Why: daemons a crashed or SIGKILL'd previous run left behind answer to nobody; nothing else reclaims them. - void agentBrowserBridge.sweepOrphanedSessions() - const browserClientAutomationDispatcher = new RpcDispatcher({ runtime: runtimeService }) - configureBrowserClientPageAutomationRuntime({ - browserManager, - getAgentBrowserBridge: () => agentBrowserBridge, - executeRpc: async (method, params, signal) => { - const response = await browserClientAutomationDispatcher.dispatch( - { - id: randomUUID(), - authToken: 'local-browser-client-automation', - method, - params - }, - { signal } - ) - if (!response.ok) { - throw new BrowserClientPageCommandError(response.error.code) - } - return response.result - } - }) - - // Emulator bridge (serve-sim). macOS-only feature (gated in CLI/runtime); always ship like agent-browser. - // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. - const emulatorBridge = new EmulatorBridge() - runtimeService.setEmulatorBridge(emulatorBridge) - // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or - // crash mid-delete can leave the moved directory on disk. - void sweepStaleWorktreeTrash( - collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) - ).catch((error) => { - console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) - }) - nativeTheme.themeSource = store.getSettings().theme ?? 'system' - // Why (#16441): the real-home grant runs a codex app-server session. It stays - // ordered before managed-hook reconciliation — an incapable host must re-arm - // and complete the legacy real-home sweep first — but awaiting it inline - // stalled app init behind that session, so chain instead of blocking. - const startupManagedHookSettings = store.getSettings() - const shouldReconcileStartupManagedHooks = - shouldInstallManagedHooks(is.dev) && - resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' - const realHomeCodexHookState = - shouldReconcileStartupManagedHooks && - shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && - codexRuntimeHome.isHostSystemDefaultRealHomeSelected() - ? ensureRealHomeCodexHookState({ - hooksEnabled: true, - userDataPath: app.getPath('userData') - }).catch((error: unknown) => { - console.warn('[codex-real-home-hooks] startup ensure failed:', error) - }) - : Promise.resolve() - // Why skip rather than remove when the off switch is set: the hook files are user-global but this - // decision reads only THIS profile's settings, so removing here deletes the hooks every other Orca - // instance depends on (STA-5679). Skipping already keeps removed hooks from reappearing on launch. - if (shouldReconcileStartupManagedHooks) { - const managedHookStore = store - void realHomeCodexHookState - .then(() => - installManagedAgentHooks(managedHookStore.getSettings(), { - shouldHydrateShellPath: app.isPackaged, - onInstallError: recordManagedHookInstallFailure, - shouldContinue: (agent) => { - const settings = managedHookStore.getSettings() - return shouldContinueManagedHookStartup(isQuitting, settings, agent) - } - }) - ) - .catch((error: unknown) => { - console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) - }) - } - // Why: process-gone metrics only see survivors; retain a recent whole-app - // snapshot for comparison in crash reports. - startPreGoneProcessMetricsSampling() - app.on('child-process-gone', (_event, details) => { - recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { - name: details.name, - serviceName: details.serviceName, - type: details.type - }) - if ( - isGpuFallbackCrashCandidate({ - platform: process.platform, - processType: details.type, - reason: details.reason - }) - ) { - const crashedAt = performance.now() - void gpuCrashDiagnostics?.record() - void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) - } - }) - - logStartupMilestone('services-initialized') - await ensureMainI18n() - await setMainUiLanguage(store.getSettings().uiLanguage) - logStartupMilestone('i18n-ready') - - registerAppMenu({ - appMenuLabel: devInstanceIdentity.name, - onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), - onBeforeReload: ({ ignoreCache, webContentsId }) => { - if (mainWindow?.webContents.id === webContentsId) { - markExpectedRendererReload(webContentsId) - } - recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) - }, - onOpenSettings: openSettingsFromSystemMenu, - onOpenSetupGuide: (targetWindow) => { - recordCrashBreadcrumb('setup_guide_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenSetupGuide(targetBrowserWindow) - }, - onOpenCrashReport: (targetWindow) => { - recordCrashBreadcrumb('crash_report_opened') - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenCrashReport(targetBrowserWindow) - }, - onOpenFeatureTour: (targetWindow) => { - recordCrashBreadcrumb('feature_tour_opened') - // Why: use the invoking BrowserWindow so hidden/E2E and multi-window flows route to the right renderer, not global focus. - const targetBrowserWindow = targetWindow instanceof BrowserWindow ? targetWindow : null - sendOpenFeatureTour(targetBrowserWindow) - }, - // Why: menu zoom must act on the window the user is looking at — routing to - // the main window while the dashboard pop-out is focused zooms behind it. - onZoomIn: () => { - if (!zoomDashboardPopoutIfFocused('in')) { - mainWindow?.webContents.send('terminal:zoom', 'in') - } - }, - onZoomOut: () => { - if (!zoomDashboardPopoutIfFocused('out')) { - mainWindow?.webContents.send('terminal:zoom', 'out') - } - }, - onZoomReset: () => { - if (!zoomDashboardPopoutIfFocused('reset')) { - mainWindow?.webContents.send('terminal:zoom', 'reset') - } - }, - onToggleLeftSidebar: () => { - mainWindow?.webContents.send('ui:toggleLeftSidebar') - }, - onToggleRightSidebar: () => { - mainWindow?.webContents.send('ui:toggleRightSidebar') - }, - onToggleAppearance: (key) => { - if (!store) { - return - } - if (key === 'statusBarVisible') { - // Why: status bar visibility lives in persisted UI state (not settings) and the renderer owns the toggle — forward the event, let it flip + store. - mainWindow?.webContents.send('ui:toggleStatusBar') - return - } - const current = store.getSettings() - // Why: these appearance settings are default-on, so a missing persisted value must toggle from visible -> hidden. - const next = getNextDefaultOnAppearanceSettingValue(current[key]) - store.updateSettings({ [key]: next }, { notifyListeners: true }) - rebuildAppMenu() - }, - getAppearanceState: () => { - const settings = store?.getSettings() - const ui = store?.getUI() - return { - showTasksButton: settings?.showTasksButton !== false, - showAutomationsButton: settings?.showAutomationsButton !== false, - showMobileButton: settings?.showMobileButton !== false, - showTitlebarAppName: settings?.showTitlebarAppName !== false, - statusBarVisible: ui?.statusBarVisible !== false - } - }, - getKeybindings: () => keybindings?.getOverrides() - }) - // Why: parallel E2E Electron instances would race the fixed port (EADDRINUSE); port 0 gives each a random OS-assigned port. - const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) - const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT - const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) - if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { - throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) - } - // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). - const devWsPort = is.dev && !isE2E ? 6769 : undefined - let serveOptions: ServeOptions | null = null - try { - serveOptions = isServeMode ? getServeOptions() : null - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - app.exit(1) - return - } - // Why: existing installs may have pairing creds under the late app.getPath('userData'); copy them forward before switching to the canonical path. - migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) - runtimeRpc = new OrcaRuntimeRpcServer({ - runtime, - // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. - userDataPath: getCanonicalUserDataPath(), - enableWebSocket: true, - // Why: STA-2370 — the desktop app binds the WS listener to loopback until the user pairs a device; - // `orca serve` is an explicit remote opt-in, and E2E keeps the wide bind its harness connects over. - exposeNetworkByDefault: Boolean(serveOptions) || isE2E, - ...(isE2E ? { wsPort: e2eWsPort } : {}), - ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined - ? { - wsPort: serveOptions.wsPort, - // Why: only explicit `orca serve --port` overrides a stale STA-1511 fallback (issue #8535); default/dev stay fallback-first for pairing stability. - preferPinnedWsPort: true - } - : {}), - webClientRoot: getBundledWebClientRoot() - }) - registerMobileHandlers(runtimeRpc, { - getRelayStatus: () => desktopRelayStatus, - consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { - if ( - !mainWindow || - mainWindow.isDestroyed() || - mainWindow.webContents.id !== webContentsId || - !pendingUnpairedDeviceAuthFailure - ) { - return false - } - pendingUnpairedDeviceAuthFailure = false - return true - } - }) - // Why: repeated direct auth failures otherwise look like a client that never connects; point users to re-pairing. - runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { - // Why: runtime startup races renderer mount; retain the one-shot until the listener consumes it. - pendingUnpairedDeviceAuthFailure = true - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') - } - }) - - const shellPathReady = windowsShellPathHydration.whenReady() - let desktopWindow: BrowserWindow | null = null - if (process.platform === 'win32' && app.isPackaged && !serveOptions) { - const desktopStartup = startWindowsDesktopBeforeShellPathReady({ - bindServices: bindTerminalRuntimeStartupServices, - openWindow: () => openMainWindow({ revealOnDidFinishLoad: true }), - shellPathReady, - startServices: startTerminalRuntimeStartupServices - }) - desktopWindow = desktopStartup.window - } else { - await shellPathReady - bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) - } - app.on('activate', handleMacAppActivation) - - if (serveOptions) { - // Why: give managed WSL launchers a brief chance to migrate before headless PTYs go live, without slow repairs withholding all RPC readiness. - logStartupMilestone('wsl-cli-barrier-start') - await managedWslCliStartupBarrierReady - logStartupMilestone('wsl-cli-barrier-resolved', { - reconciliation: managedWslCliReconciliationStatus - }) - // Why: headless PTYs must not start on the fallback provider, then get swept when an activated renderer registers desktop lifecycle handlers. - await localPtyStartupReady - await localPtyProviderStartupReady - await registerHeadlessPtyRuntime( - runtime, - prepareCodexRuntimeHomeForLaunch, - () => store!.getSettings(), - (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target), - store, - prepareCodexSessionResumeForLaunch, - { - onCodexHomePtySpawned: handleCodexHomePtySpawned, - onPtyExit: handlePtyExit - } - ) - await runtime.refreshRestoredOrchestrationAuthority() - await runtime.reconcileLegacyWorkerTerminals() - // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. - if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend( - new OffscreenBrowserBackend(browserManager, { - getAgentBrowserBridge: () => agentBrowserBridge - }) - ) - } - // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. - runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) - await runtimeRpc.start().catch((error) => { - console.error('[runtime] Failed to start headless RPC transport:', error) - throw error - }) - settleServeDesktopActivation() - // Why: every attempt must reach app.quit(); a page beforeunload can veto an earlier signal. - registerServeSignalHandlers(process, () => app.quit()) - // Why: headless serve has no renderer to run the normal cli:install flow; do it here for macOS/Linux only (Windows-excluded: install() only mutates registry PATH, not child terminals). - if (process.platform === 'darwin' || process.platform === 'linux') { - try { - // Why: serve is headless — a fallback osascript admin prompt would hang it; skip elevation since ~/.local/bin needs none. - const cliStatus = await new CliInstaller({ - privilegedRunner: async () => { - throw new Error('serve CLI auto-install must not request administrator privileges') - } - }).install() - console.log( - `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` - ) - } catch (error) { - console.warn( - '[serve] orca CLI install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // Why: Linux CLI installs as `orca-ide`, but the Claude Team launcher invokes bare `orca`; drop a ~/.local/bin dispatcher (ahead of /usr/bin) so it resolves. Best-effort. - if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { - try { - const dispatcher = await installLinuxBareOrcaDispatcher({ - resourcesPath: process.resourcesPath - }) - console.log( - `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + - `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` - ) - } catch (error) { - console.warn( - '[serve] bare orca dispatcher install skipped:', - error instanceof Error ? error.message : String(error) - ) - } - } - // 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 - } - - // Why: window and RPC startup run in parallel; registerPtyHandlers gates PTY spawns so RPC binds without racing the daemon provider swap. - const desktopRuntimeRpc = runtimeRpc - if (!desktopRuntimeRpc) { - throw new Error('runtime_rpc_unavailable') - } - const [win, runtimeRpcStartResult] = await Promise.all([ - Promise.resolve(desktopWindow ?? openMainWindow()), - shellPathReady - .then(() => desktopRuntimeRpc.start()) - .then( - () => ({ ok: true as const }), - (error: unknown) => { - recordRuntimeRpcStartFailure(error) - return { ok: false as const, error } - } - ) - ]) - if (!runtimeRpcStartResult.ok) { - void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) - } - - const cloudAuth = getOrcaCloudAuthConfig() - if (cloudAuth.configured) { - try { - const relayService = new DesktopRelayService({ - authConfig: cloudAuth.config, - userDataPath: getProfileUserDataPath(), - appVersion: app.getVersion(), - runtimeRpc, - onStatus: (status) => { - desktopRelayStatus = status - mainWindow?.webContents.send('mobile:relayStatusChanged', status) - } - }) - desktopRelayService = relayService - runtimeRpc.setMobileRelayPairingProvider({ - createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), - onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), - onDemandStateChanged: () => relayService.demandStateChanged(), - getEndpoints: (context, params) => relayService.getEndpoints(context, params), - provisionRelay: (context, params) => relayService.provisionRelay(context, params) - }) - relayService.start() - // Why: sleeping past relay-token expiry kills the broker with no retry - // timer; resume is the moment that state becomes recoverable. - powerMonitor.on('resume', () => desktopRelayService?.ensureLive()) - } catch (error) { - console.warn( - '[relay] Desktop relay startup unavailable:', - error instanceof Error ? error.message : String(error) - ) - } - } - - // Why: macOS notification permission dialog must fire after the window is shown, else it's hidden behind the maximized window. - win.once('show', () => { - // Why: store can be null if init failed earlier; bail rather than throw inside an Electron event listener. - if (!store) { - return - } - const onboarding = store.getOnboarding() - if (onboarding.closedAt !== null) { - triggerStartupNotificationRegistration(store) - } - }) -}) - -// Why: app.exit() skips Electron quit events, so keep its log child from surviving forced exits. -process.once('exit', stopTccPromptNotice) - -app.on('before-quit', () => { - if (isQuittingForUpdate()) { - recordUpdaterLifecycle('before_quit_allowed', undefined, { - message: 'before-quit allowed for update install' - }) - } - isQuitting = true - desktopRelayService?.fenceAndCloseNow() - runtimeRpc?.setMobileRelayPairingProvider(null) - unsubscribeAgentAwakeStatusChanges?.() - unsubscribeAgentAwakeStatusChanges = null - agentAwakeService?.dispose() - agentAwakeService = null - // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). - rateLimits?.stop() -}) - -// Why: will-quit fires twice — first pass preventDefaults and runs teardown; second pass exits. -let daemonDisconnectDone = false -// Why 2s: a config delete is best-effort, not durable state. -const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 - -app.on('will-quit', (e) => { - // Why return instead of re-running teardown: the second pass is Electron re-firing after - // our own app.quit(), so every step below already ran and every durable write already - // landed. Re-entering would start a fresh unawaited write that the exit then tears down. - if (daemonDisconnectDone) { - return - } - // Why preventDefault before any work: everything below must be free to await, and a - // synchronous durable write here parks the main thread — uninterruptibly, on a stalled - // network profile mount. The teardown deadline cannot rescue that, because its timer - // lives on the same thread it would need to bound (#9447 covers the wedged-transport - // half; this covers the blocked-syscall half). - if (!quitTeardownStartGate.tryStart(e)) { - return - } - unsubscribeSystemResumeBroadcast?.() - unsubscribeSystemResumeBroadcast = null - // Why: renderer guards can still cancel before this committed phase; `log stream` must survive those vetoes. - stopTccPromptNotice() - const updateQuitInProgress = isQuittingForUpdate() - if (updateQuitInProgress) { - recordUpdaterLifecycle( - 'will_quit_cleanup_started', - { daemonTeardown: 'disconnect' }, - { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } - ) - } - // Why: before-quit can still be aborted by renderer beforeunload; only remove the Windows tray icon on the committed quit path. - destroySystemTray() - // Why: an agent still working at quit gets no terminating hook, so stats.flushAsync() closes those sessions out synchronously (only the write is deferred) — otherwise their duration is lost. - starNag?.stop() - automations?.stop() - // Why: plugin hosts are forked children; dispose sends shutdown and - // escalates to SIGKILL so they cannot outlive the app. The promise joins - // the teardown barrier below — quitting before it resolves would let - // Electron exit first and orphan the hosts. - setPluginServiceForRpc(null) - pluginKillListService = null - pluginMarketplaceService = null - pluginMarketplaceInstaller = null - const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() - const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() - const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() - pluginService = null - setUnreadDockBadgeCount(0) - agentHookServer.stop() - // Why Windows only: POSIX hooks short-circuit on ORCA_PANE_KEY, while Windows must register a - // bare script path that cannot express the guard and would otherwise keep spawning after quit. - // Why bounded here: every other teardown member carries its own ceiling, and this one reaches - // $GROK_HOME -- which can be a stalled network mount, where the fs calls never settle and the - // shared 20s deadline becomes the only thing ending the quit. - const grokHookCleanup = - process.platform === 'win32' - ? settleWithinMs( - removeManagedAgentHooksAsync({ agents: ['grok'] }), - GROK_HOOK_CLEANUP_DEADLINE_MS - ).then((settled) => { - if (settled.outcome === 'timed-out') { - console.warn('[agent-hooks] Grok hook cleanup on quit timed out') - return - } - if (settled.outcome === 'failed') { - console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) - return - } - // Why: removers report failures as statuses, so inspect details even after fulfillment. - for (const status of settled.value.filter((entry) => entry.detail)) { - console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) - } - }) - : Promise.resolve() - // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. - wslHookRelayManager.disposeAll() - const statsFlush = stats?.flushAsync() ?? Promise.resolve() - // Why: agent-browser daemon processes would otherwise linger after quit, holding ports and stale session state on disk. - // Why the barrier below: each session's close is its own agent-browser child taking hundreds of ms, - // so an unawaited call reaches app.quit() first and every open tab's daemon survives the quit (#16367). - // Why retire headless page owners first: it closes those helpers without a duplicate close fanout. - const browserShutdown = (async (): Promise => { - await runtime?.getOffscreenBrowserBackend()?.destroyAll?.() - await runtime?.getAgentBrowserBridge()?.destroyAllSessions() - })() - // Why (review P2-4): local SSH browser routes own loopback listeners and, on the - // system-ssh path, `ssh -N -D` children that would otherwise outlive the app. - const localSshRouteShutdown = import('./browser/local-ssh-browser-route') - .then((routes) => routes.closeAllLocalSshBrowserRoutes()) - .catch(() => {}) - browserManager.setBrowserGuestStateChangedListener(null) - const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() - // Why immediately before store.flushAsync() with no await in between: beginSshShutdown() marks every - // active SSH lease detached in memory synchronously, and that flush is what persists it. - const sshShutdown = beginSshShutdown() - killAllPty() - const watcherShutdown = shutdownWatchersOnce() - const storeFlush = store?.flushAsync() ?? Promise.resolve() - // 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(() => {}) - const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() - const skillUploadShutdown = runtime?.disposeSkillUploadSessions() ?? Promise.resolve() - - // Why: capture pid/runtimeId synchronously (before any await) so a later teardown path can't null them out mid-chain. - const ownedPid = process.pid - const ownedRuntimeId = runtime?.getRuntimeId() - const rpcStopAndClear = runtimeRpc - ? runtimeRpc - .stop() - .then(() => awaitRuntimeFileWatcherUnsubscribes()) - .then(() => { - if (ownedRuntimeId) { - // Why: must match the path the runtime server wrote metadata to (getCanonicalUserDataPath), not late app.getPath('userData'). - clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) - } - }) - .catch((error) => { - console.error('[runtime] Failed to stop local RPC transport:', error) - }) - : Promise.resolve() - // Why: allSettled (not all) keeps fail-open — a daemon-disconnect rejection still quits instead of hanging. - // Why: telemetry flush folds in before app.quit() (bounded 2s); catch defensively so a flush failure can't cancel the quit chain. - // Why: normal quits keep the detached daemon for warm reattach, but a dead dev parent leaves the temp/dev profile ownerless. - const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() - // Why: a wedged transport (half-open post-sleep socket) can leave one - // member unsettled forever and block app.quit() until Force Quit (#9447). - // Why stats/state join here: their writes are durable but not worth hanging the app for. - // Losing at most the last debounce interval beats a quit that never completes, and the - // temp+rename swap means a write cut short by the deadline leaves the old file intact. - settleTeardownWithinDeadline([ - { name: 'daemon', promise: daemonTeardown }, - { name: 'browser', promise: browserShutdown }, - { name: 'runtime-rpc', promise: rpcStopAndClear }, - { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown }, - { name: 'browser-client-hosts', promise: browserClientHostShutdown }, - { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, - { name: 'ssh', promise: sshShutdown }, - { name: 'plugin-hosts', promise: pluginHostShutdown }, - { name: 'skill-uploads', promise: skillUploadShutdown }, - { name: 'grok-hooks', promise: grokHookCleanup }, - { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, - { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, - { name: 'usage-cache', promise: usageCacheFlush }, - { name: 'stats', promise: statsFlush }, - { name: 'state', promise: storeFlush } - ]) - .then((pendingTeardowns) => { - if (pendingTeardowns.length > 0) { - console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) - } - }) - .then(() => shutdownTelemetry()) - .then(() => shutdownObservability()) - .catch(() => { - /* swallow — telemetry must never prevent app.quit() */ - }) - .then(() => { - daemonDisconnectDone = true - app.quit() - }) -}) - -app.on('window-all-closed', () => { - // Why: serve mode / disposable offscreen browser windows must not take down runtime RPC — the policy fn keeps the app alive. - // Why: on macOS a quit-in-progress (Cmd+Q) is canceled by the renderer buffer-capture deferral; re-trigger quit so it actually exits. - if ( - shouldQuitWhenAllWindowsClosed({ - platform: process.platform, - isQuitting, - isServeMode - }) - ) { - app.quit() - } -}) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 2bdc6faa9db..e517a834d5e 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -1,2429 +1,48 @@ -/* eslint-disable max-lines */ -import { BrowserWindow, dialog, ipcMain, shell } from 'electron' -import { readdir, readFile, writeFile, stat, lstat, open, rename, rm } from 'node:fs/promises' -import type { FileHandle } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' -import { dirname, extname, join, resolve } from 'node:path' -import type { ChildProcess } from 'node:child_process' -import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync, wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { tryDeleteWslUncPath } from '../wsl-unc-delete' import type { Store } from '../persistence' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { - GitBranchCompareResult, - GitCommitCompareResult, - GitDiffResult -} from '../../shared/git-diff-compare-types' -import type { GitForkSyncExpectedUpstream, GitForkSyncResult } from '../../shared/git-fork-sync' -import type { - GitConflictOperation, - GitStagingArea, - GitStatusResult, - GitUpstreamStatus -} from '../../shared/git-status-types' -import type { GlobalSettings } from '../../shared/global-settings-types' -import type { Repo } from '../../shared/repo-types' -import type { TuiAgent } from '../../shared/tui-agent' -import type { GitPushTarget } from '../../shared/worktree/types' -import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' -import type { GitAdmissionTier } from '../git/command-runner/git-exec-options' -import type { SshMutationExpectation } from '../../shared/ssh-types' -import { sortDirEntries } from '../../shared/file-name-sort' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import { - getStatus, - getSubmoduleStatus, - abortMerge, - abortRebase, - detectConflictOperation, - getDiff, - commitChanges, - stageFile, - unstageFile, - bulkStageFiles, - bulkUnstageFiles, - bulkDiscardChanges, - discardChanges, - getStagedCommitContext, - getBranchCompare, - getBranchDiff, - getCommitCompare, - getCommitDiff -} from '../git/status' -import { getHistory } from '../git/history' -import { - cancelGenerateCommitMessageLocal, - cancelGeneratePullRequestFieldsLocal, - discoverCommitMessageModelsLocal, - discoverCommitMessageModelsRemote, - generateCommitMessageFromContext, - generatePullRequestFieldsFromContext, - resolveCommitMessageSettings, - type DiscoverCommitMessageModelsResult, - type CommitMessageGenerationTarget, - type GenerateCommitMessageResult, - type GeneratePullRequestFieldsResult -} from '../text-generation/commit-message-text-generation' -import { getPullRequestDraftContext } from '../text-generation/pull-request-context' -import { getUpstreamStatus } from '../git/upstream' -import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' -import { gitSyncForkDefaultBranch } from '../git/fork-sync' -import { validateGitForkSyncExpectedUpstream } from '../../shared/git-fork-sync' -import { checkIgnoredPaths } from '../git/check-ignored-paths' -import { - appendFolderToGitignore, - findKnownHugeFolderPathsToIgnore -} from '../git/huge-folder-ignore' -import { assertGitPushTargetShape } from '../../shared/git-push-target-validation' -import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' -import type { HostedReviewProvider } from '../../shared/hosted-review' -import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' -import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' -import { validateGitPushTarget } from '../git/push-target-validation' -import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' -import { resolveAuthorizedPath, authorizeExternalPath } from './filesystem-auth' -import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache' -import { validateGitRelativeFilePath, isENOENT } from './filesystem-path-containment' -import { listQuickOpenFiles } from './filesystem-list-files' -import { registerFilesystemMutationHandlers } from './filesystem-mutations' -import { searchWithGitGrep } from './filesystem-search-git' -import { - getLocalGitOptionsForRegisteredWorktree, - getLocalGitOptionsForRepo, - getLocalRepoForRegisteredWorktree -} from './local-worktree-runtime-options' -import { - resolveSourceControlAiLinkedIssue, - resolveSourceControlAiLinkedIssueMeta -} from './source-control-ai-linked-issue' -import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' -import { checkRgAvailable } from './rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - getSshFilesystemProvider, - requireSshFilesystemProvider -} from '../providers/ssh-filesystem-dispatch' -import { - getSshGitProvider, - SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-git-dispatch' -import { resolveHostedReviewBodyForGeneration } from '../source-control/pull-request-template' -import { loadPullRequestLinkedIssue } from '../source-control/pull-request-linked-issue' -import { - prepareLocalCommitMessageAgentEnv, - type CommitMessageAgentRuntimeTarget, - type CommitMessageAgentEnvironmentResolvers -} from '../text-generation/commit-message-agent-environment' -import { listRepoWorktrees } from '../repo-worktrees' -import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' -import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from './readdir-error-diagnostics' -import { splitWorktreeId } from '../../shared/worktree/id' -import { getRuntimePathBasename } from '../../shared/cross-platform-path' -import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerLocalLogTailHandlers } from './local-log-tail' -import { localLogFileIdentity } from '../ai-vault/local-log-tail-reader' -import { sanitizeLocalDownloadFilename } from '../local-download-filename' -import { registerFilesystemDownloadFolderHandlers } from './filesystem-download-folder' -import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' -import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' import { - applyGitStatusUpstreamRefWatchRequest, - type GitStatusUpstreamRefWatchRequest -} from './git-status-upstream-ref-watch-request' - -// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. -const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB -const BINARY_PROBE_BYTES = 8192 -const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ -// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. -const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 -// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. -const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB -const PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} -async function readLocalLogSnapshot(filePath: string): Promise<{ - content: string - isBinary: boolean - fileIdentity?: string -}> { - const handle = await open(filePath, 'r') - try { - const stats = await handle.stat() - if (stats.size > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - const buffer = await handle.readFile() - if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { - throw new Error( - `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` - ) - } - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - return { - content: buffer.toString('utf8'), - isBinary: false, - fileIdentity: localLogFileIdentity(stats) - } - } finally { - await handle.close() - } -} - -type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } - -function validateRequiredString(value: unknown, label: string): string { - if (typeof value !== 'string' || value.trim() === '') { - throw new Error(`${label} is required`) - } - return value -} - -function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { - if (encoding === 'base64') { - return Buffer.from(content, 'base64') - } - return Buffer.from(content, 'utf8') -} - -type DownloadSession = { - destinationPath: string - tempPath: string - destinationExisted: boolean - handle: FileHandle - cleanupTimer: ReturnType - senderId: number -} - -const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 - -function createSiblingTransferPath(destinationPath: string, suffix: string): string { - // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. - return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) -} - -async function cleanupLocalTransferPath(filePath: string | null): Promise { - if (!filePath) { - return - } - await rm(filePath, { force: true }).catch(() => {}) -} - -async function inspectDownloadDestination(destinationPath: string): Promise<{ existed: boolean }> { - try { - const destinationStat = await stat(destinationPath) - if (destinationStat.isDirectory()) { - throw new Error('Cannot download to a directory') - } - return { existed: true } - } catch (error) { - if (isENOENT(error)) { - return { existed: false } - } - throw error - } -} - -async function assertDestinationStillUnclaimed(destinationPath: string): Promise { - try { - await stat(destinationPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - throw new Error('Destination file appeared before download completed') -} - -async function promoteDownloadedFile( - tempPath: string, - destinationPath: string, - destinationExisted: boolean -): Promise { - if (!destinationExisted) { - await assertDestinationStillUnclaimed(destinationPath) - await rename(tempPath, destinationPath) - return - } - - const backupPath = createSiblingTransferPath(destinationPath, 'backup') - let backupCreated = false - try { - await rename(destinationPath, backupPath) - backupCreated = true - await rename(tempPath, destinationPath) - await cleanupLocalTransferPath(backupPath) - } catch (error) { - if (backupCreated) { - await rename(backupPath, destinationPath).catch(() => {}) - } - throw error - } -} - -function comparableLocalPath(value: string): string { - const normalized = resolve(value) - return process.platform === 'win32' ? normalized.toLowerCase() : normalized -} - -function getCandidateLocalWorktreePaths( - worktreePath: string, - resolvedWorktreePath: string -): Set { - return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) -} - -function hasRegisteredWorktreeMetaForRepo( - store: Store, - repoId: string, - candidatePaths: Set -): boolean { - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { - return true - } - } - return false -} - -function comparableRemotePath(value: string): string { - return value.replace(/[/\\]+$/g, '') -} - -function hasRegisteredRemoteWorktreeMetaForRepo( - store: Store, - repoId: string, - worktreePath: string -): boolean { - const comparableWorktreePath = comparableRemotePath(worktreePath) - for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { - const parsed = splitWorktreeId(worktreeId) - if ( - parsed?.repoId === repoId && - comparableRemotePath(parsed.worktreePath) === comparableWorktreePath - ) { - return true - } - } - return false -} - -async function localRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string -): Promise { - let resolvedWorktreePath: string - try { - resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) - } catch { - return false - } - const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) - if (candidatePaths.has(comparableLocalPath(repo.path))) { - return true - } - if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { - return true - } - try { - const worktrees = await listRepoWorktrees(repo) - return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) - } catch { - return false - } -} - -async function remoteRepoOwnsWorktree( - store: Store, - repo: Repo, - worktreePath: string, - connectionId: string -): Promise { - const comparableWorktreePath = comparableRemotePath(worktreePath) - if (comparableRemotePath(repo.path) === comparableWorktreePath) { - return true - } - const provider = getSshGitProvider(connectionId) - if (!provider) { - return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) - } - try { - const worktrees = await provider.listWorktrees(repo.path) - return worktrees.some( - (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath - ) - } catch { - return false - } -} - -async function getRepoForSourceControlAi( - store: Store, - args: { repoId?: string; worktreePath: string; connectionId?: string } -): Promise { - if (!args.repoId) { - return null - } - const repo = store.getRepo(args.repoId) - if (!repo) { - return null - } - if (args.connectionId) { - if (repo.connectionId !== args.connectionId) { - return null - } - // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. - return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) - ? repo - : null - } - if (repo.connectionId) { - return null - } - // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. - return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null -} - -function getLocalAgentRuntimeTarget( - gitOptions: LocalProjectWorktreeGitOptions -): CommitMessageAgentRuntimeTarget { - return gitOptions.wslDistro - ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } - : { runtime: 'host' } -} - -async function resolveModelDiscoveryLocalPath( - store: Store, - requestedPath: string -): Promise { - try { - return await resolveRegisteredWorktreePath(requestedPath, store) - } catch (error) { - const folderWorkspaces = - typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] - const isFolderWorkspaceRoot = folderWorkspaces.some( - (workspace) => - comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) - ) - if (!isFolderWorkspaceRoot) { - throw error - } - return resolveAuthorizedPath(requestedPath, store) - } -} - -function getLocalTextGenerationTarget( - worktreePath: string, - gitOptions: LocalProjectWorktreeGitOptions, - env?: NodeJS.ProcessEnv -): Extract { - return { - kind: 'local', - cwd: worktreePath, - ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), - ...(env ? { env } : {}) - } -} - -function validateFullGitObjectId(value: string, label: string): string { - if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { - throw new Error(`${label} must be a full git object id`) - } - return value -} - -/** - * Check if a buffer appears to be binary (contains null bytes in first 8KB). - */ -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i++) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function isBinaryFilePrefix(filePath: string): Promise { - const handle = await open(filePath, 'r') - try { - const probe = Buffer.alloc(BINARY_PROBE_BYTES) - const { bytesRead } = await handle.read(probe, 0, probe.length, 0) - return isBinaryBuffer(probe.subarray(0, bytesRead)) - } finally { - await handle.close() - } -} - -function isDirectoryEntry(entry: { isDirectory(): boolean; isSymbolicLink(): boolean }): boolean { - // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} + createFilesystemHandlerContext, + type FilesystemHandlerContext +} from './filesystem/filesystem-handler-context' +import { registerFilesystemReadHandlers } from './filesystem/filesystem-read-handlers' +import { registerFilesystemDownloadHandlers } from './filesystem/filesystem-download-handlers' +import { registerFilesystemWriteHandlers } from './filesystem/filesystem-write-handlers' +import { registerFilesystemSearchHandlers } from './filesystem/filesystem-search-handlers' +import { registerFilesystemGitStatusHandlers } from './filesystem/filesystem-git-status-handlers' +import { registerFilesystemGitCommitHandlers } from './filesystem/filesystem-git-commit-handlers' +import { registerFilesystemGitCommitGenerationHandlers } from './filesystem/filesystem-git-commit-generation-handlers' +import { registerFilesystemGitModelDiscoveryHandlers } from './filesystem/filesystem-git-model-discovery-handlers' +import { registerFilesystemGitPullRequestGenerationHandlers } from './filesystem/filesystem-git-pull-request-generation-handlers' +import { registerFilesystemGitRemoteHandlers } from './filesystem/filesystem-git-remote-handlers' +import { registerFilesystemGitDiffHandlers } from './filesystem/filesystem-git-diff-handlers' +import { registerFilesystemGitIndexHandlers } from './filesystem/filesystem-git-index-handlers' +import { registerFilesystemGitUrlHandlers } from './filesystem/filesystem-git-url-handlers' export function registerFilesystemHandlers( store: Store, commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers ): void { - const activeTextSearches = new Map() - const downloadSessions = new Map() - - async function closeDownloadSession( - transferId: string, - cleanupTemp: boolean - ): Promise { - const session = downloadSessions.get(transferId) - if (!session) { - return null - } - downloadSessions.delete(transferId) - clearTimeout(session.cleanupTimer) - await session.handle.close().catch(() => {}) - if (cleanupTemp) { - await cleanupLocalTransferPath(session.tempPath) - } - return session - } - - function cleanupDownloadSessionsForSender(senderId: number): void { - for (const [transferId, session] of Array.from(downloadSessions)) { - if (session.senderId === senderId) { - void closeDownloadSession(transferId, true) - } - } - } - - // ─── Filesystem ───────────────────────────────────────── - ipcMain.handle( - 'fs:readDir', - async (_event, args: { dirPath: string; connectionId?: string }): Promise => { - // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. - let throwSite: ReadDirThrowSite = 'authorize' - try { - if (args.connectionId) { - throwSite = 'ssh-provider' - const provider = requireSshFilesystemProvider(args.connectionId) - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(args.dirPath)) - } - throwSite = 'authorize' - const dirPath = await resolveAuthorizedPath(args.dirPath, store) - throwSite = 'readdir' - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } catch (error: unknown) { - recordCrashBreadcrumb( - 'fs_readdir_error', - buildReadDirErrorBreadcrumb({ - dirPath: args.dirPath, - connectionId: args.connectionId, - throwSite, - error - }) - ) - throw error - } - } - ) - - ipcMain.handle( - 'fs:readFile', - async ( - _event, - args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } - ): Promise<{ - content: string - isBinary: boolean - isImage?: boolean - mimeType?: string - fileIdentity?: string - }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.readFile(args.filePath) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - if (args.includeLocalLogMetadata === true) { - return readLocalLogSnapshot(filePath) - } - const stats = await stat(filePath) - const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE - if (stats.size > sizeLimit) { - throw new Error( - `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` - ) - } - - if (mimeType) { - const buffer = await readFile(filePath) - return { - content: buffer.toString('base64'), - isBinary: true, - // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. - isImage: true, - mimeType - } - } - - // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. - if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { - return { content: '', isBinary: true } - } - - const buffer = await readFile(filePath) - if (isBinaryBuffer(buffer)) { - return { content: '', isBinary: true } - } - - return { content: buffer.toString('utf-8'), isBinary: false } - } - ) - - ipcMain.handle( - 'fs:downloadFile', - async ( - event, - args: { filePath?: string; connectionId?: string } - ): Promise => { - const filePath = validateRequiredString(args?.filePath, 'filePath') - const connectionId = validateRequiredString(args?.connectionId, 'connectionId') - const provider = requireSshFilesystemProvider(connectionId) - const remoteStat = await provider.stat(filePath) - if (remoteStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - if (!provider.downloadFile) { - throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') - } - - const remoteBasename = getRuntimePathBasename(filePath) - const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath }) - : await dialog.showSaveDialog({ defaultPath }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await provider.downloadFile(filePath, tempPath) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - registerFilesystemDownloadFolderHandlers() - - ipcMain.handle( - 'fs:saveDownloadedFile', - async ( - event, - args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } - ): Promise => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - if (typeof args?.content !== 'string') { - throw new Error('content is required') - } - const content = args.content - const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - let promoted = false - try { - await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) - await promoteDownloadedFile(tempPath, destinationPath, existed) - promoted = true - return { canceled: false, destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:startDownloadedFile', - async ( - event, - args: { suggestedName?: string } - ): Promise< - | { canceled: true } - | { - canceled: false - transferId: string - destinationPath: string - } - > => { - const suggestedName = sanitizeLocalDownloadFilename( - validateRequiredString(args?.suggestedName, 'suggestedName') - ) - const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined - const dialogResult = parentWindow - ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) - : await dialog.showSaveDialog({ defaultPath: suggestedName }) - if (dialogResult.canceled || !dialogResult.filePath) { - return { canceled: true } - } - - const destinationPath = dialogResult.filePath - const { existed } = await inspectDownloadDestination(destinationPath) - const tempPath = createSiblingTransferPath(destinationPath, 'download') - const transferId = randomUUID() - try { - const handle = await open(tempPath, 'wx') - const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN - const cleanupTimer = setTimeout(() => { - void closeDownloadSession(transferId, true) - }, DOWNLOAD_SESSION_TTL_MS) - if (typeof cleanupTimer.unref === 'function') { - cleanupTimer.unref() - } - downloadSessions.set(transferId, { - destinationPath, - tempPath, - destinationExisted: existed, - handle, - cleanupTimer, - senderId - }) - event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) - return { canceled: false, transferId, destinationPath } - } catch (error) { - await cleanupLocalTransferPath(tempPath) - throw error - } - } - ) - - ipcMain.handle( - 'fs:appendDownloadedFileChunk', - async ( - _event, - args: { transferId?: string; contentBase64?: string } - ): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') - const session = downloadSessions.get(transferId) - if (!session) { - throw new Error('Download session not found') - } - await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:finishDownloadedFile', - async ( - _event, - args: { transferId?: string } - ): Promise<{ canceled: false; destinationPath: string }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - const session = await closeDownloadSession(transferId, false) - if (!session) { - throw new Error('Download session not found') - } - let promoted = false - try { - await promoteDownloadedFile( - session.tempPath, - session.destinationPath, - session.destinationExisted - ) - promoted = true - return { canceled: false, destinationPath: session.destinationPath } - } finally { - if (!promoted) { - await cleanupLocalTransferPath(session.tempPath) - } - } - } - ) - - ipcMain.handle( - 'fs:cancelDownloadedFile', - async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { - const transferId = validateRequiredString(args?.transferId, 'transferId') - await closeDownloadSession(transferId, true) - return { ok: true } - } - ) - - ipcMain.handle( - 'fs:listMarkdownDocuments', - async ( - _event, - args: { rootPath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const relativePaths = await provider.listFiles(args.rootPath) - return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) - } - - const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) - return listMarkdownDocuments(rootPath) - } - ) - - ipcMain.handle( - 'fs:writeFile', - async ( - _event, - args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.writeFile(args.filePath, args.content) - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - - await writeFile(filePath, args.content, 'utf-8') - } - ) - - ipcMain.handle( - 'fs:deletePath', - async ( - _event, - args: { - targetPath: string - connectionId?: string - recursive?: boolean - } & SshMutationExpectation - ): Promise => { - assertSshMutationExpectation( - args.connectionId, - args.expectedSshTargetId, - args.expectedSshConnectionGeneration, - args.expectedExecutionHostId - ) - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.deletePath(args.targetPath, args.recursive) - } - // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). - const targetPath = await resolveAuthorizedPath(args.targetPath, store, { - preserveSymlink: true - }) - - // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). - if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { - return - } - - // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). - try { - await shell.trashItem(targetPath) - } catch (error) { - if (isENOENT(error)) { - return - } - throw error - } - } - ) - - registerFilesystemMutationHandlers(store) - - ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { - authorizeExternalPath(args.targetPath) - }) - - ipcMain.handle( - 'fs:stat', - async ( - _event, - args: { filePath: string; connectionId?: string } - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - const s = await provider.stat(args.filePath) - return { size: s.size, isDirectory: s.type === 'directory', mtime: s.mtime } - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - const stats = await stat(filePath) - return { - size: stats.size, - isDirectory: stats.isDirectory(), - mtime: stats.mtimeMs - } - } - ) - - ipcMain.handle( - 'fs:pathExists', - async (_event, args: { filePath: string; connectionId?: string }): Promise => { - try { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - await provider.stat(args.filePath) - return true - } - const filePath = await resolveAuthorizedPath(args.filePath, store) - await stat(filePath) - return true - } catch (error) { - if (isENOENT(error)) { - return false - } - throw error - } - } - ) - - // ─── Search ──────────────────────────────────────────── - ipcMain.handle( - 'fs:search', - async (event, args: SearchOptions & { connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = requireSshFilesystemProvider(args.connectionId) - return provider.search(args) - } - const rootPath = await resolveAuthorizedPath(args.rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.rootPath, - rootPath - ) - const maxResults = Math.max( - 1, - Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const searchKey = `${event.sender.id}:${rootPath}` - // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - - if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { - return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const rgArgs = buildRgArgs(args.query, rootPath, args) - - // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - const previousChild = activeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - let killTimeout: ReturnType - - const transformAbsPath = wslDistroForOutput - ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (activeTextSearches.get(searchKey) === child) { - activeTextSearches.delete(searchKey) - } - clearTimeout(killTimeout) - // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. - child?.stdout?.off('data', handleStdoutData) - child?.stderr?.off('data', handleStderrData) - child?.off('error', handleError) - child?.off('close', handleClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: rootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - activeTextSearches.set(searchKey, nextChild) - - const handleStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const handleStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const handleError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !wslDistroForOutput - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.setEncoding('utf-8') - nextChild.stdout!.on('data', handleStdoutData) - nextChild.stderr!.on('data', handleStderrData) - nextChild.once('error', handleError) - nextChild.once('close', handleClose) - - // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - ) - - // ─── List all files (for quick-open) ───────────────────── - // Why #7721: token-keyed so a workspace switch aborts the prior full-tree scan (SSH otherwise stacks scans past the 30s timeout). - const listFilesCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'fs:listFiles', - async ( - event, - args: { - rootPath: string - connectionId?: string - excludePaths?: string[] - requestToken?: string - maxResults?: number - searchQuery?: string - } - ): Promise => { - const controller = listFilesCancellations.begin(event, args.requestToken) - try { - if (args.connectionId) { - const provider = getSshFilesystemProvider(args.connectionId) - // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. - if (!provider) { - return [] - } - // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. - if ( - args.searchQuery !== undefined && - provider.supportsQuickOpenSearch && - !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) - ) { - const legacyFiles = await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, - signal: controller?.signal - }) - const ranker = new QuickOpenPathRanker( - args.searchQuery, - args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT - ) - for (const file of legacyFiles) { - ranker.consider(file) - } - return ranker.result().paths - } - return await provider.listFiles(args.rootPath, { - excludePaths: args.excludePaths, - ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), - ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), - signal: controller?.signal - }) - } - return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) - } finally { - listFilesCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { - listFilesCancellations.cancel(event, args.requestToken) - }) - - // ─── Git operations ───────────────────────────────────── - const gitStatusCancellations = createSenderScopedRequestCancellations() - ipcMain.handle( - 'git:status', - async ( - event, - args: { - worktreePath: string - connectionId?: string - admissionTier?: GitAdmissionTier - includeIgnored?: boolean - includeLineStats?: boolean - bypassEffectiveUpstreamNegativeCache?: boolean - reuseLineStats?: boolean - branchLineTotalMergeBase?: string - requestToken?: string - } - ): Promise => { - const controller = gitStatusCancellations.begin(event, args.requestToken) - const options = { - includeIgnored: args.includeIgnored ?? false, - admissionTier: args.admissionTier ?? ('status' as const), - ...(args.includeLineStats === false ? { includeLineStats: false } : {}), - ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), - ...(args.branchLineTotalMergeBase === undefined - ? {} - : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), - ...(args.bypassEffectiveUpstreamNegativeCache === true - ? { bypassEffectiveUpstreamNegativeCache: true } - : {}), - ...(controller ? { signal: controller.signal } : {}) - } - try { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). - return await provider.getStatus(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - // Why: one registered-worktree lookup feeds both — status polls this - // handler, and the scan walks every repo's worktree meta. - const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) - const gitOptions = getLocalGitOptionsForRepo(store, repo) - const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] - return await getStatus(worktreePath, { - ...options, - ...gitOptions, - ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) - }) - } finally { - gitStatusCancellations.finish(event, args.requestToken, controller) - } - } - ) - - ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { - gitStatusCancellations.cancel(event, args.requestToken) - }) - - ipcMain.handle( - 'git:setStatusUpstreamRefWatch', - (_event, args: GitStatusUpstreamRefWatchRequest): Promise => - applyGitStatusUpstreamRefWatchRequest(store, args) - ) - - // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. - ipcMain.handle( - 'git:submoduleStatus', - async ( - _event, - args: { - worktreePath: string - submodulePath: string - connectionId?: string - area?: GitStagingArea - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getSubmoduleStatus(worktreePath, args.submodulePath, { - ...gitOptions, - ...(args.area === 'staged' ? { staged: true } : {}) - }) - } - ) - - ipcMain.handle( - 'git:checkIgnored', - async ( - _event, - args: { worktreePath: string; paths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.checkIgnoredPaths(args.worktreePath, paths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return checkIgnoredPaths(worktreePath, paths, gitOptions) - } - ) - - // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. - ipcMain.handle( - 'git:findHugeFoldersToIgnore', - async (_event, args: { worktreePath: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) - } - ) - - ipcMain.handle( - 'git:appendGitignore', - async (_event, args: { worktreePath: string; folderName: string }): Promise => { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return appendFolderToGitignore(worktreePath, args.folderName) - } - ) - - ipcMain.handle( - 'git:history', - async ( - _event, - args: { worktreePath: string; connectionId?: string } & GitHistoryOptions - ): Promise => { - const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getHistory(args.worktreePath, options) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getHistory(worktreePath, { ...options, ...gitOptions }) - } - ) - - // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. - ipcMain.handle( - 'git:conflictOperation', - async ( - _event, - args: { worktreePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.detectConflictOperation(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - return detectConflictOperation(worktreePath) - } - ) - - ipcMain.handle( - 'git:abortMerge', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortMerge(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:abortRebase', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${args.connectionId}"`) - } - return provider.abortRebase(args.worktreePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:diff', - async ( - _event, - args: { - worktreePath: string - filePath: string - staged: boolean - compareAgainstHead?: boolean - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getDiff( - args.worktreePath, - args.filePath, - args.staged, - args.compareAgainstHead - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:commit', - async ( - _event, - args: { worktreePath: string; message: string; connectionId?: string } - ): Promise<{ success: boolean; error?: string }> => { - // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. - if (typeof args.message !== 'string' || args.message.trim().length === 0) { - throw new Error('Commit message is required') - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.commit(args.worktreePath, args.message) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return commitChanges(worktreePath, args.message, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:generateCommitMessage', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'commitMessage', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - let context - try { - context = await provider.getStagedCommitContext(args.worktreePath) - } catch (error) { - console.error('[filesystem] Failed to read remote staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args) - ) - return generateCommitMessageFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - let context - try { - context = await getStagedCommitContext(worktreePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } catch (error) { - console.error('[filesystem] Failed to read staged commit context:', error) - return { - success: false, - error: 'Failed to read staged changes.' - } - } - if (!context) { - return { success: false, error: 'No staged changes to summarize.' } - } - context = withLinkedIssueDraftContext( - context, - resolveSourceControlAiLinkedIssue(store, args, worktreePath) - ) - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generateCommitMessageFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGenerateCommitMessage', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGenerateCommitMessageLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:discoverCommitMessageModels', - async ( - _event, - args: { agentId: string; worktreePath?: string; connectionId?: string } - ): Promise => { - const agentId = args.agentId - const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] - if (args.connectionId) { - if (!args.worktreePath) { - return { success: false, error: 'Missing worktree path for remote model discovery.' } - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: `No git provider for connection "${args.connectionId}"` - } - } - return discoverCommitMessageModelsRemote( - agentId as TuiAgent, - args.worktreePath, - (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), - agentCommandOverride - ) - } - let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } - let localDiscoveryOptions: Parameters[3] - if (args.worktreePath) { - const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro - localRuntimeTarget = wslDistro - ? { runtime: 'wsl', wslDistro } - : getLocalAgentRuntimeTarget(gitOptions) - localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - agentId, - commitMessageAgentEnv, - localRuntimeTarget - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return localDiscoveryOptions - ? discoverCommitMessageModelsLocal( - agentId as TuiAgent, - localEnv.env, - agentCommandOverride, - localDiscoveryOptions - ) - : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) - } - ) - - ipcMain.handle( - 'git:generatePullRequestFields', - async ( - _event, - args: { - worktreePath: string - // Raw (unstripped) meta key; validated against worktreePath before any meta read. - worktreeId?: string - repoId?: string - base: string - title: string - body: string - draft: boolean - provider?: HostedReviewProvider - useTemplate?: boolean - connectionId?: string - sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams - sourceControlAi?: GlobalSettings['sourceControlAi'] - agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] - } - ): Promise => { - const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) - const baseSettings = store.getSettings() - const requestSettings = { - ...baseSettings, - ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), - ...(args.agentCmdOverrides !== undefined - ? { agentCmdOverrides: args.agentCmdOverrides } - : {}) - } - const resolvedSettings = args.sourceControlAiResolvedParams - ? { ok: true as const, params: args.sourceControlAiResolvedParams } - : resolveCommitMessageSettings( - requestSettings, - discoveryHostKey, - 'pullRequest', - await getRepoForSourceControlAi(store, args) - ) - if (!resolvedSettings.ok) { - return { success: false, error: resolvedSettings.error } - } - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return { - success: false, - error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE - } - } - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: args.worktreePath, - connectionId: args.connectionId - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: args.worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv) => provider.exec(argv, args.worktreePath), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: - error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { - kind: 'remote', - cwd: args.worktreePath, - execute: (plan, cwd, timeoutMs, operation) => - provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), - missingBinaryLocation: 'remote PATH' - }) - } - - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) - const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ - meta: issueMeta, - provider: args.provider, - repoPath: worktreePath, - connectionId: args.connectionId, - localGitOptions: gitOptions - }) - let context: Awaited> - try { - const currentBody = await resolveHostedReviewBodyForGeneration({ - body: args.body, - repoPath: worktreePath, - connectionId: args.connectionId, - provider: args.provider, - useTemplate: args.useTemplate - }) - context = await getPullRequestDraftContext( - (argv, options) => - gitExecFileAsync(argv, { cwd: worktreePath, ...gitOptions, ...options }), - { - base: args.base, - currentTitle: args.title, - currentBody, - currentDraft: args.draft - } - ) - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' - } - } - if (!context) { - return { success: false, error: 'No branch changes to summarize.' } - } - const linkedIssueDetails = await linkedIssueDetailsPromise - context = { - ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), - ...(args.provider ? { provider: args.provider } : {}), - ...(linkedIssueDetails ? { linkedIssueDetails } : {}) - } - const localEnv = await prepareLocalCommitMessageAgentEnv( - resolvedSettings.params.agentId, - commitMessageAgentEnv, - getLocalAgentRuntimeTarget(gitOptions) - ) - if (!localEnv.ok) { - return { success: false, error: localEnv.error } - } - return generatePullRequestFieldsFromContext( - context, - resolvedSettings.params, - getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) - ) - } - ) - - ipcMain.handle( - 'git:cancelGeneratePullRequestFields', - async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - return - } - await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') - return - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - cancelGeneratePullRequestFieldsLocal(worktreePath) - } - ) - - ipcMain.handle( - 'git:branchCompare', - async ( - _event, - args: { - worktreePath: string - baseRef: string - connectionId?: string - admissionTier?: GitAdmissionTier - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return args.admissionTier - ? provider.getBranchCompare(args.worktreePath, args.baseRef, { - admissionTier: args.admissionTier - }) - : provider.getBranchCompare(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchCompare(worktreePath, args.baseRef, { - ...gitOptions, - ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) - }) - } - ) - - ipcMain.handle( - 'git:commitCompare', - async ( - _event, - args: { worktreePath: string; commitId: string; connectionId?: string } - ): Promise => { - const commitId = validateFullGitObjectId(args.commitId, 'commitId') - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitCompare(args.worktreePath, commitId) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitCompare(worktreePath, commitId, gitOptions) - } - ) - - ipcMain.handle( - 'git:upstreamStatus', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) - } - ) - - ipcMain.handle( - 'git:fetch', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fetchRemote(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFetch(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:syncFork', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - expectedUpstream: GitForkSyncExpectedUpstream - } - ): Promise => { - const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { - required: true - }) - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:push', - async ( - _event, - args: { - worktreePath: string - publish?: boolean - forceWithLease?: boolean - connectionId?: string - pushTarget?: GitPushTarget - } - ): Promise => { - // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. - const publish = args.publish === true - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPush(worktreePath, publish, args.pushTarget, { - forceWithLease: args.forceWithLease === true, - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:pull', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.pullBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitPull(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:fastForward', - async ( - _event, - args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } - ): Promise => { - if (args.connectionId) { - if (args.pushTarget) { - assertGitPushTargetShape(args.pushTarget) - } - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.fastForwardBranch(args.worktreePath, args.pushTarget) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - if (args.pushTarget) { - await validateGitPushTarget(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - await gitFastForward(worktreePath, args.pushTarget, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:rebaseFromBase', - async ( - _event, - args: { worktreePath: string; baseRef: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.rebaseFromBase(args.worktreePath, args.baseRef) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await gitPullRebaseFromBase(worktreePath, args.baseRef, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:branchDiff', - async ( - _event, - args: { - worktreePath: string - compare: { - baseRef: string - baseOid: string - headOid: string - mergeBase: string - } - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { - includePatch: true, - headOid: args.compare.headOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - return ( - results[0] ?? { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } - ) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getBranchDiff( - worktreePath, - { - mergeBase: args.compare.mergeBase, - headOid: args.compare.headOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:commitDiff', - async ( - _event, - args: { - worktreePath: string - commitOid: string - parentOid?: string | null - filePath: string - oldPath?: string - connectionId?: string - } - ): Promise => { - const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') - const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getCommitDiff(args.worktreePath, { - commitOid, - parentOid, - filePath: args.filePath, - oldPath: args.oldPath - }) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const oldPath = args.oldPath - ? validateGitRelativeFilePath(worktreePath, args.oldPath) - : undefined - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - return getCommitDiff( - worktreePath, - { - commitOid, - parentOid, - filePath, - oldPath - }, - { ...gitOptions, admissionTier: 'interactive' } - ) - } - ) - - ipcMain.handle( - 'git:stage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.stageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:unstage', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.unstageFile(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) - } - ) - - ipcMain.handle( - 'git:discard', - async ( - _event, - args: { worktreePath: string; filePath: string; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.discardChanges(args.worktreePath, args.filePath) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await discardChanges(worktreePath, filePath, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkDiscard', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkDiscardChanges(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkStage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkStageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkStageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:bulkUnstage', - async ( - _event, - args: { worktreePath: string; filePaths: string[]; connectionId?: string } - ): Promise => { - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) - const gitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - args.worktreePath, - worktreePath - ) - await bulkUnstageFiles(worktreePath, filePaths, { - ...gitOptions, - admissionTier: 'interactive' - }) - } - ) - - ipcMain.handle( - 'git:remoteFileUrl', - async ( - _event, - args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } - ): Promise => { - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteFileUrl(worktreePath, args.relativePath, args.line) - } - ) - - ipcMain.handle( - 'git:remoteCommitUrl', - async ( - _event, - args: { worktreePath: string; sha: string; connectionId?: string } - ): Promise => { - const sha = validateFullGitObjectId(args.sha, 'sha') - // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. - if (args.connectionId) { - const provider = getSshGitProvider(args.connectionId) - if (!provider) { - throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.getRemoteCommitUrl(args.worktreePath, sha) - } - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) - await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) - return getRemoteCommitUrl(worktreePath, sha) - } + const context: FilesystemHandlerContext = createFilesystemHandlerContext( + store, + commitMessageAgentEnv, + createSenderScopedRequestCancellations(), + createSenderScopedRequestCancellations() ) + registerFilesystemReadHandlers(context) + registerFilesystemDownloadHandlers(context) + registerFilesystemWriteHandlers(context) + registerFilesystemSearchHandlers(context) + registerFilesystemGitStatusHandlers(context) + registerFilesystemGitCommitHandlers(context) + registerFilesystemGitCommitGenerationHandlers(context) + registerFilesystemGitModelDiscoveryHandlers(context) + registerFilesystemGitPullRequestGenerationHandlers(context) + registerFilesystemGitRemoteHandlers(context) + registerFilesystemGitDiffHandlers(context) + registerFilesystemGitIndexHandlers(context) + registerFilesystemGitUrlHandlers(context) registerLocalLogTailHandlers(store) } diff --git a/src/main/ipc/filesystem/filesystem-download-handlers.ts b/src/main/ipc/filesystem/filesystem-download-handlers.ts new file mode 100644 index 00000000000..51a90c36213 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-download-handlers.ts @@ -0,0 +1,210 @@ +import { BrowserWindow, dialog, ipcMain } from 'electron' +import { randomUUID } from 'node:crypto' +import { open, writeFile } from 'node:fs/promises' +import { getRuntimePathBasename } from '../../../shared/cross-platform-path' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { sanitizeLocalDownloadFilename } from '../../local-download-filename' +import { registerFilesystemDownloadFolderHandlers } from '../filesystem-download-folder' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + cleanupLocalTransferPath, + decodeDownloadedFileContent, + DOWNLOAD_SESSION_TTL_MS, + inspectDownloadDestination, + promoteDownloadedFile, + validateRequiredString, + createSiblingTransferPath, + type DownloadFileResult +} from './filesystem-file-helpers' + +export function registerFilesystemDownloadHandlers(context: FilesystemHandlerContext): void { + const { downloadSessions, closeDownloadSession, cleanupDownloadSessionsForSender } = context + + ipcMain.handle( + 'fs:downloadFile', + async ( + event, + args: { filePath?: string; connectionId?: string } + ): Promise => { + const filePath = validateRequiredString(args?.filePath, 'filePath') + const connectionId = validateRequiredString(args?.connectionId, 'connectionId') + const provider = requireSshFilesystemProvider(connectionId) + const remoteStat = await provider.stat(filePath) + if (remoteStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + if (!provider.downloadFile) { + throw new Error('Remote file download is unavailable. Reconnect the SSH target and retry.') + } + + const remoteBasename = getRuntimePathBasename(filePath) + const defaultPath = sanitizeLocalDownloadFilename(remoteBasename) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath }) + : await dialog.showSaveDialog({ defaultPath }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await provider.downloadFile(filePath, tempPath) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + registerFilesystemDownloadFolderHandlers() + + ipcMain.handle( + 'fs:saveDownloadedFile', + async ( + event, + args: { suggestedName?: string; content?: string; encoding?: 'utf8' | 'base64' } + ): Promise => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + if (typeof args?.content !== 'string') { + throw new Error('content is required') + } + const content = args.content + const encoding = args?.encoding === 'base64' ? 'base64' : 'utf8' + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + let promoted = false + try { + await writeFile(tempPath, decodeDownloadedFileContent(content, encoding)) + await promoteDownloadedFile(tempPath, destinationPath, existed) + promoted = true + return { canceled: false, destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:startDownloadedFile', + async ( + event, + args: { suggestedName?: string } + ): Promise< + { canceled: true } | { canceled: false; transferId: string; destinationPath: string } + > => { + const suggestedName = sanitizeLocalDownloadFilename( + validateRequiredString(args?.suggestedName, 'suggestedName') + ) + const parentWindow = BrowserWindow.fromWebContents(event.sender) ?? undefined + const dialogResult = parentWindow + ? await dialog.showSaveDialog(parentWindow, { defaultPath: suggestedName }) + : await dialog.showSaveDialog({ defaultPath: suggestedName }) + if (dialogResult.canceled || !dialogResult.filePath) { + return { canceled: true } + } + + const destinationPath = dialogResult.filePath + const { existed } = await inspectDownloadDestination(destinationPath) + const tempPath = createSiblingTransferPath(destinationPath, 'download') + const transferId = randomUUID() + try { + const handle = await open(tempPath, 'wx') + const senderId = typeof event.sender.id === 'number' ? event.sender.id : Number.NaN + const cleanupTimer = setTimeout(() => { + void closeDownloadSession(transferId, true) + }, DOWNLOAD_SESSION_TTL_MS) + if (typeof cleanupTimer.unref === 'function') { + cleanupTimer.unref() + } + downloadSessions.set(transferId, { + destinationPath, + tempPath, + destinationExisted: existed, + handle, + cleanupTimer, + senderId + }) + event.sender.once?.('destroyed', () => cleanupDownloadSessionsForSender(senderId)) + return { canceled: false, transferId, destinationPath } + } catch (error) { + await cleanupLocalTransferPath(tempPath) + throw error + } + } + ) + + ipcMain.handle( + 'fs:appendDownloadedFileChunk', + async ( + _event, + args: { transferId?: string; contentBase64?: string } + ): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const contentBase64 = validateRequiredString(args?.contentBase64, 'contentBase64') + const session = downloadSessions.get(transferId) + if (!session) { + throw new Error('Download session not found') + } + await session.handle.writeFile(Buffer.from(contentBase64, 'base64')) + return { ok: true } + } + ) + + ipcMain.handle( + 'fs:finishDownloadedFile', + async ( + _event, + args: { transferId?: string } + ): Promise<{ canceled: false; destinationPath: string }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + const session = await closeDownloadSession(transferId, false) + if (!session) { + throw new Error('Download session not found') + } + let promoted = false + try { + await promoteDownloadedFile( + session.tempPath, + session.destinationPath, + session.destinationExisted + ) + promoted = true + return { canceled: false, destinationPath: session.destinationPath } + } finally { + if (!promoted) { + await cleanupLocalTransferPath(session.tempPath) + } + } + } + ) + + ipcMain.handle( + 'fs:cancelDownloadedFile', + async (_event, args: { transferId?: string }): Promise<{ ok: true }> => { + const transferId = validateRequiredString(args?.transferId, 'transferId') + await closeDownloadSession(transferId, true) + return { ok: true } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-file-helpers.ts b/src/main/ipc/filesystem/filesystem-file-helpers.ts new file mode 100644 index 00000000000..53e2eb3ec30 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-file-helpers.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto' +import { open, rename, rm, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader' +import { isENOENT } from '../filesystem-path-containment' + +// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files. +export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB +export const BINARY_PROBE_BYTES = 8192 +export const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ +// 32 visible matches plus one truncation sentinel stays below the legacy frame ceiling. +export const QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT = 33 +// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe. +export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB +export const PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export async function readLocalLogSnapshot(filePath: string): Promise<{ + content: string + isBinary: boolean + fileIdentity?: string +}> { + const handle = await open(filePath, 'r') + try { + const stats = await handle.stat() + if (stats.size > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + const buffer = await handle.readFile() + if (buffer.byteLength > MAX_TEXT_FILE_SIZE) { + throw new Error( + `File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit` + ) + } + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { + content: buffer.toString('utf8'), + isBinary: false, + fileIdentity: localLogFileIdentity(stats) + } + } finally { + await handle.close() + } +} + +export function validateRequiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${label} is required`) + } + return value +} + +export function decodeDownloadedFileContent(content: string, encoding: 'utf8' | 'base64'): Buffer { + return encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') +} + +export function createSiblingTransferPath(destinationPath: string, suffix: string): string { + // Why: promotion renames must stay on the destination volume, so transfer paths remain siblings. + return join(dirname(destinationPath), `.${randomUUID()}.${suffix}`) +} + +export async function cleanupLocalTransferPath(filePath: string | null): Promise { + if (!filePath) { + return + } + await rm(filePath, { force: true }).catch(() => {}) +} + +export async function inspectDownloadDestination( + destinationPath: string +): Promise<{ existed: boolean }> { + try { + const destinationStat = await stat(destinationPath) + if (destinationStat.isDirectory()) { + throw new Error('Cannot download to a directory') + } + return { existed: true } + } catch (error) { + if (isENOENT(error)) { + return { existed: false } + } + throw error + } +} + +export async function assertDestinationStillUnclaimed(destinationPath: string): Promise { + try { + await stat(destinationPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + throw new Error('Destination file appeared before download completed') +} + +export async function promoteDownloadedFile( + tempPath: string, + destinationPath: string, + destinationExisted: boolean +): Promise { + if (!destinationExisted) { + await assertDestinationStillUnclaimed(destinationPath) + await rename(tempPath, destinationPath) + return + } + + const backupPath = createSiblingTransferPath(destinationPath, 'backup') + let backupCreated = false + try { + await rename(destinationPath, backupPath) + backupCreated = true + await rename(tempPath, destinationPath) + await cleanupLocalTransferPath(backupPath) + } catch (error) { + if (backupCreated) { + await rename(backupPath, destinationPath).catch(() => {}) + } + throw error + } +} + +/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */ +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, BINARY_PROBE_BYTES) + for (let i = 0; i < len; i++) { + if (buffer[i] === 0) { + return true + } + } + return false +} + +export async function isBinaryFilePrefix(filePath: string): Promise { + const handle: FileHandle = await open(filePath, 'r') + try { + const probe = Buffer.alloc(BINARY_PROBE_BYTES) + const { bytesRead } = await handle.read(probe, 0, probe.length, 0) + return isBinaryBuffer(probe.subarray(0, bytesRead)) + } finally { + await handle.close() + } +} + +export function isDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: following a symlink in readDir can touch macOS TCC-protected containers; treat links as file-like until explicitly opened. + if (entry.isSymbolicLink()) { + return false + } + return entry.isDirectory() +} + +export type DownloadFileResult = { canceled: true } | { canceled: false; destinationPath: string } + +export const DOWNLOAD_SESSION_TTL_MS = 30 * 60 * 1000 diff --git a/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts new file mode 100644 index 00000000000..4e55a39f76a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-generation-handlers.ts @@ -0,0 +1,157 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import { + cancelGenerateCommitMessageLocal, + generateCommitMessageFromContext, + resolveCommitMessageSettings, + type GenerateCommitMessageResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getStagedCommitContext } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssue } from '../source-control-ai-linked-issue' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitCommitGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generateCommitMessage', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'commitMessage', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + let context + try { + context = await provider.getStagedCommitContext(args.worktreePath) + } catch (error) { + console.error('[filesystem] Failed to read remote staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) + return generateCommitMessageFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + let context + try { + context = await getStagedCommitContext(worktreePath, { + ...gitOptions, + admissionTier: 'interactive' + }) + } catch (error) { + console.error('[filesystem] Failed to read staged commit context:', error) + return { + success: false, + error: 'Failed to read staged changes.' + } + } + if (!context) { + return { success: false, error: 'No staged changes to summarize.' } + } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generateCommitMessageFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGenerateCommitMessage', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'commit-message') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGenerateCommitMessageLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts new file mode 100644 index 00000000000..8040a766c2a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-commit-handlers.ts @@ -0,0 +1,42 @@ +import { ipcMain } from 'electron' +import { commitChanges } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitCommitHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:commit', + async ( + _event, + args: { worktreePath: string; message: string; connectionId?: string } + ): Promise<{ success: boolean; error?: string }> => { + // Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure. + if (typeof args.message !== 'string' || args.message.trim().length === 0) { + throw new Error('Commit message is required') + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.commit(args.worktreePath, args.message) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return commitChanges(worktreePath, args.message, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts new file mode 100644 index 00000000000..ff706fcb078 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-diff-handlers.ts @@ -0,0 +1,126 @@ +import { ipcMain } from 'electron' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import { getBranchDiff, getCommitDiff } from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitDiffHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:branchDiff', + async ( + _event, + args: { + worktreePath: string + compare: { + baseRef: string + baseOid: string + headOid: string + mergeBase: string + } + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + const results = await provider.getBranchDiff(args.worktreePath, args.compare.mergeBase, { + includePatch: true, + headOid: args.compare.headOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + return ( + results[0] ?? { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchDiff( + worktreePath, + { + mergeBase: args.compare.mergeBase, + headOid: args.compare.headOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) + + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitDiff( + worktreePath, + { + commitOid, + parentOid, + filePath, + oldPath + }, + { ...gitOptions, admissionTier: 'interactive' } + ) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-index-handlers.ts b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts new file mode 100644 index 00000000000..fbd550c70e5 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-index-handlers.ts @@ -0,0 +1,173 @@ +import { ipcMain } from 'electron' +import { + stageFile, + unstageFile, + discardChanges, + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles +} from '../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitIndexHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:stage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.stageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await stageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:unstage', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.unstageFile(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await unstageFile(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:discard', + async ( + _event, + args: { worktreePath: string; filePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.discardChanges(args.worktreePath, args.filePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await discardChanges(worktreePath, filePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:bulkDiscard', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkDiscardChanges(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkDiscardChanges(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkStage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkStageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkStageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:bulkUnstage', + async ( + _event, + args: { worktreePath: string; filePaths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.bulkUnstageFiles(args.worktreePath, args.filePaths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePaths = args.filePaths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await bulkUnstageFiles(worktreePath, filePaths, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts new file mode 100644 index 00000000000..0933c08587e --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-model-discovery-handlers.ts @@ -0,0 +1,82 @@ +import { ipcMain } from 'electron' +import type { TuiAgent } from '../../../shared/tui-agent' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import { + discoverCommitMessageModelsLocal, + discoverCommitMessageModelsRemote, + type DiscoverCommitMessageModelsResult +} from '../../text-generation/commit-message-text-generation' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { parseWslPath } from '../../wsl' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { + resolveModelDiscoveryLocalPath, + getLocalAgentRuntimeTarget +} from './filesystem-worktree-helpers' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitModelDiscoveryHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:discoverCommitMessageModels', + async ( + _event, + args: { agentId: string; worktreePath?: string; connectionId?: string } + ): Promise => { + const agentId = args.agentId + const agentCommandOverride = store.getSettings().agentCmdOverrides?.[agentId as TuiAgent] + if (args.connectionId) { + if (!args.worktreePath) { + return { success: false, error: 'Missing worktree path for remote model discovery.' } + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: `No git provider for connection "${args.connectionId}"` + } + } + return discoverCommitMessageModelsRemote( + agentId as TuiAgent, + args.worktreePath, + (plan, cwd, timeoutMs) => provider.executeCommitMessagePlan(plan, cwd, timeoutMs), + agentCommandOverride + ) + } + let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } + let localDiscoveryOptions: Parameters[3] + if (args.worktreePath) { + const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro + localRuntimeTarget = wslDistro + ? { runtime: 'wsl', wslDistro } + : getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + agentId, + commitMessageAgentEnv, + localRuntimeTarget + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return localDiscoveryOptions + ? discoverCommitMessageModelsLocal( + agentId as TuiAgent, + localEnv.env, + agentCommandOverride, + localDiscoveryOptions + ) + : discoverCommitMessageModelsLocal(agentId as TuiAgent, localEnv.env, agentCommandOverride) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts new file mode 100644 index 00000000000..317169ba856 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-pull-request-generation-handlers.ts @@ -0,0 +1,214 @@ +import { ipcMain } from 'electron' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai' +import type { HostedReviewProvider } from '../../../shared/hosted-review' +import { + cancelGeneratePullRequestFieldsLocal, + generatePullRequestFieldsFromContext, + resolveCommitMessageSettings, + type GeneratePullRequestFieldsResult +} from '../../text-generation/commit-message-text-generation' +import { getCommitMessageModelDiscoveryHostKey } from '../../../shared/commit-message-host-key' +import { getPullRequestDraftContext } from '../../text-generation/pull-request-context' +import { prepareLocalCommitMessageAgentEnv } from '../../text-generation/commit-message-agent-environment' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { gitExecFileAsync } from '../../git/runner' +import { withLinkedIssueDraftContext } from '../../../shared/source-control-ai-action-variables' +import { resolveSourceControlAiLinkedIssueMeta } from '../source-control-ai-linked-issue' +import { resolveHostedReviewBodyForGeneration } from '../../source-control/pull-request-template' +import { loadPullRequestLinkedIssue } from '../../source-control/pull-request-linked-issue' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + getLocalAgentRuntimeTarget, + getLocalTextGenerationTarget, + getRepoForSourceControlAi +} from './filesystem-worktree-helpers' + +export function registerFilesystemGitPullRequestGenerationHandlers( + context: FilesystemHandlerContext +): void { + const { store, commitMessageAgentEnv } = context + ipcMain.handle( + 'git:generatePullRequestFields', + async ( + _event, + args: { + worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string + repoId?: string + base: string + title: string + body: string + draft: boolean + provider?: HostedReviewProvider + useTemplate?: boolean + connectionId?: string + sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams + sourceControlAi?: GlobalSettings['sourceControlAi'] + agentCmdOverrides?: GlobalSettings['agentCmdOverrides'] + } + ): Promise => { + const discoveryHostKey = getCommitMessageModelDiscoveryHostKey(args.connectionId ?? null) + const baseSettings = store.getSettings() + const requestSettings = { + ...baseSettings, + ...(args.sourceControlAi !== undefined ? { sourceControlAi: args.sourceControlAi } : {}), + ...(args.agentCmdOverrides !== undefined + ? { agentCmdOverrides: args.agentCmdOverrides } + : {}) + } + const resolvedSettings = args.sourceControlAiResolvedParams + ? { ok: true as const, params: args.sourceControlAiResolvedParams } + : resolveCommitMessageSettings( + requestSettings, + discoveryHostKey, + 'pullRequest', + await getRepoForSourceControlAi(store, args) + ) + if (!resolvedSettings.ok) { + return { success: false, error: resolvedSettings.error } + } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return { + success: false, + error: SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE + } + } + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: args.worktreePath, + connectionId: args.connectionId + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: args.worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv) => provider.exec(argv, args.worktreePath), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { + kind: 'remote', + cwd: args.worktreePath, + execute: (plan, cwd, timeoutMs, operation) => + provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation), + missingBinaryLocation: 'remote PATH' + }) + } + + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + const issueMeta = resolveSourceControlAiLinkedIssueMeta(store, args, worktreePath) + const linkedIssueDetailsPromise = loadPullRequestLinkedIssue({ + meta: issueMeta, + provider: args.provider, + repoPath: worktreePath, + connectionId: args.connectionId, + localGitOptions: gitOptions + }) + let context: Awaited> + try { + const currentBody = await resolveHostedReviewBodyForGeneration({ + body: args.body, + repoPath: worktreePath, + connectionId: args.connectionId, + provider: args.provider, + useTemplate: args.useTemplate + }) + context = await getPullRequestDraftContext( + (argv, options) => + gitExecFileAsync(argv, { cwd: worktreePath, ...gitOptions, ...options }), + { + base: args.base, + currentTitle: args.title, + currentBody, + currentDraft: args.draft + } + ) + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to prepare branch for PR details.' + } + } + if (!context) { + return { success: false, error: 'No branch changes to summarize.' } + } + const linkedIssueDetails = await linkedIssueDetailsPromise + context = { + ...withLinkedIssueDraftContext(context, issueMeta?.linkedIssue), + ...(args.provider ? { provider: args.provider } : {}), + ...(linkedIssueDetails ? { linkedIssueDetails } : {}) + } + const localEnv = await prepareLocalCommitMessageAgentEnv( + resolvedSettings.params.agentId, + commitMessageAgentEnv, + getLocalAgentRuntimeTarget(gitOptions) + ) + if (!localEnv.ok) { + return { success: false, error: localEnv.error } + } + return generatePullRequestFieldsFromContext( + context, + resolvedSettings.params, + getLocalTextGenerationTarget(worktreePath, gitOptions, localEnv.env) + ) + } + ) + + ipcMain.handle( + 'git:cancelGeneratePullRequestFields', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + return + } + await provider.cancelGenerateCommitMessage(args.worktreePath, 'pull-request-fields') + return + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + cancelGeneratePullRequestFieldsLocal(worktreePath) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts new file mode 100644 index 00000000000..9450094109a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-remote-handlers.ts @@ -0,0 +1,10 @@ +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { registerGitRemoteBranchMutationHandlers } from './git-remote/branch-mutation-handlers' +import { registerGitRemoteCompareHandlers } from './git-remote/compare-handlers' +import { registerGitRemoteSyncHandlers } from './git-remote/sync-handlers' + +export function registerFilesystemGitRemoteHandlers(context: FilesystemHandlerContext): void { + registerGitRemoteCompareHandlers(context) + registerGitRemoteSyncHandlers(context) + registerGitRemoteBranchMutationHandlers(context) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts new file mode 100644 index 00000000000..09a80f9f993 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -0,0 +1,307 @@ +import { ipcMain } from 'electron' +import type { + GitConflictOperation, + GitStagingArea, + GitStatusResult +} from '../../../shared/git-status-types' +import type { GitDiffResult } from '../../../shared/git-diff-compare-types' +import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' +import type { GitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' +import { + getStatus, + getSubmoduleStatus, + abortMerge, + abortRebase, + detectConflictOperation, + getDiff +} from '../../git/status' +import { getHistory } from '../../git/history' +import { checkIgnoredPaths } from '../../git/check-ignored-paths' +import { + appendFolderToGitignore, + findKnownHugeFolderPathsToIgnore +} from '../../git/huge-folder-ignore' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateGitRelativeFilePath } from '../filesystem-path-containment' +import { + getLocalGitOptionsForRegisteredWorktree, + getLocalGitOptionsForRepo, + getLocalRepoForRegisteredWorktree +} from '../local-worktree-runtime-options' +import { getWorktreeSharedLinkPaths } from '../../git/worktree-shared-directories' +import { applyGitStatusUpstreamRefWatchRequest } from '../git-status-upstream-ref-watch-request' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { + const { store, gitStatusCancellations } = context + ipcMain.handle( + 'git:status', + async ( + event, + args: { + worktreePath: string + connectionId?: string + admissionTier?: GitAdmissionTier + includeIgnored?: boolean + includeLineStats?: boolean + bypassEffectiveUpstreamNegativeCache?: boolean + reuseLineStats?: boolean + branchLineTotalMergeBase?: string + requestToken?: string + } + ): Promise => { + const controller = gitStatusCancellations.begin(event, args.requestToken) + const options = { + includeIgnored: args.includeIgnored ?? false, + admissionTier: args.admissionTier ?? ('status' as const), + ...(args.includeLineStats === false ? { includeLineStats: false } : {}), + ...(args.reuseLineStats === true ? { reuseLineStats: true } : {}), + ...(args.branchLineTotalMergeBase === undefined + ? {} + : { branchLineTotalMergeBase: args.branchLineTotalMergeBase }), + ...(args.bypassEffectiveUpstreamNegativeCache === true + ? { bypassEffectiveUpstreamNegativeCache: true } + : {}), + ...(controller ? { signal: controller.signal } : {}) + } + try { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: await keeps the cancellation token registered until the remote request settles (an early finally would free it). + return await provider.getStatus(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + // Why: one registered-worktree lookup feeds both — status polls this + // handler, and the scan walks every repo's worktree meta. + const repo = getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath) + const gitOptions = getLocalGitOptionsForRepo(store, repo) + const sharedLinkPaths = repo ? getWorktreeSharedLinkPaths(repo) : [] + return await getStatus(worktreePath, { + ...options, + ...gitOptions, + ...(sharedLinkPaths.length > 0 ? { sharedLinkPaths } : {}) + }) + } finally { + gitStatusCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('git:cancelStatus', (event, args: { requestToken: string }): void => { + gitStatusCancellations.cancel(event, args.requestToken) + }) + + ipcMain.handle( + 'git:setStatusUpstreamRefWatch', + (_event, args: GitStatusUpstreamRefWatchRequest): Promise => + applyGitStatusUpstreamRefWatchRequest(store, args) + ) + + // Why: parent status reports only one gitlink row per submodule; fetch inner per-file changes from the submodule's own worktree. + ipcMain.handle( + 'git:submoduleStatus', + async ( + _event, + args: { + worktreePath: string + submodulePath: string + connectionId?: string + area?: GitStagingArea + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getSubmoduleStatus(args.worktreePath, args.submodulePath, args.area) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getSubmoduleStatus(worktreePath, args.submodulePath, { + ...gitOptions, + ...(args.area === 'staged' ? { staged: true } : {}) + }) + } + ) + + ipcMain.handle( + 'git:checkIgnored', + async ( + _event, + args: { worktreePath: string; paths: string[]; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const paths = args.paths.map((p) => validateGitRelativeFilePath(args.worktreePath, p)) + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.checkIgnoredPaths(args.worktreePath, paths) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const paths = args.paths.map((p) => validateGitRelativeFilePath(worktreePath, p)) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return checkIgnoredPaths(worktreePath, paths, gitOptions) + } + ) + + // Why: backs the SCM "ignore the flooding folder" flow; local-only since huge untracked folders are a local-dev pathology. + ipcMain.handle( + 'git:findHugeFoldersToIgnore', + async (_event, args: { worktreePath: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return findKnownHugeFolderPathsToIgnore(worktreePath, gitOptions) + } + ) + + ipcMain.handle( + 'git:appendGitignore', + async (_event, args: { worktreePath: string; folderName: string }): Promise => { + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return appendFolderToGitignore(worktreePath, args.folderName) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getHistory(worktreePath, { ...options, ...gitOptions }) + } + ) + + // Why: fs-only conflict-state check so non-active worktrees can clear their Rebasing/Merging badges without a full git status. + ipcMain.handle( + 'git:conflictOperation', + async ( + _event, + args: { worktreePath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.detectConflictOperation(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return detectConflictOperation(worktreePath) + } + ) + + ipcMain.handle( + 'git:abortMerge', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortMerge(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortMerge(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:abortRebase', + async (_event, args: { worktreePath: string; connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.abortRebase(args.worktreePath) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await abortRebase(worktreePath, { ...gitOptions, admissionTier: 'interactive' }) + } + ) + + ipcMain.handle( + 'git:diff', + async ( + _event, + args: { + worktreePath: string + filePath: string + staged: boolean + compareAgainstHead?: boolean + connectionId?: string + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getDiff( + args.worktreePath, + args.filePath, + args.staged, + args.compareAgainstHead + ) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-url-handlers.ts b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts new file mode 100644 index 00000000000..cb14d98db60 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-url-handlers.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import { awaitWindowsHostGitEnvironmentReady } from '../../git/runner' +import { getRemoteCommitUrl, getRemoteFileUrl } from '../../git/repo' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { validateFullGitObjectId } from './filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitUrlHandlers(context: FilesystemHandlerContext): void { + const { store } = context + ipcMain.handle( + 'git:remoteFileUrl', + async ( + _event, + args: { worktreePath: string; relativePath: string; line: number; connectionId?: string } + ): Promise => { + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteFileUrl(args.worktreePath, args.relativePath, args.line) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteFileUrl(worktreePath, args.relativePath, args.line) + } + ) + + ipcMain.handle( + 'git:remoteCommitUrl', + async ( + _event, + args: { worktreePath: string; sha: string; connectionId?: string } + ): Promise => { + const sha = validateFullGitObjectId(args.sha, 'sha') + // Why: remote repos can't read relay-side .git/config locally; delegate URL construction to the SSH provider. + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getRemoteCommitUrl(args.worktreePath, sha) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + await awaitWindowsHostGitEnvironmentReady({ cwd: worktreePath }) + return getRemoteCommitUrl(worktreePath, sha) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-handler-context.ts b/src/main/ipc/filesystem/filesystem-handler-context.ts new file mode 100644 index 00000000000..3aeb8320cc3 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-handler-context.ts @@ -0,0 +1,75 @@ +import type { ChildProcess } from 'node:child_process' +import type { FileHandle } from 'node:fs/promises' +import type { Store } from '../../persistence' +import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment' +import type { SenderScopedRequestCancellations } from '../sender-scoped-request-cancellation' +import { cleanupLocalTransferPath } from './filesystem-file-helpers' + +export type DownloadSession = { + destinationPath: string + tempPath: string + destinationExisted: boolean + handle: FileHandle + cleanupTimer: ReturnType + senderId: number +} + +export type FilesystemHandlerContext = { + store: Store + commitMessageAgentEnv?: CommitMessageAgentEnvironmentResolvers + activeTextSearches: Map + downloadSessions: Map + listFilesCancellations: SenderScopedRequestCancellations + gitStatusCancellations: SenderScopedRequestCancellations + closeDownloadSession: ( + transferId: string, + cleanupTemp: boolean + ) => Promise + cleanupDownloadSessionsForSender: (senderId: number) => void +} + +export function createFilesystemHandlerContext( + store: Store, + commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | undefined, + listFilesCancellations: SenderScopedRequestCancellations, + gitStatusCancellations: SenderScopedRequestCancellations +): FilesystemHandlerContext { + const activeTextSearches = new Map() + const downloadSessions = new Map() + + const closeDownloadSession = async ( + transferId: string, + cleanupTemp: boolean + ): Promise => { + const session = downloadSessions.get(transferId) + if (!session) { + return null + } + downloadSessions.delete(transferId) + clearTimeout(session.cleanupTimer) + await session.handle.close().catch(() => {}) + if (cleanupTemp) { + await cleanupLocalTransferPath(session.tempPath) + } + return session + } + + const cleanupDownloadSessionsForSender = (senderId: number): void => { + for (const [transferId, session] of Array.from(downloadSessions)) { + if (session.senderId === senderId) { + void closeDownloadSession(transferId, true) + } + } + } + + return { + store, + commitMessageAgentEnv, + activeTextSearches, + downloadSessions, + listFilesCancellations, + gitStatusCancellations, + closeDownloadSession, + cleanupDownloadSessionsForSender + } +} diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts new file mode 100644 index 00000000000..d1a86f1f748 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -0,0 +1,170 @@ +import { ipcMain } from 'electron' +import { readdir, readFile, stat } from 'node:fs/promises' +import { extname } from 'node:path' +import type { DirEntry, MarkdownDocument } from '../../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../../shared/file-name-sort' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from '../markdown-documents' +import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-store' +import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { + BINARY_PROBE_BYTES, + isBinaryBuffer, + isBinaryFilePrefix, + isDirectoryEntry, + MAX_PREVIEWABLE_BINARY_SIZE, + MAX_TEXT_FILE_SIZE, + PREVIEWABLE_BINARY_MIME_TYPES, + readLocalLogSnapshot +} from './filesystem-file-helpers' + +export function registerFilesystemReadHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:readDir', + async (_event, args: { dirPath: string; connectionId?: string }): Promise => { + // Why: fs:readDir throws surface as opaque IPC errors; record the throw site + redacted path shape to keep them diagnosable. + let throwSite: ReadDirThrowSite = 'authorize' + try { + if (args.connectionId) { + throwSite = 'ssh-provider' + const provider = requireSshFilesystemProvider(args.connectionId) + // Why: re-sort locally — the remote relay may be an older build with lexicographic ordering. + return sortDirEntries(await provider.readDir(args.dirPath)) + } + const dirPath = await resolveAuthorizedPath(args.dirPath, store) + throwSite = 'readdir' + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries.map((entry) => ({ + name: entry.name, + isDirectory: isDirectoryEntry(entry), + isSymlink: entry.isSymbolicLink() + })) + return sortDirEntries(mapped) + } catch (error: unknown) { + recordCrashBreadcrumb( + 'fs_readdir_error', + buildReadDirErrorBreadcrumb({ + dirPath: args.dirPath, + connectionId: args.connectionId, + throwSite, + error + }) + ) + throw error + } + } + ) + + ipcMain.handle( + 'fs:readFile', + async ( + _event, + args: { filePath: string; connectionId?: string; includeLocalLogMetadata?: boolean } + ): Promise<{ + content: string + isBinary: boolean + isImage?: boolean + mimeType?: string + fileIdentity?: string + }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.readFile(args.filePath) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + if (args.includeLocalLogMetadata === true) { + return readLocalLogSnapshot(filePath) + } + const stats = await stat(filePath) + const mimeType = PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const sizeLimit = mimeType ? MAX_PREVIEWABLE_BINARY_SIZE : MAX_TEXT_FILE_SIZE + if (stats.size > sizeLimit) { + throw new Error( + `File too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB exceeds ${sizeLimit / 1024 / 1024}MB limit` + ) + } + + if (mimeType) { + const buffer = await readFile(filePath) + return { + content: buffer.toString('base64'), + isBinary: true, + // Why: the renderer keys previewable-binary rendering off `isImage`, so set it for PDFs too to stay compatible. + isImage: true, + mimeType + } + } + + // Why: probe large unknown files first so archives aren't fully buffered only to discover they aren't editable text. + if (stats.size > BINARY_PROBE_BYTES && (await isBinaryFilePrefix(filePath))) { + return { content: '', isBinary: true } + } + + const buffer = await readFile(filePath) + if (isBinaryBuffer(buffer)) { + return { content: '', isBinary: true } + } + return { content: buffer.toString('utf-8'), isBinary: false } + } + ) + + ipcMain.handle( + 'fs:listMarkdownDocuments', + async ( + _event, + args: { rootPath: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const relativePaths = await provider.listFiles(args.rootPath) + return markdownDocumentsFromRelativePaths(args.rootPath, relativePaths) + } + const rootPath = await resolveRegisteredWorktreePath(args.rootPath, store) + return listMarkdownDocuments(rootPath) + } + ) + + ipcMain.handle( + 'fs:stat', + async ( + _event, + args: { filePath: string; connectionId?: string } + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + const result = await provider.stat(args.filePath) + return { size: result.size, isDirectory: result.type === 'directory', mtime: result.mtime } + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } + ) + + ipcMain.handle( + 'fs:pathExists', + async (_event, args: { filePath: string; connectionId?: string }): Promise => { + try { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + await provider.stat(args.filePath) + return true + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + await stat(filePath) + return true + } catch (error) { + if (isENOENT(error)) { + return false + } + throw error + } + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-search-handlers.ts b/src/main/ipc/filesystem/filesystem-search-handlers.ts new file mode 100644 index 00000000000..c2ec1531ead --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-search-handlers.ts @@ -0,0 +1,234 @@ +import { ipcMain } from 'electron' +import type { ChildProcess } from 'node:child_process' +import type { SearchOptions, SearchResult } from '../../../shared/code-search-types' +import { + buildRgArgs, + createAccumulator, + DEFAULT_SEARCH_MAX_RESULTS, + finalize, + ingestRgJsonLine, + SEARCH_TIMEOUT_MS +} from '../../../shared/text-search' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../../shared/ripgrep-process-availability' +import { toWindowsWslPath, parseWslPath } from '../../wsl' +import { wslAwareSpawn } from '../../git/runner' +import { + getSshFilesystemProvider, + requireSshFilesystemProvider +} from '../../providers/ssh-filesystem-dispatch' +import { checkRgAvailable } from '../rg-availability' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { listQuickOpenFiles } from '../filesystem-list-files' +import { searchWithGitGrep } from '../filesystem-search-git' +import { getLocalGitOptionsForRegisteredWorktree } from '../local-worktree-runtime-options' +import { QuickOpenPathRanker } from '../../../shared/quick-open-path-search' +import type { FilesystemHandlerContext } from './filesystem-handler-context' +import { QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT } from './filesystem-file-helpers' + +export function registerFilesystemSearchHandlers(context: FilesystemHandlerContext): void { + const { store, activeTextSearches } = context + + ipcMain.handle( + 'fs:search', + async (event, args: SearchOptions & { connectionId?: string }): Promise => { + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.search(args) + } + const rootPath = await resolveAuthorizedPath(args.rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.rootPath, + rootPath + ) + const maxResults = Math.max( + 1, + Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro + + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { + return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) + } + + return new Promise((resolvePromise) => { + const rgArgs = buildRgArgs(args.query, rootPath, args) + // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let child: ChildProcess | null = null + let killTimeout: ReturnType + + const transformAbsPath = wslDistroForOutput + ? (path: string): string => + path.startsWith('/') ? toWindowsWslPath(path, wslDistroForOutput) : path + : undefined + + const finish = (result: SearchResult | PromiseLike): void => { + if (resolved) { + return + } + resolved = true + if (activeTextSearches.get(searchKey) === child) { + activeTextSearches.delete(searchKey) + } + clearTimeout(killTimeout) + // Why: child.kill() is advisory; detach our closures so repeated searches don't retain old scans if rg ignores it. + child?.stdout?.off('data', handleStdoutData) + child?.stderr?.off('data', handleStderrData) + child?.off('error', handleError) + child?.off('close', handleClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) + } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: rootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + activeTextSearches.set(searchKey, nextChild) + + const handleStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + } + const handleStderrData = (): void => { + // Drain stderr so rg cannot block on a full pipe. + } + const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + } + + nextChild.stdout!.setEncoding('utf-8') + nextChild.stdout!.on('data', handleStdoutData) + nextChild.stderr!.on('data', handleStderrData) + nextChild.once('error', handleError) + nextChild.once('close', handleClose) + + // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. + killTimeout = setTimeout(() => { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + }, SEARCH_TIMEOUT_MS) + }) + } + ) + + const { listFilesCancellations } = context + ipcMain.handle( + 'fs:listFiles', + async ( + event, + args: { + rootPath: string + connectionId?: string + excludePaths?: string[] + requestToken?: string + maxResults?: number + searchQuery?: string + } + ): Promise => { + const controller = listFilesCancellations.begin(event, args.requestToken) + try { + if (args.connectionId) { + const provider = getSshFilesystemProvider(args.connectionId) + // Why: no provider (cold start / disconnected) → return [] so quick-open shows "No matching files" instead of an error. + if (!provider) { + return [] + } + // Why: forward excludePaths or nested linked worktrees get double-scanned over SSH, causing timeout-induced partial results. + if ( + args.searchQuery !== undefined && + provider.supportsQuickOpenSearch && + !(await provider.supportsQuickOpenSearch({ signal: controller?.signal })) + ) { + const legacyFiles = await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + maxResults: QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT, + signal: controller?.signal + }) + const ranker = new QuickOpenPathRanker( + args.searchQuery, + args.maxResults ?? QUICK_OPEN_SSH_LEGACY_RESULT_LIMIT + ) + for (const file of legacyFiles) { + ranker.consider(file) + } + return ranker.result().paths + } + return await provider.listFiles(args.rootPath, { + excludePaths: args.excludePaths, + ...(args.maxResults === undefined ? {} : { maxResults: args.maxResults }), + ...(args.searchQuery === undefined ? {} : { searchQuery: args.searchQuery }), + signal: controller?.signal + }) + } + return await listQuickOpenFiles(args.rootPath, store, args.excludePaths, controller?.signal) + } finally { + listFilesCancellations.finish(event, args.requestToken, controller) + } + } + ) + + ipcMain.handle('fs:cancelListFiles', (event, args: { requestToken: string }): void => { + listFilesCancellations.cancel(event, args.requestToken) + }) +} diff --git a/src/main/ipc/filesystem/filesystem-worktree-helpers.ts b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts new file mode 100644 index 00000000000..18dd5c4f1f6 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-worktree-helpers.ts @@ -0,0 +1,185 @@ +import type { Repo } from '../../../shared/repo-types' +import type { Store } from '../../persistence' +import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' +import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' +import { resolve } from 'node:path' +import { getSshGitProvider } from '../../providers/ssh-git-dispatch' +import { listRepoWorktrees } from '../../repo-worktrees' +import { resolveAuthorizedPath } from '../filesystem-auth' +import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache' +import { splitWorktreeId } from '../../../shared/worktree/id' + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function getCandidateLocalWorktreePaths( + worktreePath: string, + resolvedWorktreePath: string +): Set { + return new Set([worktreePath, resolvedWorktreePath].map(comparableLocalPath)) +} + +function hasRegisteredWorktreeMetaForRepo( + store: Store, + repoId: string, + candidatePaths: Set +): boolean { + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if (parsed?.repoId === repoId && candidatePaths.has(comparableLocalPath(parsed.worktreePath))) { + return true + } + } + return false +} + +function comparableRemotePath(value: string): string { + return value.replace(/[/\\]+$/g, '') +} + +function hasRegisteredRemoteWorktreeMetaForRepo( + store: Store, + repoId: string, + worktreePath: string +): boolean { + const comparableWorktreePath = comparableRemotePath(worktreePath) + for (const worktreeId of Object.keys(store.getAllWorktreeMeta())) { + const parsed = splitWorktreeId(worktreeId) + if ( + parsed?.repoId === repoId && + comparableRemotePath(parsed.worktreePath) === comparableWorktreePath + ) { + return true + } + } + return false +} + +async function localRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string +): Promise { + let resolvedWorktreePath: string + try { + resolvedWorktreePath = await resolveRegisteredWorktreePath(worktreePath, store) + } catch { + return false + } + const candidatePaths = getCandidateLocalWorktreePaths(worktreePath, resolvedWorktreePath) + if (candidatePaths.has(comparableLocalPath(repo.path))) { + return true + } + if (hasRegisteredWorktreeMetaForRepo(store, repo.id, candidatePaths)) { + return true + } + try { + const worktrees = await listRepoWorktrees(repo) + return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) + } catch { + return false + } +} + +async function remoteRepoOwnsWorktree( + store: Store, + repo: Repo, + worktreePath: string, + connectionId: string +): Promise { + const comparableWorktreePath = comparableRemotePath(worktreePath) + if (comparableRemotePath(repo.path) === comparableWorktreePath) { + return true + } + const provider = getSshGitProvider(connectionId) + if (!provider) { + return hasRegisteredRemoteWorktreeMetaForRepo(store, repo.id, worktreePath) + } + try { + const worktrees = await provider.listWorktrees(repo.path) + return worktrees.some( + (worktree) => comparableRemotePath(worktree.path) === comparableWorktreePath + ) + } catch { + return false + } +} + +export async function getRepoForSourceControlAi( + store: Store, + args: { repoId?: string; worktreePath: string; connectionId?: string } +): Promise { + if (!args.repoId) { + return null + } + const repo = store.getRepo(args.repoId) + if (!repo) { + return null + } + if (args.connectionId) { + if (repo.connectionId !== args.connectionId) { + return null + } + // Why: one SSH connection can host several repos; repo-scoped AI overrides apply only when the worktree belongs to that repo. + return (await remoteRepoOwnsWorktree(store, repo, args.worktreePath, args.connectionId)) + ? repo + : null + } + if (repo.connectionId) { + return null + } + // Why: renderer-supplied repoId is advisory; apply repo overrides only when the local worktree belongs to that repo. + return (await localRepoOwnsWorktree(store, repo, args.worktreePath)) ? repo : null +} + +export function getLocalAgentRuntimeTarget( + gitOptions: LocalProjectWorktreeGitOptions +): CommitMessageAgentRuntimeTarget { + return gitOptions.wslDistro + ? { runtime: 'wsl', wslDistro: gitOptions.wslDistro } + : { runtime: 'host' } +} + +export async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + try { + return await resolveRegisteredWorktreePath(requestedPath, store) + } catch (error) { + const folderWorkspaces = + typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] + const isFolderWorkspaceRoot = folderWorkspaces.some( + (workspace) => + comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) + ) + if (!isFolderWorkspaceRoot) { + throw error + } + return resolveAuthorizedPath(requestedPath, store) + } +} + +export function getLocalTextGenerationTarget( + worktreePath: string, + gitOptions: LocalProjectWorktreeGitOptions, + env?: NodeJS.ProcessEnv +): Extract { + return { + kind: 'local', + cwd: worktreePath, + ...(gitOptions.wslDistro ? { wslDistro: gitOptions.wslDistro } : {}), + ...(env ? { env } : {}) + } +} + +export function validateFullGitObjectId(value: string, label: string): string { + const pattern = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + if (!pattern.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts new file mode 100644 index 00000000000..07d4de5b56a --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -0,0 +1,91 @@ +import { ipcMain, shell } from 'electron' +import { lstat, writeFile } from 'node:fs/promises' +import type { SshMutationExpectation } from '../../../shared/ssh-types' +import { assertSshMutationExpectation } from '../../ssh/ssh-connection-generation' +import { requireSshFilesystemProvider } from '../../providers/ssh-filesystem-dispatch' +import { tryDeleteWslUncPath } from '../../wsl-unc-delete' +import { authorizeExternalPath, resolveAuthorizedPath } from '../filesystem-auth' +import { isENOENT } from '../filesystem-path-containment' +import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemWriteHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'fs:writeFile', + async ( + _event, + args: { filePath: string; content: string; connectionId?: string } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.writeFile(args.filePath, args.content) + } + const filePath = await resolveAuthorizedPath(args.filePath, store) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, args.content, 'utf-8') + } + ) + + ipcMain.handle( + 'fs:deletePath', + async ( + _event, + args: { + targetPath: string + connectionId?: string + recursive?: boolean + } & SshMutationExpectation + ): Promise => { + assertSshMutationExpectation( + args.connectionId, + args.expectedSshTargetId, + args.expectedSshConnectionGeneration, + args.expectedExecutionHostId + ) + if (args.connectionId) { + const provider = requireSshFilesystemProvider(args.connectionId) + return provider.deletePath(args.targetPath, args.recursive) + } + // Why: preserve the symlink so we delete the link, not its target (realpath would trash the real file, possibly outside all roots). + const targetPath = await resolveAuthorizedPath(args.targetPath, store, { + preserveSymlink: true + }) + // Why: WSL UNC targets have no Recycle Bin (shell.trashItem throws), so hard-delete via `rm` inside the distro (issue #6415). + if (await tryDeleteWslUncPath(targetPath, { recursive: args.recursive })) { + return + } + // Why: swallow ENOENT so an external delete racing this UI delete stays idempotent (design §7.1). + try { + await shell.trashItem(targetPath) + } catch (error) { + if (isENOENT(error)) { + return + } + throw error + } + } + ) + + registerFilesystemMutationHandlers(store) + + ipcMain.handle('fs:authorizeExternalPath', (_event, args: { targetPath: string }): void => { + authorizeExternalPath(args.targetPath) + }) +} diff --git a/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts new file mode 100644 index 00000000000..53ba72d58a4 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/branch-mutation-handlers.ts @@ -0,0 +1,158 @@ +import { ipcMain } from 'electron' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import { gitFastForward, gitPull, gitPullRebaseFromBase, gitPush } from '../../../git/remote' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteBranchMutationHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:push', + async ( + _event, + args: { + worktreePath: string + publish?: boolean + forceWithLease?: boolean + connectionId?: string + pushTarget?: GitPushTarget + } + ): Promise => { + // Why: coerce to strict boolean so a malformed payload (e.g. string 'false') can't enable --set-upstream; mirror in src/relay/git-handler.ts. + const publish = args.publish === true + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pushBranch(args.worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPush(worktreePath, publish, args.pushTarget, { + forceWithLease: args.forceWithLease === true, + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:pull', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.pullBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitPull(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:fastForward', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fastForwardBranch(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFastForward(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:rebaseFromBase', + async ( + _event, + args: { worktreePath: string; baseRef: string; connectionId?: string } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.rebaseFromBase(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + await gitPullRebaseFromBase(worktreePath, args.baseRef, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/compare-handlers.ts b/src/main/ipc/filesystem/git-remote/compare-handlers.ts new file mode 100644 index 00000000000..a522ae10af2 --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/compare-handlers.ts @@ -0,0 +1,78 @@ +import { ipcMain } from 'electron' +import type { + GitBranchCompareResult, + GitCommitCompareResult +} from '../../../../shared/git-diff-compare-types' +import { getBranchCompare, getCommitCompare } from '../../../git/status' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { validateFullGitObjectId } from '../filesystem-worktree-helpers' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' +import type { GitAdmissionTier } from '../../../git/command-runner/git-exec-options' + +export function registerGitRemoteCompareHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:branchCompare', + async ( + _event, + args: { + worktreePath: string + baseRef: string + connectionId?: string + admissionTier?: GitAdmissionTier + } + ): Promise => { + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return args.admissionTier + ? provider.getBranchCompare(args.worktreePath, args.baseRef, { + admissionTier: args.admissionTier + }) + : provider.getBranchCompare(args.worktreePath, args.baseRef) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getBranchCompare(worktreePath, args.baseRef, { + ...gitOptions, + ...(args.admissionTier ? { admissionTier: args.admissionTier } : {}) + }) + } + ) + + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getCommitCompare(worktreePath, commitId, gitOptions) + } + ) +} diff --git a/src/main/ipc/filesystem/git-remote/sync-handlers.ts b/src/main/ipc/filesystem/git-remote/sync-handlers.ts new file mode 100644 index 00000000000..eae79c918dd --- /dev/null +++ b/src/main/ipc/filesystem/git-remote/sync-handlers.ts @@ -0,0 +1,118 @@ +import { ipcMain } from 'electron' +import type { + GitForkSyncExpectedUpstream, + GitForkSyncResult +} from '../../../../shared/git-fork-sync' +import type { GitPushTarget } from '../../../../shared/worktree/types' +import type { GitUpstreamStatus } from '../../../../shared/git-status-types' +import { gitFetch } from '../../../git/remote' +import { gitSyncForkDefaultBranch } from '../../../git/fork-sync' +import { getUpstreamStatus } from '../../../git/upstream' +import { validateGitPushTarget } from '../../../git/push-target-validation' +import { + getSshGitProvider, + SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE +} from '../../../providers/ssh-git-dispatch' +import { resolveRegisteredWorktreePath } from '../../registered-worktree-roots-cache' +import { getLocalGitOptionsForRegisteredWorktree } from '../../local-worktree-runtime-options' +import { assertGitPushTargetShape } from '../../../../shared/git-push-target-validation' +import { validateGitForkSyncExpectedUpstream } from '../../../../shared/git-fork-sync' +import type { FilesystemHandlerContext } from '../filesystem-handler-context' + +export function registerGitRemoteSyncHandlers(context: FilesystemHandlerContext): void { + const { store } = context + + ipcMain.handle( + 'git:upstreamStatus', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.getUpstreamStatus(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return getUpstreamStatus(worktreePath, args.pushTarget, gitOptions) + } + ) + + ipcMain.handle( + 'git:fetch', + async ( + _event, + args: { worktreePath: string; connectionId?: string; pushTarget?: GitPushTarget } + ): Promise => { + if (args.connectionId) { + if (args.pushTarget) { + assertGitPushTargetShape(args.pushTarget) + } + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.fetchRemote(args.worktreePath, args.pushTarget) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + if (args.pushTarget) { + await validateGitPushTarget(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + await gitFetch(worktreePath, args.pushTarget, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) + + ipcMain.handle( + 'git:syncFork', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + expectedUpstream: GitForkSyncExpectedUpstream + } + ): Promise => { + const expectedUpstream = validateGitForkSyncExpectedUpstream(args.expectedUpstream, { + required: true + }) + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.syncForkDefaultBranch(args.worktreePath, expectedUpstream) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const gitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + args.worktreePath, + worktreePath + ) + return gitSyncForkDefaultBranch(worktreePath, expectedUpstream, { + ...gitOptions, + admissionTier: 'interactive' + }) + } + ) +} diff --git a/src/main/macos-press-and-hold-default.test.ts b/src/main/macos-press-and-hold-default.test.ts index c30aed82d10..b1535b50408 100644 --- a/src/main/macos-press-and-hold-default.test.ts +++ b/src/main/macos-press-and-hold-default.test.ts @@ -267,20 +267,26 @@ describe('readBundleIdentifierFromExecutablePath', () => { }) describe('startup wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') it('runs before app.whenReady(), which is the last point AppKit could still see it', () => { const callIndex = source.indexOf( 'applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath())' ) const initDataPathIndex = source.indexOf('initDataPath()') - const readyIndex = source.indexOf('app.whenReady().then(') + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') expect(callIndex).toBeGreaterThanOrEqual(0) expect(readyIndex).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) // Why after initDataPath: the record lives beside orca-data.json, and the canonical userData // path is only captured there. expect(callIndex).toBeGreaterThan(initDataPathIndex) - expect(callIndex).toBeLessThan(readyIndex) + expect(preflightCall).toBeLessThan(readyIndex) }) }) diff --git a/src/main/quit-teardown-agent-browser-daemons.test.ts b/src/main/quit-teardown-agent-browser-daemons.test.ts index cc2c7d7a26e..315eba73aa0 100644 --- a/src/main/quit-teardown-agent-browser-daemons.test.ts +++ b/src/main/quit-teardown-agent-browser-daemons.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * hundreds of ms apiece. Left off the will-quit barrier, `app.quit()` fired first and * every open tab's daemon outlived the app (#16367). */ -const source = readFileSync(join(__dirname, 'index.ts'), 'utf8') +const source = readFileSync(join(__dirname, 'startup', 'main-process-quit.ts'), 'utf8') function teardownBarrierMembers(): string { const start = source.indexOf('settleTeardownWithinDeadline([') @@ -25,7 +25,7 @@ describe('quit teardown of agent-browser daemons', () => { it('captures the destroyAllSessions promise instead of firing and forgetting', () => { expect(source).toMatch( - /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ + /const browserShutdown = \(async \(\): Promise => \{[\s\S]*?await state\.runtime\?\.getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)\s+\}\)\(\)/ ) // Why: a second, uncaptured call site is the pre-fix shape — it loses the race to app.quit(). expect(source.match(/getAgentBrowserBridge\(\)\?\.destroyAllSessions\(\)/g)).toHaveLength(1) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 8ce0b167a4a..5d12b979bc3 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1,2182 +1,10 @@ -/* eslint-disable max-lines -- Why: centralizes polling, stale-data handling, account-switch fetch semantics, and renderer push coordination in one place */ -import type { BrowserWindow } from 'electron' -import type { - CodexRateLimitResetResult, - RateLimitState, - ProviderRateLimits, - InactiveAccountUsage, - RateLimitRuntimeTarget -} from '../../shared/rate-limit-types' -import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' -import type { InactiveClaudeAccountInfo } from './claude-fetcher' -import { mapClaudeUsageWindow } from './claude-usage-window' -import type { ClaudeStatusLineRateLimits } from '../../shared/claude-statusline-rate-limits' -import { consumeCodexRateLimitResetCredit, fetchCodexRateLimits } from './codex-fetcher' -import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' -import type { NetworkProxySettings } from '../../shared/network-proxy' -import { - normalizeClaudeAccountSelectionTarget, - type ClaudeAccountSelectionTarget, - type NormalizedClaudeAccountSelectionTarget -} from '../claude-accounts/runtime-selection' -import { fetchGeminiRateLimits } from './gemini-usage-fetcher' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' -import { fetchKimiRateLimits } from './kimi-fetcher' -import type { KimiHomeResolution } from '../kimi/kimi-runtime-home' -import { fetchGrokRateLimits } from './grok-fetcher' -import { readGrokAuthSession } from './grok-auth' -import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' -import { fetchMiniMaxRateLimits } from './minimax-fetcher' -import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' -import { - normalizeCodexAccountSelectionTarget, - type CodexAccountSelectionTarget, - type NormalizedCodexAccountSelectionTarget -} from '../codex-accounts/runtime-selection' -import type { CodexRateLimitHomeResolution } from '../codex-accounts/runtime-home-service' +import { RateLimitServiceConfiguration } from './service/service-configuration' -export type InactiveCodexAccountInfo = { - id: string - resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } -} +export type { InactiveCodexAccountInfo } from './service/service-types' -type CodexHomePathResolver = (target?: CodexAccountSelectionTarget) => CodexRateLimitHomeResolution -type KimiHomeResolver = () => Promise -type ClaudeAuthPreparationResolver = ( - target?: ClaudeAccountSelectionTarget -) => Promise - -type OpenCodeGoRateLimitConfig = { - sessionCookie: string - workspaceIdOverride: string -} - -type MiniMaxRateLimitConfig = { - sessionCookie: string - groupId: string - models: string -} - -type MiniMaxResolvedConfig = { - config: MiniMaxRateLimitConfig - error: string | null -} - -type GeminiCliOAuthEnabledResolver = () => boolean -type ActiveRateLimitProvider = ProviderRateLimits['provider'] -type ActiveProviderState = { - provider: ActiveRateLimitProvider - limits: ProviderRateLimits | null -} -type ActiveWindowRefreshPlan = - | { kind: 'none' } - | { kind: 'full' } - | { kind: 'providers'; providers: ActiveRateLimitProvider[] } - -// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. -const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes -const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. -const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. -const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts -const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS -// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. -const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS -const MAX_ACTIVE_FAILURE_STREAK = 8 -// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. -const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'grok' -]) -const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped -// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". -const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 -// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. -const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 -const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open -// Why: each inactive Codex probe spawns a real codex process inside that -// account's live credential home; pace them out instead of bursting every -// account the moment the switcher opens. -const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 -const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 - -// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). -type InternalRateLimitState = { - claude: ProviderRateLimits | null - codex: ProviderRateLimits | null - gemini: ProviderRateLimits | null - opencodeGo: ProviderRateLimits | null - kimi: ProviderRateLimits | null - antigravity: ProviderRateLimits | null - minimax: ProviderRateLimits | null - grok: ProviderRateLimits | null -} - -function normalizePollingInterval(ms: number): number { - if (!Number.isFinite(ms)) { - return DEFAULT_POLL_MS - } - return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) -} - -function isSystemDefaultClaudeAuth( - authPreparation: ClaudeRuntimeAuthPreparation | undefined -): boolean { - // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. - if (!authPreparation) { - return true - } - const provenance = authPreparation?.provenance - return provenance === 'system' || Boolean(provenance?.endsWith(':system')) -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - -function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { - // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. - const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') - return trimmed || null -} - -function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const onAbort = (): void => { - clearTimeout(timer) - resolve() - } - const timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, ms) - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - -function isSameUsageWindow( - a: ProviderRateLimits['session'], - b: ProviderRateLimits['session'] -): boolean { - if (!a || !b) { - return a === b - } - return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt -} - -export class RateLimitService { - private state: InternalRateLimitState = { - claude: null, - codex: null, - gemini: null, - opencodeGo: null, - kimi: null, - antigravity: null, - minimax: null, - grok: null - } - private grokAuthConfigured = readGrokAuthSession().status === 'ok' - private pollInterval: number = DEFAULT_POLL_MS - private timer: ReturnType | null = null - private deferredStartupRefreshTimer: ReturnType | null = null - // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. - private lastActiveFailureRetryAtByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. - private activeFailureStreakByProvider: Record = { - claude: 0, - codex: 0, - gemini: 0, - 'opencode-go': 0, - kimi: 0, - minimax: 0, - grok: 0, - antigravity: 0 - } - private mainWindow: BrowserWindow | null = null - private detachWindowListeners: (() => void) | null = null - private isFetching = false - private fullFetchQueued = false - private codexOnlyFetchQueued = false - private claudeOnlyFetchQueued = false - private grokOnlyFetchQueued = false - private activeFetchAbortControllers = new Set() - private fetchIdleResolvers: (() => void)[] = [] - private codexFetchGeneration = 0 - private claudeFetchGeneration = 0 - // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. - private lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null - private opencodeFetchGeneration = 0 - private minimaxFetchGeneration = 0 - private lastOpencodeConfigHash = '' - private lastMiniMaxConfigHash = '' - private codexHomePathResolver: CodexHomePathResolver | null = null - private codexFetchTarget: NormalizedCodexAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - // Why: resolved per cycle — the local-account runtime policy can flip between fetches. - private kimiHomeResolver: KimiHomeResolver | null = null - private claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null - private claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { - runtime: 'host', - wslDistro: null - } - private openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null - private miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null - private geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null - private inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null - private inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null - private networkProxySettingsResolver: (() => NetworkProxySettings) | null = null - private inactiveClaudeCache = new Map() - private inactiveCodexCache = new Map() - private inactiveClaudeFetching = new Set() - private inactiveCodexFetching = new Set() - private inactiveCodexFetchInFlight = false - private lastInactiveClaudeFetchAt = 0 - private inactiveClaudeAccountsGeneration = 0 - private lastInactiveCodexFetchAt = 0 - private inactiveCodexAccountsGeneration = 0 - private stateListeners = new Set<(state: RateLimitState) => void>() - - constructor() {} - - onStateChange(listener: (state: RateLimitState) => void): () => void { - this.stateListeners.add(listener) - return () => { - this.stateListeners.delete(listener) - } - } - - setCodexHomePathResolver(resolver: CodexHomePathResolver): void { - this.codexHomePathResolver = resolver - } - - // Why: `skip` and a `ready` null are different answers — null still means the - // system-default lane, so it must never stand in for "don't fetch" (#STA-4422). - private resolveCodexHome(target?: CodexAccountSelectionTarget): { - skip: boolean - homePath: string | null - } { - const resolution = this.codexHomePathResolver?.(target) - if (!resolution) { - return { skip: false, homePath: null } - } - return resolution.kind === 'skip' - ? { skip: true, homePath: null } - : { skip: false, homePath: resolution.codexHomePath } - } - - setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { - this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) - } - - setKimiHomeResolver(resolver: KimiHomeResolver): void { - this.kimiHomeResolver = resolver - } - - // Why: resolving a WSL home probes wsl.exe, so it must not run before the other - // providers' fetches are started; chaining keeps the no-resolver path immediate. - private fetchKimiWithResolvedHome(): Promise { - const pendingHome = this.kimiHomeResolver?.() - return pendingHome - ? pendingHome.then((home) => fetchKimiRateLimits({ home })) - : fetchKimiRateLimits({ home: undefined }) - } - - setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { - this.claudeAuthPreparationResolver = resolver - } - - setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { - this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) - } - - setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { - this.openCodeGoConfigResolver = resolver - } - - setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { - this.miniMaxConfigResolver = resolver - } - - setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { - this.geminiCliOAuthEnabledResolver = resolver - } - - setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { - this.networkProxySettingsResolver = resolver - } - - setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { - this.inactiveClaudeAccountsResolver = resolver - this.inactiveClaudeAccountsGeneration += 1 - } - - setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { - this.inactiveCodexAccountsResolver = resolver - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - } - - attach(mainWindow: BrowserWindow): void { - this.detachWindowListeners?.() - this.mainWindow = mainWindow - const refreshOnResume = (): void => { - void this.refreshIfWindowActive() - } - // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. - const detachWindowListeners = (): void => { - mainWindow.removeListener('focus', refreshOnResume) - mainWindow.removeListener('show', refreshOnResume) - mainWindow.removeListener('restore', refreshOnResume) - mainWindow.removeListener('closed', onClosed) - } - const onClosed = (): void => { - detachWindowListeners() - if (this.detachWindowListeners === detachWindowListeners) { - this.detachWindowListeners = null - } - if (this.mainWindow === mainWindow) { - this.mainWindow = null - } - } - mainWindow.on('focus', refreshOnResume) - mainWindow.on('show', refreshOnResume) - mainWindow.on('restore', refreshOnResume) - mainWindow.on('closed', onClosed) - this.detachWindowListeners = detachWindowListeners - } - - start(options: { fetchImmediately?: boolean } = {}): void { - if (options.fetchImmediately !== false) { - void this.fetchAll() - } else { - this.scheduleDeferredStartupRefresh() - } - this.startTimer() - } - - stop(): void { - this.abortActiveFetchCycle() - this.clearQueuedFetches() - this.inactiveClaudeFetching.clear() - this.inactiveCodexFetching.clear() - this.resolveAndClearFetchIdleWaiters() - this.stopTimer() - this.clearDeferredStartupRefresh() - this.detachWindowListeners?.() - this.detachWindowListeners = null - this.mainWindow = null - } - - getState(): RateLimitState { - this.pruneInactiveClaudeState() - this.pruneInactiveCodexState() - return { - ...this.state, - // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. - minimaxCookieConfigured: hasMiniMaxSessionCookie(), - grokAuthConfigured: this.grokAuthConfigured, - claudeTarget: this.claudeFetchTarget, - codexTarget: this.codexFetchTarget, - inactiveClaudeAccounts: this.buildInactiveArray( - this.inactiveClaudeCache, - this.inactiveClaudeFetching - ), - inactiveCodexAccounts: this.buildInactiveArray( - this.inactiveCodexCache, - this.inactiveCodexFetching - ) - } - } - - async refresh(): Promise { - // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. - await this.fetchAll({ force: true }) - return this.getState() - } - - async refreshIfStale(): Promise { - // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - return this.getState() - } - - async refreshGrok(): Promise { - await this.fetchGrokOnly({ force: true }) - return this.getState() - } - - invalidateMiniMaxCredentialState(): void { - this.minimaxFetchGeneration += 1 - // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. - this.updateState({ - ...this.state, - minimax: this.withFetchingStatus(null, 'minimax') - }) - } - - async refreshForCodexAccountChange( - outgoingAccountId?: string | null, - target?: CodexAccountSelectionTarget - ): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - // Why: weekly-only plans report no session window, so gating on session alone - // dropped their snapshot and left the switcher's inline bars empty. - if ( - outgoingAccountId && - (this.state.codex?.session || this.state.codex?.weekly) && - this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - ) { - this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) - } - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.codex = 0 - this.inactiveCodexAccountsGeneration += 1 - this.pruneInactiveCodexState() - // Why: the switch must NOT reset the inactive-fetch debounce — re-probing - // every inactive account per switch spawns codex in each credential home - // and endangers rotating refresh tokens; the switcher shows the cached - // snapshot (seeded above for the outgoing account) until the debounce ends. - // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(null, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { - const nextTarget = normalizeCodexAccountSelectionTarget(target) - const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) - this.codexFetchTarget = nextTarget - this.codexFetchGeneration += 1 - this.activeFailureStreakByProvider.codex = 0 - this.updateState({ - ...this.state, - codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') - }) - await this.fetchCodexOnly({ force: true }) - return this.getState() - } - - async consumeCodexRateLimitResetCredit(options: { - idempotencyKey: string - target: RateLimitRuntimeTarget - codexHomePath: string | null - }): Promise { - const codexTarget = normalizeCodexAccountSelectionTarget(options.target) - const codexHomePath = options.codexHomePath - const scopedStateBeforeReset = this.getState() - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - if (missingWslCodexHome) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') - } - try { - const outcome = await consumeCodexRateLimitResetCredit({ - codexHomePath, - idempotencyKey: options.idempotencyKey - }) - const state = await this.fetchCodexResetResultState( - codexTarget, - codexHomePath, - scopedStateBeforeReset - ) - return { outcome, state } - } catch (error) { - if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { - await this.fetchCodexOnly({ force: true }) - } - throw error - } - } - - async refreshForClaudeAccountChange( - outgoingAccountId?: string | null, - target?: ClaudeAccountSelectionTarget - ): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. - if ( - outgoingAccountId && - this.state.claude?.session && - this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - ) { - this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) - } - this.claudeFetchTarget = nextTarget - this.inactiveClaudeAccountsGeneration += 1 - this.pruneInactiveClaudeState() - this.claudeFetchGeneration += 1 - // Why: a new account/target starts with a clean retry schedule. - this.activeFailureStreakByProvider.claude = 0 - // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. - this.lastClaudeAuthSnapshot = null - this.lastInactiveClaudeFetchAt = 0 - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(null, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { - const nextTarget = normalizeClaudeAccountSelectionTarget(target) - const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) - this.claudeFetchTarget = nextTarget - this.claudeFetchGeneration += 1 - this.activeFailureStreakByProvider.claude = 0 - if (targetChanged) { - // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. - this.lastClaudeAuthSnapshot = null - } - this.updateState({ - ...this.state, - claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') - }) - await this.fetchClaudeOnly({ force: true }) - return this.getState() - } - - async refreshAfterClaudeLivePtysDrained(): Promise { - // Why: "Waiting for Claude session" can only recover once no live claude - // owns the credentials. Refetch on the last PTY exit instead of leaving - // the stale terminal error up until the failure backoff elapses. - if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { - return - } - this.activeFailureStreakByProvider.claude = 0 - await this.fetchClaudeOnly({ force: true }) - } - - async fetchInactiveClaudeAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveClaudeState() - if (this.inactiveClaudeFetching.size > 0) { - return - } - const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - const fetchGeneration = this.inactiveClaudeAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - - for (const account of accounts) { - this.inactiveClaudeFetching.add(account.id) - } - this.pushToRenderer() - - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - try { - const fresh = await fetchManagedAccountUsage(account, { - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeFetching.delete(account.id) - if (!this.isCurrentInactiveClaudeAccount(account.id)) { - this.inactiveClaudeCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveClaudeCache.get(account.id) ?? null - this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveClaudeAccountsGeneration || - !this.isCurrentInactiveClaudeAccount(account.id) - ) { - this.inactiveClaudeCache.delete(account.id) - } - } - this.inactiveClaudeFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { - this.lastInactiveClaudeFetchAt = Date.now() - } - } finally { - this.finishFetchCycle(controller) - } - } - - async fetchInactiveCodexAccountsOnOpen(): Promise { - if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { - return - } - this.pruneInactiveCodexState() - if (this.inactiveCodexFetchInFlight) { - return - } - const accounts = this.inactiveCodexAccountsResolver?.() ?? [] - if (accounts.length === 0) { - return - } - // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. - const fetchGeneration = this.inactiveCodexAccountsGeneration - const controller = this.beginFetchCycle() - const signal = controller.signal - this.inactiveCodexFetchInFlight = true - - let staggerNextProbe = false - try { - for (const account of accounts) { - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - if (staggerNextProbe) { - await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) - // Why: the account set can change while the stagger delay runs. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - } - const home = account.resolveHome() - if (home.kind === 'skip') { - continue - } - staggerNextProbe = true - this.inactiveCodexFetching.add(account.id) - this.pushToRenderer() - try { - // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. - // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. - const fresh = await fetchCodexRateLimits({ - codexHomePath: home.managedHomePath, - allowPtyFallback: false, - signal - }) - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexFetching.delete(account.id) - if (!this.isCurrentInactiveCodexAccount(account.id)) { - this.inactiveCodexCache.delete(account.id) - } - this.pushToRenderer() - continue - } - const cached = this.inactiveCodexCache.get(account.id) ?? null - this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) - } catch { - // Why: per-account try/catch prevents one failure from aborting the batch. - if ( - signal.aborted || - fetchGeneration !== this.inactiveCodexAccountsGeneration || - !this.isCurrentInactiveCodexAccount(account.id) - ) { - this.inactiveCodexCache.delete(account.id) - } - } - this.inactiveCodexFetching.delete(account.id) - this.pushToRenderer() - } - - if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { - this.lastInactiveCodexFetchAt = Date.now() - } - } finally { - this.inactiveCodexFetchInFlight = false - this.finishFetchCycle(controller) - } - } - - evictInactiveClaudeCache(accountId: string): void { - this.inactiveClaudeAccountsGeneration += 1 - this.inactiveClaudeCache.delete(accountId) - this.inactiveClaudeFetching.delete(accountId) - this.pushToRenderer() - } - - private isCurrentInactiveClaudeAccount(accountId: string): boolean { - return (this.inactiveClaudeAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private isCurrentInactiveCodexAccount(accountId: string): boolean { - return (this.inactiveCodexAccountsResolver?.() ?? []).some( - (account) => account.id === accountId - ) - } - - private pruneInactiveClaudeState(): void { - const currentIds = new Set( - (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveClaudeCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeCache.delete(accountId) - } - } - for (const accountId of this.inactiveClaudeFetching) { - if (!currentIds.has(accountId)) { - this.inactiveClaudeFetching.delete(accountId) - } - } - } - - private pruneInactiveCodexState(): void { - const currentIds = new Set( - (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) - ) - for (const accountId of this.inactiveCodexCache.keys()) { - if (!currentIds.has(accountId)) { - this.inactiveCodexCache.delete(accountId) - } - } - for (const accountId of this.inactiveCodexFetching) { - if (!currentIds.has(accountId)) { - this.inactiveCodexFetching.delete(accountId) - } - } - } - - evictInactiveCodexCache(accountId: string): void { - // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. - this.inactiveCodexCache.delete(accountId) - this.inactiveCodexFetching.delete(accountId) - this.pushToRenderer() - } - - setPollingInterval(ms: number): void { - this.pollInterval = normalizePollingInterval(ms) - if (this.timer) { - this.stopTimer() - this.startTimer() - } - } - - // --------------------------------------------------------------------------- - // Internal - // --------------------------------------------------------------------------- - - private startTimer(): void { - this.stopTimer() - this.timer = setInterval(() => { - if (!this.shouldBackgroundPoll()) { - return - } - void this.fetchAll() - }, this.pollInterval) - } - - private stopTimer(): void { - if (this.timer) { - clearInterval(this.timer) - this.timer = null - } - } - - private scheduleDeferredStartupRefresh(): void { - this.clearDeferredStartupRefresh() - this.deferredStartupRefreshTimer = setTimeout(() => { - this.deferredStartupRefreshTimer = null - void this.refreshIfWindowActive() - }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) - } - - private clearDeferredStartupRefresh(): void { - if (this.deferredStartupRefreshTimer) { - clearTimeout(this.deferredStartupRefreshTimer) - this.deferredStartupRefreshTimer = null - } - } - - private shouldBackgroundPoll(): boolean { - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return false - } - // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). - if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { - return false - } - return this.mainWindow.isFocused() - } - - private getActiveProviderState(): ActiveProviderState[] { - // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. - const byProvider: Record = { - claude: this.state.claude, - codex: this.state.codex, - gemini: this.state.gemini, - 'opencode-go': this.state.opencodeGo, - kimi: this.state.kimi, - minimax: this.state.minimax, - grok: this.state.grok, - antigravity: this.state.antigravity - } - return Object.entries(byProvider).map(([provider, limits]) => ({ - provider: provider as ActiveRateLimitProvider, - limits - })) - } - - private getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { - const retryableFailures: ActiveRateLimitProvider[] = [] - for (const { provider, limits } of this.getActiveProviderState()) { - if (!limits || limits.status === 'idle' || limits.status === 'fetching') { - return { kind: 'full' } - } - if (limits.status === 'ok' || limits.status === 'unavailable') { - if (now - limits.updatedAt >= MIN_REFETCH_MS) { - return { kind: 'full' } - } - continue - } - // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. - if (limits.status === 'error') { - // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. - if (this.isRetryAfterActive(limits)) { - continue - } - const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] - const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ? Math.min( - ACTIVE_FAILURE_REFETCH_MS * - 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), - MAX_ACTIVE_FAILURE_REFETCH_MS - ) - : MIN_REFETCH_MS - if (now - lastRetryAt >= throttleMs) { - retryableFailures.push(provider) - } - } - } - - if (retryableFailures.length === 0) { - return { kind: 'none' } - } - return { kind: 'providers', providers: retryableFailures } - } - - private async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { - if (plan.kind === 'none') { - return - } - if (plan.kind === 'full') { - // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. - // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. - if (!this.isFetching) { - const now = Date.now() - for (const { provider, limits } of this.getActiveProviderState()) { - if (limits?.status === 'error') { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - } - } - await this.fetchAll() - return - } - - // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. - if (this.isFetching) { - return - } - - const now = Date.now() - for (const provider of plan.providers) { - this.lastActiveFailureRetryAtByProvider[provider] = now - } - - const canRefreshIndividually = plan.providers.every((provider) => - INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) - ) - if (!canRefreshIndividually) { - await this.fetchAll() - return - } - - // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. - if (plan.providers.includes('claude')) { - await this.fetchClaudeOnly() - } - if (plan.providers.includes('codex')) { - await this.fetchCodexOnly() - } - if (plan.providers.includes('grok')) { - await this.fetchGrokOnly() - } - } - - private async refreshIfWindowActive(): Promise { - if (!this.shouldBackgroundPoll()) { - return - } - const plan = this.getActiveWindowRefreshPlan(Date.now()) - await this.runActiveWindowRefreshPlan(plan) - } - - private async fetchAll(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.fullFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - shouldContinue = true - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchCodexOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.codexOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - shouldContinue = true - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchClaudeOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.claudeOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private async fetchGrokOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.grokOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - private waitForFetchIdle(): Promise { - if ( - !this.isFetching && - !this.fullFetchQueued && - !this.codexOnlyFetchQueued && - !this.claudeOnlyFetchQueued && - !this.grokOnlyFetchQueued - ) { - return Promise.resolve() - } - // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. - return new Promise((resolve) => { - this.fetchIdleResolvers.push(resolve) - }) - } - - private resolveFetchIdleWaiters(): void { - if ( - this.isFetching || - this.fullFetchQueued || - this.codexOnlyFetchQueued || - this.claudeOnlyFetchQueued || - this.grokOnlyFetchQueued - ) { - return - } - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private beginFetchCycle(): AbortController { - const controller = new AbortController() - this.activeFetchAbortControllers.add(controller) - return controller - } - - private finishFetchCycle(controller: AbortController): void { - this.activeFetchAbortControllers.delete(controller) - } - - private async runWithFetchAbortSignal( - fn: (signal: AbortSignal) => Promise - ): Promise { - const controller = this.beginFetchCycle() - try { - await fn(controller.signal) - return controller.signal - } finally { - this.finishFetchCycle(controller) - } - } - - private abortActiveFetchCycle(): void { - for (const controller of this.activeFetchAbortControllers) { - controller.abort() - } - this.activeFetchAbortControllers.clear() - } - - private clearQueuedFetches(): void { - this.fullFetchQueued = false - this.codexOnlyFetchQueued = false - this.claudeOnlyFetchQueued = false - this.grokOnlyFetchQueued = false - } - - private resolveAndClearFetchIdleWaiters(): void { - const resolvers = this.fetchIdleResolvers - this.fetchIdleResolvers = [] - for (const resolve of resolvers) { - resolve() - } - } - - private isSameCodexTarget( - left: NormalizedCodexAccountSelectionTarget, - right: NormalizedCodexAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private isSameClaudeTarget( - left: NormalizedClaudeAccountSelectionTarget, - right: NormalizedClaudeAccountSelectionTarget - ): boolean { - return left.runtime === right.runtime && left.wslDistro === right.wslDistro - } - - private getCodexProvenance( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null - ): string { - const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' - return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` - } - - private getMissingWslCodexHomeResult( - target: NormalizedCodexAccountSelectionTarget - ): ProviderRateLimits | null { - if (target.runtime !== 'wsl') { - return null - } - return { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, - status: 'error' - } - } - - private async fetchCodexResetResultState( - target: NormalizedCodexAccountSelectionTarget, - codexHomePath: string | null, - stateBeforeReset: RateLimitState - ): Promise { - const controller = this.beginFetchCycle() - let fresh: ProviderRateLimits - try { - fresh = await fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal: controller.signal - }) - } catch (error) { - fresh = { - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: toErrorMessage(error), - status: 'error' - } - } finally { - this.finishFetchCycle(controller) - } - - const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) - const currentCodexHome = this.resolveCodexHome(target) - // Why: a skip has no provenance to compare, so treat it as no longer active - // rather than publishing this result against the system-default lane. - const stillActive = - !currentCodexHome.skip && - this.isSameCodexTarget(this.codexFetchTarget, target) && - this.getCodexProvenance(target, currentCodexHome.homePath) === - this.getCodexProvenance(target, codexHomePath) - if (stillActive) { - // Why: this post-redemption read is newer than every Codex fetch that - // started before it, so invalidate those results before publishing it. - this.codexFetchGeneration += 1 - this.trackActiveFailureStreak('codex', fresh) - this.updateState({ - ...this.state, - codex: this.applyStalePolicy(fresh, this.state.codex) - }) - } - - // Why: the caller must receive the redeemed target even if the global UI - // switched targets while the provider mutation was in flight. - return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } - } - - private shouldAllowCodexPtyFallback(): boolean { - // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. - return process.platform !== 'win32' - } - - private shouldAllowClaudePtyFallback( - authPreparation: ClaudeRuntimeAuthPreparation | undefined - ): boolean { - // Why: Windows hidden PTY support is less reliable than host/WSL shells. - if (process.platform === 'win32') { - return false - } - // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. - return !isSystemDefaultClaudeAuth(authPreparation) - } - - private shouldAllowClaudeUsagePanelSupplement(): boolean { - // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. - return process.platform !== 'win32' - } - - private resolveMiniMaxConfig(): MiniMaxResolvedConfig { - try { - return { - config: this.miniMaxConfigResolver?.() ?? { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: null - } - } catch (error) { - // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. - return { - config: { - sessionCookie: '', - groupId: '', - models: 'general' - }, - error: toErrorMessage(error) - } - } - } - - private getMiniMaxCredentialError(message: string): ProviderRateLimits { - return { - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: message, - status: 'error', - usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } - } - } - - // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. - private isRetryAfterActive(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'error' && - limits.usageMetadata?.retryAtMs && - limits.usageMetadata.retryAtMs > Date.now() - ) - } - - // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. - private isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { - return Boolean( - limits?.status === 'ok' && - limits.usageMetadata?.source === 'live-session' && - Date.now() - limits.updatedAt < MIN_REFETCH_MS - ) - } - - private shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { - return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) - } - - private resolveClaudeFetchApply( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not - // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. - const current = this.state.claude - if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { - return current - } - return this.applyStalePolicy(fresh, previous) - } - - private rememberClaudeAuthSnapshot( - authPreparation: ClaudeRuntimeAuthPreparation | undefined, - claudeGeneration: number, - claudeTarget: NormalizedClaudeAccountSelectionTarget - ): void { - // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. - if ( - claudeGeneration !== this.claudeFetchGeneration || - !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - ) { - return - } - this.lastClaudeAuthSnapshot = { - configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), - provenance: authPreparation?.provenance ?? 'system' - } - } - - /** Live usage windows forwarded from a Claude session's statusLine command. */ - ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { - // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. - const snapshot = this.lastClaudeAuthSnapshot - if (!snapshot) { - // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. - console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { - eventConfigDir: event.configDir - }) - return - } - // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. - if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { - console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { - eventConfigDir: event.configDir, - snapshotConfigDir: snapshot.configDir - }) - return - } - const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) - const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) - if (!freshSession && !freshWeekly) { - return - } - const previous = this.state.claude - // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. - const session = freshSession ?? previous?.session ?? null - const weekly = freshWeekly ?? previous?.weekly ?? null - if ( - previous?.status === 'ok' && - previous.usageMetadata?.source === 'live-session' && - Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && - isSameUsageWindow(previous.session, session) && - isSameUsageWindow(previous.weekly, weekly) - ) { - return - } - this.activeFailureStreakByProvider.claude = 0 - this.updateState({ - ...this.state, - claude: { - provider: 'claude', - session, - weekly, - // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. - // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. - fableWeekly: previous?.fableWeekly ?? null, - updatedAt: Date.now(), - error: null, - status: 'ok', - usageMetadata: { - source: 'live-session', - lastSuccessfulSource: 'live-session', - credentialSource: previous?.usageMetadata?.credentialSource, - authProvenance: snapshot.provenance - } - } - }) - } - - private trackActiveFailureStreak( - provider: ActiveRateLimitProvider, - fresh: ProviderRateLimits - ): void { - if (fresh.status === 'error') { - this.activeFailureStreakByProvider[provider] = Math.min( - this.activeFailureStreakByProvider[provider] + 1, - MAX_ACTIVE_FAILURE_STREAK - ) - return - } - if (fresh.status === 'ok' || fresh.status === 'unavailable') { - this.activeFailureStreakByProvider[provider] = 0 - } - } - - private withFetchingStatus( - current: ProviderRateLimits | null, - provider: - | 'claude' - | 'codex' - | 'gemini' - | 'opencode-go' - | 'kimi' - | 'minimax' - | 'grok' - | 'antigravity' - ): ProviderRateLimits { - if (!current) { - return { - provider, - session: null, - weekly: null, - updatedAt: 0, - error: null, - status: 'fetching' - } - } - // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. - if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { - return current - } - return { ...current, status: 'fetching' } - } - - private async runFetchAllCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const codexTarget = this.codexFetchTarget - const previousState = this.state - // Why: a skipped Codex poll must not stop the other providers' cycle, so gate - // only the Codex slot instead of returning early (#STA-4422). - const codexHome = this.resolveCodexHome(codexTarget) - const codexFetchGated = codexHome.skip - const codexHomePath = codexHome.homePath - const codexStateBeforeFetch = - previousState.codex?.status === 'fetching' ? null : previousState.codex - const codexProvenance = codexFetchGated - ? null - : this.getCodexProvenance(codexTarget, codexHomePath) - const codexGeneration = this.codexFetchGeneration - const openCodeGoConfig = this.openCodeGoConfigResolver?.() - const cookie = openCodeGoConfig?.sessionCookie ?? '' - const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' - const miniMaxConfigResult = this.resolveMiniMaxConfig() - const miniMaxCookie = miniMaxConfigResult.config.sessionCookie - const miniMaxGroupId = miniMaxConfigResult.config.groupId - const miniMaxModels = miniMaxConfigResult.config.models - const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false - // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - // Discard stale data on config change — it belongs to a different session/workspace. - const currentConfigHash = `${cookie}|${workspaceIdOverride}` - const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash - if (opencodeConfigChanged) { - this.lastOpencodeConfigHash = currentConfigHash - this.opencodeFetchGeneration += 1 - } - const opencodeGeneration = this.opencodeFetchGeneration - - const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` - const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash - if (miniMaxConfigChanged) { - this.lastMiniMaxConfigHash = currentMiniMaxConfigHash - this.minimaxFetchGeneration += 1 - } - const miniMaxGeneration = this.minimaxFetchGeneration - - // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude'), - // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. - codex: codexFetchGated - ? codexStateBeforeFetch - : this.withFetchingStatus(previousState.codex, 'codex'), - gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), - opencodeGo: opencodeConfigChanged - ? this.withFetchingStatus(null, 'opencode-go') - : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), - kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), - antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), - minimax: miniMaxConfigChanged - ? this.withFetchingStatus(null, 'minimax') - : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const missingWslCodexHome = - codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) - const grokResultPromise = fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).then( - (value) => ({ status: 'fulfilled', value }) as const, - (reason) => ({ status: 'rejected', reason }) as const - ) - - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - const claudeFetchGated = - !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) - - if (signal.aborted) { - return - } - - const claude = - claudeResult.status === 'fulfilled' - ? claudeResult.value - : ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const codex = - codexResult.status === 'fulfilled' - ? codexResult.value - : ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const gemini = - geminiResult.status === 'fulfilled' - ? geminiResult.value - : ({ - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) - - const opencodeGo = - opencodeGoResult.status === 'fulfilled' - ? opencodeGoResult.value - : ({ - provider: 'opencode-go', - session: null, - weekly: null, - monthly: null, - updatedAt: Date.now(), - error: - opencodeGoResult.reason instanceof Error - ? opencodeGoResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const kimi = - kimiResult.status === 'fulfilled' - ? kimiResult.value - : ({ - provider: 'kimi', - session: null, - weekly: null, - updatedAt: Date.now(), - error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const miniMax = - miniMaxResult.status === 'fulfilled' - ? miniMaxResult.value - : ({ - provider: 'minimax', - session: null, - weekly: null, - updatedAt: Date.now(), - error: - miniMaxResult.reason instanceof Error - ? miniMaxResult.reason.message - : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - - const latestCodexHome = this.resolveCodexHome(codexTarget) - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - // Why: a finishing skip has no provenance, so an in-flight result must never be - // applied as though the target had become the system default (#STA-4422). - const shouldApplyCodex = - !codexFetchGated && - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - const codexBecameUnavailable = - !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration - // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. - const shouldApplyClaude = - !claudeFetchGated && - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration - const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) - if (shouldApplyOpencode) { - this.trackActiveFailureStreak('opencode-go', opencodeGo) - } - this.trackActiveFailureStreak('kimi', kimi) - if (shouldApplyMiniMax) { - this.trackActiveFailureStreak('minimax', miniMax) - } - - // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude, - codex: shouldApplyCodex - ? this.applyStalePolicy(codex, previousState.codex) - : codexBecameUnavailable - ? codexStateBeforeFetch - : this.state.codex, - gemini: this.applyStalePolicy(gemini, previousState.gemini), - opencodeGo: shouldApplyOpencode - ? opencodeConfigChanged - ? opencodeGo - : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) - : this.state.opencodeGo, - kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), - minimax: shouldApplyMiniMax - ? miniMaxConfigChanged - ? miniMax - : this.applyStalePolicy(miniMax, previousState.minimax) - : this.state.minimax - }) - - const grokResult = await grokResultPromise - if (signal.aborted) { - return - } - const grok = - grokResult.status === 'fulfilled' - ? grokResult.value - : ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', - status: 'error' - } satisfies ProviderRateLimits) - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const codexTarget = this.codexFetchTarget - const codexGeneration = this.codexFetchGeneration - const codexHome = this.resolveCodexHome(codexTarget) - // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). - if (codexHome.skip) { - if ( - codexGeneration === this.codexFetchGeneration && - this.state.codex?.status === 'fetching' - ) { - this.updateState({ ...this.state, codex: null }) - } - return - } - const codexHomePath = codexHome.homePath - const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) - const previousState = this.state - - this.updateState({ - ...previousState, - codex: this.withFetchingStatus(previousState.codex, 'codex') - }) - - const missingWslCodexHome = codexHomePath - ? null - : this.getMissingWslCodexHomeResult(codexTarget) - const codex = await ( - missingWslCodexHome - ? Promise.resolve(missingWslCodexHome) - : fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - }) - ).catch((err): ProviderRateLimits => ({ - provider: 'codex', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestCodexHome = this.resolveCodexHome(codexTarget) - if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { - this.updateState({ - ...this.state, - codex: previousState.codex?.status === 'fetching' ? null : previousState.codex - }) - return - } - const shouldApplyCodex = - !latestCodexHome.skip && - codexGeneration === this.codexFetchGeneration && - codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) - - if (shouldApplyCodex) { - this.trackActiveFailureStreak('codex', codex) - } - this.updateState({ - ...this.state, - codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex - }) - } - - private async runFetchClaudeOnlyCycle( - signal: AbortSignal, - options?: { force?: boolean } - ): Promise { - if (signal.aborted) { - return - } - // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. - if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { - return - } - const claudeTarget = this.claudeFetchTarget - // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. - const claudeGeneration = this.claudeFetchGeneration - const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) - const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' - const previousState = this.state - - this.updateState({ - ...previousState, - claude: this.withFetchingStatus(previousState.claude, 'claude') - }) - - const claude = await fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }).catch((err): ProviderRateLimits => ({ - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) - if (signal.aborted) { - return - } - const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' - const shouldApplyClaude = - claudeGeneration === this.claudeFetchGeneration && - claudeProvenance === latestClaudeProvenance && - this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) - - if (shouldApplyClaude) { - this.trackActiveFailureStreak('claude', claude) - } - this.updateState({ - ...this.state, - claude: shouldApplyClaude - ? this.resolveClaudeFetchApply(claude, previousState.claude) - : this.state.claude - }) - } - - private async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { - if (signal.aborted) { - return - } - const previousState = this.state - const grokAuthReadResult = readGrokAuthSession() - this.grokAuthConfigured = grokAuthReadResult.status === 'ok' - - this.updateState({ - ...previousState, - grok: this.withFetchingStatus(previousState.grok, 'grok') - }) - - const grok = await fetchGrokRateLimits({ - signal, - authReadResult: grokAuthReadResult - }).catch((err): ProviderRateLimits => ({ - provider: 'grok', - session: null, - weekly: null, - updatedAt: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', - status: 'error' - })) - - if (signal.aborted) { - return - } - - this.trackActiveFailureStreak('grok', grok) - this.updateState({ - ...this.state, - grok: this.applyStalePolicy(grok, previousState.grok) - }) - } - - private applyStalePolicy( - fresh: ProviderRateLimits, - previous: ProviderRateLimits | null - ): ProviderRateLimits { - // Fresh data is fine — use it - if (fresh.status === 'ok') { - return { - ...fresh, - usageMetadata: { - ...fresh.usageMetadata, - lastSuccessfulSource: - fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource - } - } - } - - // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. - if (fresh.status === 'unavailable') { - return fresh - } - - const previousHasData = Boolean( - previous?.session || - previous?.weekly || - previous?.fableWeekly || - previous?.monthly || - (previous?.buckets && previous.buckets.length > 0) - ) - - // No previous data to fall back on - if (!previous || !previousHasData) { - return fresh - } - - // Previous data is too old — don't show stale data - const staleThresholdMs = - fresh.usageMetadata?.failureKind === 'rate-limited' - ? RATE_LIMITED_STALE_THRESHOLD_MS - : STALE_THRESHOLD_MS - if (Date.now() - previous.updatedAt > staleThresholdMs) { - return fresh - } - - // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. - return { - ...previous, - error: fresh.error, - status: 'error', - usageMetadata: { - ...previous.usageMetadata, - ...fresh.usageMetadata, - lastSuccessfulSource: - previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source - } - } - } - - private buildInactiveArray( - cache: Map, - fetching: Set - ): InactiveAccountUsage[] { - const result: InactiveAccountUsage[] = [] - for (const [accountId, limits] of cache) { - result.push({ - accountId, - rateLimits: limits, - updatedAt: limits.updatedAt, - isFetching: fetching.has(accountId) - }) - } - // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. - for (const accountId of fetching) { - if (!cache.has(accountId)) { - result.push({ - accountId, - rateLimits: null, - updatedAt: 0, - isFetching: true - }) - } - } - return result - } - - private updateState(next: InternalRateLimitState): void { - this.state = next - this.pushToRenderer() - } - - private pushToRenderer(): void { - const state = this.getState() - for (const listener of this.stateListeners) { - try { - listener(state) - } catch { - // ignore — one bad listener must not break the others - } - } - if (!this.mainWindow || this.mainWindow.isDestroyed()) { - return - } - this.mainWindow.webContents.send('rateLimits:update', state) - } -} +/** + * Coordinates provider quota polling and publishes a stable rate-limit snapshot. + * The implementation is layered by lifecycle, account selection, and fetch policy + * so each module stays small while this path remains the public integration seam. + */ +export class RateLimitService extends RateLimitServiceConfiguration {} diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts new file mode 100644 index 00000000000..9e758a98518 --- /dev/null +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -0,0 +1,182 @@ +import { consumeCodexRateLimitResetCredit } from '../codex-fetcher' +import { RateLimitServiceInactiveAccounts } from './service-inactive-accounts' +import { + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type CodexAccountSelectionTarget, + type ClaudeAccountSelectionTarget, + type RateLimitRuntimeTarget, + type RateLimitState, + type CodexRateLimitResetResult +} from './service-types' + +export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceInactiveAccounts { + async refresh(): Promise { + // Why: this user-directed refresh must bypass the poll throttle, else the click can no-op after wake/focus and feel broken. + await this.fetchAll({ force: true }) + return this.getState() + } + + async refreshIfStale(): Promise { + // Why: reconnecting mobile subscribers need fresh backgrounded-desktop data, but replaying a subscription must not queue another forced fetch. + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + return this.getState() + } + + async refreshGrok(): Promise { + await this.fetchGrokOnly({ force: true }) + return this.getState() + } + + invalidateMiniMaxCredentialState(): void { + this.minimaxFetchGeneration += 1 + // Why: saving/forgetting the cookie can race an in-flight fetch; clear the visible snapshot before any old-cookie result returns. + this.updateState({ + ...this.state, + minimax: this.withFetchingStatus(null, 'minimax') + }) + } + + async refreshForCodexAccountChange( + outgoingAccountId?: string | null, + target?: CodexAccountSelectionTarget + ): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. + if ( + outgoingAccountId && + (this.state.codex?.session || this.state.codex?.weekly) && + this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + ) { + this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) + } + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.codex = 0 + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + // Why: the switch must NOT reset the inactive-fetch debounce — re-probing + // every inactive account per switch spawns codex in each credential home + // and endangers rotating refresh tokens; the switcher shows the cached + // snapshot (seeded above for the outgoing account) until the debounce ends. + // Why: clear the old Codex view immediately, else the previous account's limits show under the newly selected identity until the next poll. + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(null, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async refreshCodexForTarget(target?: CodexAccountSelectionTarget): Promise { + const nextTarget = normalizeCodexAccountSelectionTarget(target) + const targetChanged = !this.isSameCodexTarget(this.codexFetchTarget, nextTarget) + this.codexFetchTarget = nextTarget + this.codexFetchGeneration += 1 + this.activeFailureStreakByProvider.codex = 0 + this.updateState({ + ...this.state, + codex: this.withFetchingStatus(targetChanged ? null : this.state.codex, 'codex') + }) + await this.fetchCodexOnly({ force: true }) + return this.getState() + } + + async consumeCodexRateLimitResetCredit(options: { + idempotencyKey: string + target: RateLimitRuntimeTarget + codexHomePath: string | null + }): Promise { + const codexTarget = normalizeCodexAccountSelectionTarget(options.target) + const codexHomePath = options.codexHomePath + const scopedStateBeforeReset = this.getState() + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + if (missingWslCodexHome) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw new Error(missingWslCodexHome.error ?? 'Codex home unavailable') + } + try { + const outcome = await consumeCodexRateLimitResetCredit({ + codexHomePath, + idempotencyKey: options.idempotencyKey + }) + const state = await this.fetchCodexResetResultState( + codexTarget, + codexHomePath, + scopedStateBeforeReset + ) + return { outcome, state } + } catch (error) { + if (this.isSameCodexTarget(this.codexFetchTarget, codexTarget)) { + await this.fetchCodexOnly({ force: true }) + } + throw error + } + } + + async refreshForClaudeAccountChange( + outgoingAccountId?: string | null, + target?: ClaudeAccountSelectionTarget + ): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + // Why: snapshot the outgoing account's usage before clearing so the switcher's inline bars can show last-known data immediately. + if ( + outgoingAccountId && + this.state.claude?.session && + this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + ) { + this.inactiveClaudeCache.set(outgoingAccountId, this.state.claude) + } + this.claudeFetchTarget = nextTarget + this.inactiveClaudeAccountsGeneration += 1 + this.pruneInactiveClaudeState() + this.claudeFetchGeneration += 1 + // Why: a new account/target starts with a clean retry schedule. + this.activeFailureStreakByProvider.claude = 0 + // Why: statusline posts from the outgoing account's sessions must not land on the incoming account's bar mid-switch. + this.lastClaudeAuthSnapshot = null + this.lastInactiveClaudeFetchAt = 0 + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(null, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshClaudeForTarget(target?: ClaudeAccountSelectionTarget): Promise { + const nextTarget = normalizeClaudeAccountSelectionTarget(target) + const targetChanged = !this.isSameClaudeTarget(this.claudeFetchTarget, nextTarget) + this.claudeFetchTarget = nextTarget + this.claudeFetchGeneration += 1 + this.activeFailureStreakByProvider.claude = 0 + if (targetChanged) { + // Why: statusline posts from the outgoing target's sessions must not land on the incoming target's bar mid-switch. + this.lastClaudeAuthSnapshot = null + } + this.updateState({ + ...this.state, + claude: this.withFetchingStatus(targetChanged ? null : this.state.claude, 'claude') + }) + await this.fetchClaudeOnly({ force: true }) + return this.getState() + } + + async refreshAfterClaudeLivePtysDrained(): Promise { + // Why: "Waiting for Claude session" can only recover once no live claude + // owns the credentials. Refetch on the last PTY exit instead of leaving + // the stale terminal error up until the failure backoff elapses. + if (!this.state.claude?.usageMetadata?.deferredByLiveClaudeSession) { + return + } + this.activeFailureStreakByProvider.claude = 0 + await this.fetchClaudeOnly({ force: true }) + } +} diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts new file mode 100644 index 00000000000..52aa02ccddd --- /dev/null +++ b/src/main/rate-limits/service/service-configuration.ts @@ -0,0 +1,139 @@ +import type { BrowserWindow } from 'electron' +import { hasMiniMaxSessionCookie } from '../../minimax/minimax-cookie-store' +import { RateLimitServiceAccountRefresh } from './service-account-refresh' +import { + type CodexAccountSelectionTarget, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAccountSelectionTarget, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type InactiveCodexAccountInfo, + type InactiveClaudeAccountInfo, + type RateLimitState, + normalizeCodexAccountSelectionTarget, + normalizeClaudeAccountSelectionTarget, + type NetworkProxySettings +} from './service-types' + +export abstract class RateLimitServiceConfiguration extends RateLimitServiceAccountRefresh { + setCodexHomePathResolver(resolver: CodexHomePathResolver): void { + this.codexHomePathResolver = resolver + } + + setCodexFetchTarget(target?: CodexAccountSelectionTarget): void { + this.codexFetchTarget = normalizeCodexAccountSelectionTarget(target) + } + + setKimiHomeResolver(resolver: KimiHomeResolver): void { + this.kimiHomeResolver = resolver + } + + setClaudeAuthPreparationResolver(resolver: ClaudeAuthPreparationResolver): void { + this.claudeAuthPreparationResolver = resolver + } + + setClaudeFetchTarget(target?: ClaudeAccountSelectionTarget): void { + this.claudeFetchTarget = normalizeClaudeAccountSelectionTarget(target) + } + + setOpenCodeGoConfigResolver(resolver: () => OpenCodeGoRateLimitConfig): void { + this.openCodeGoConfigResolver = resolver + } + + setMiniMaxConfigResolver(resolver: () => MiniMaxRateLimitConfig): void { + this.miniMaxConfigResolver = resolver + } + + setGeminiCliOAuthEnabledResolver(resolver: GeminiCliOAuthEnabledResolver): void { + this.geminiCliOAuthEnabledResolver = resolver + } + + setNetworkProxySettingsResolver(resolver: () => NetworkProxySettings): void { + this.networkProxySettingsResolver = resolver + } + + setInactiveClaudeAccountsResolver(resolver: () => InactiveClaudeAccountInfo[]): void { + this.inactiveClaudeAccountsResolver = resolver + this.inactiveClaudeAccountsGeneration += 1 + } + + setInactiveCodexAccountsResolver(resolver: () => InactiveCodexAccountInfo[]): void { + this.inactiveCodexAccountsResolver = resolver + this.inactiveCodexAccountsGeneration += 1 + this.pruneInactiveCodexState() + } + attach(mainWindow: BrowserWindow): void { + this.detachWindowListeners?.() + this.mainWindow = mainWindow + const refreshOnResume = (): void => { + void this.refreshIfWindowActive() + } + // Why: attach() can replace windows; remove the previous closed listener too, not only the focus listeners. + const detachWindowListeners = (): void => { + mainWindow.removeListener('focus', refreshOnResume) + mainWindow.removeListener('show', refreshOnResume) + mainWindow.removeListener('restore', refreshOnResume) + mainWindow.removeListener('closed', onClosed) + } + const onClosed = (): void => { + detachWindowListeners() + if (this.detachWindowListeners === detachWindowListeners) { + this.detachWindowListeners = null + } + if (this.mainWindow === mainWindow) { + this.mainWindow = null + } + } + mainWindow.on('focus', refreshOnResume) + mainWindow.on('show', refreshOnResume) + mainWindow.on('restore', refreshOnResume) + mainWindow.on('closed', onClosed) + this.detachWindowListeners = detachWindowListeners + } + + start(options: { fetchImmediately?: boolean } = {}): void { + if (options.fetchImmediately !== false) { + void this.fetchAll() + } else { + this.scheduleDeferredStartupRefresh() + } + this.startTimer() + } + + stop(): void { + this.abortActiveFetchCycle() + this.clearQueuedFetches() + this.inactiveClaudeFetching.clear() + this.inactiveCodexFetching.clear() + this.resolveAndClearFetchIdleWaiters() + this.stopTimer() + this.clearDeferredStartupRefresh() + this.detachWindowListeners?.() + this.detachWindowListeners = null + this.mainWindow = null + } + + getState(): RateLimitState { + this.pruneInactiveClaudeState() + this.pruneInactiveCodexState() + return { + ...this.state, + // Why: the cookie lives on the filesystem, not GlobalSettings; surface its presence so the renderer keeps the MiniMax bar across reloads. + minimaxCookieConfigured: hasMiniMaxSessionCookie(), + grokAuthConfigured: this.grokAuthConfigured, + claudeTarget: this.claudeFetchTarget, + codexTarget: this.codexFetchTarget, + inactiveClaudeAccounts: this.buildInactiveArray( + this.inactiveClaudeCache, + this.inactiveClaudeFetching + ), + inactiveCodexAccounts: this.buildInactiveArray( + this.inactiveCodexCache, + this.inactiveCodexFetching + ) + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-control.ts b/src/main/rate-limits/service/service-fetch-control.ts new file mode 100644 index 00000000000..cab01551def --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-control.ts @@ -0,0 +1,80 @@ +import { RateLimitServiceState } from './service-state' + +export abstract class RateLimitServiceFetchControl extends RateLimitServiceState { + protected waitForFetchIdle(): Promise { + if ( + !this.isFetching && + !this.fullFetchQueued && + !this.codexOnlyFetchQueued && + !this.claudeOnlyFetchQueued && + !this.grokOnlyFetchQueued + ) { + return Promise.resolve() + } + // Why: explicit-refresh callers must await the queued follow-up cycle when a poll is in flight, else the UI stops spinning early. + return new Promise((resolve) => { + this.fetchIdleResolvers.push(resolve) + }) + } + + protected resolveFetchIdleWaiters(): void { + if ( + this.isFetching || + this.fullFetchQueued || + this.codexOnlyFetchQueued || + this.claudeOnlyFetchQueued || + this.grokOnlyFetchQueued + ) { + return + } + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } + + protected beginFetchCycle(): AbortController { + const controller = new AbortController() + this.activeFetchAbortControllers.add(controller) + return controller + } + + protected finishFetchCycle(controller: AbortController): void { + this.activeFetchAbortControllers.delete(controller) + } + + protected async runWithFetchAbortSignal( + fn: (signal: AbortSignal) => Promise + ): Promise { + const controller = this.beginFetchCycle() + try { + await fn(controller.signal) + return controller.signal + } finally { + this.finishFetchCycle(controller) + } + } + + protected abortActiveFetchCycle(): void { + for (const controller of this.activeFetchAbortControllers) { + controller.abort() + } + this.activeFetchAbortControllers.clear() + } + + protected clearQueuedFetches(): void { + this.fullFetchQueued = false + this.codexOnlyFetchQueued = false + this.claudeOnlyFetchQueued = false + this.grokOnlyFetchQueued = false + } + + protected resolveAndClearFetchIdleWaiters(): void { + const resolvers = this.fetchIdleResolvers + this.fetchIdleResolvers = [] + for (const resolve of resolvers) { + resolve() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-policy.ts b/src/main/rate-limits/service/service-fetch-policy.ts new file mode 100644 index 00000000000..2c28c519a14 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-policy.ts @@ -0,0 +1,139 @@ +import { RateLimitServiceFetchTargets } from './service-fetch-targets' +import { + LIVE_CLAUDE_INGEST_DEDUPE_MS, + MIN_REFETCH_MS, + isSameUsageWindow, + normalizeClaudeConfigDir, + type ClaudeRuntimeAuthPreparation, + type ClaudeStatusLineRateLimits, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits +} from './service-types' +import { mapClaudeUsageWindow } from '../claude-usage-window' + +export abstract class RateLimitServiceFetchPolicy extends RateLimitServiceFetchTargets { + protected getMiniMaxCredentialError(message: string): ProviderRateLimits { + return { + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: message, + status: 'error', + usageMetadata: { failureKind: 'keychain-unavailable', source: 'web' } + } + } + + // Why: hitting a usage endpoint before its Retry-After expires burns the budget for nothing and keeps the 429 window alive. + protected isRetryAfterActive(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'error' && + limits.usageMetadata?.retryAtMs && + limits.usageMetadata.retryAtMs > Date.now() + ) + } + + // Why: a live Claude session already streams fresh usage windows; spending the OAuth usage endpoint's tight budget on the same data invites 429s. + protected isLiveClaudeUsageFresh(limits: ProviderRateLimits | null): boolean { + return Boolean( + limits?.status === 'ok' && + limits.usageMetadata?.source === 'live-session' && + Date.now() - limits.updatedAt < MIN_REFETCH_MS + ) + } + + protected shouldSkipAutomatedClaudeFetch(limits: ProviderRateLimits | null): boolean { + return this.isRetryAfterActive(limits) || this.isLiveClaudeUsageFresh(limits) + } + + protected resolveClaudeFetchApply( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Why: a live statusline post can land while an OAuth cycle is in flight; a failed fetch must not + // roll the bar back to the pre-cycle snapshot or flip the just-refreshed live data to error. + const current = this.state.claude + if (fresh.status !== 'ok' && current && this.isLiveClaudeUsageFresh(current)) { + return current + } + return this.applyStalePolicy(fresh, previous) + } + + protected rememberClaudeAuthSnapshot( + authPreparation: ClaudeRuntimeAuthPreparation | undefined, + claudeGeneration: number, + claudeTarget: NormalizedClaudeAccountSelectionTarget + ): void { + // Why: an account switch during the resolver await already cleared the snapshot; restoring the outgoing account's configDir here would cross-attribute its live posts to the new bar. + if ( + claudeGeneration !== this.claudeFetchGeneration || + !this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + ) { + return + } + this.lastClaudeAuthSnapshot = { + configDir: normalizeClaudeConfigDir(authPreparation?.envPatch.CLAUDE_CONFIG_DIR), + provenance: authPreparation?.provenance ?? 'system' + } + } + + /** Live usage windows forwarded from a Claude session's statusLine command. */ + ingestLiveClaudeRateLimits(event: ClaudeStatusLineRateLimits): void { + // Why: attribution needs the selected account's config dir; until a fetch cycle captures it, drop posts rather than guess the account. + const snapshot = this.lastClaudeAuthSnapshot + if (!snapshot) { + // Why: breadcrumbs make a silently dark live feed diagnosable — dropped posts are otherwise invisible. + console.debug('[rate-limits] dropped live Claude usage: no auth snapshot yet', { + eventConfigDir: event.configDir + }) + return + } + // Why: sessions of other accounts (or other runtimes) report their own quota; mixing them into the active account's bar would lie. + if (normalizeClaudeConfigDir(event.configDir) !== snapshot.configDir) { + console.debug('[rate-limits] dropped live Claude usage: configDir mismatch', { + eventConfigDir: event.configDir, + snapshotConfigDir: snapshot.configDir + }) + return + } + const freshSession = mapClaudeUsageWindow(event.fiveHour ?? undefined, 300) + const freshWeekly = mapClaudeUsageWindow(event.sevenDay ?? undefined, 10080) + if (!freshSession && !freshWeekly) { + return + } + const previous = this.state.claude + // Why: statusline payloads can carry a single window; an absent one means "no update", not "cleared" — keep the other bar populated. + const session = freshSession ?? previous?.session ?? null + const weekly = freshWeekly ?? previous?.weekly ?? null + if ( + previous?.status === 'ok' && + previous.usageMetadata?.source === 'live-session' && + Date.now() - previous.updatedAt < LIVE_CLAUDE_INGEST_DEDUPE_MS && + isSameUsageWindow(previous.session, session) && + isSameUsageWindow(previous.weekly, weekly) + ) { + return + } + this.activeFailureStreakByProvider.claude = 0 + this.updateState({ + ...this.state, + claude: { + provider: 'claude', + session, + weekly, + // Why: the statusline payload has no Fable scoped window; keep the last OAuth-provided one visible. + // Tradeoff: while live posts keep the OAuth poll gated, fableWeekly stays frozen until the session idles past the freshness window. + fableWeekly: previous?.fableWeekly ?? null, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'live-session', + lastSuccessfulSource: 'live-session', + credentialSource: previous?.usageMetadata?.credentialSource, + authProvenance: snapshot.provenance + } + } + }) + } +} diff --git a/src/main/rate-limits/service/service-fetch-queue.ts b/src/main/rate-limits/service/service-fetch-queue.ts new file mode 100644 index 00000000000..49531ee3fb1 --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-queue.ts @@ -0,0 +1,245 @@ +import { RateLimitServiceProviderCycles } from './service-provider-cycles' + +export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles { + protected async fetchAll(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.fullFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + shouldContinue = true + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchCodexOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.codexOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + shouldContinue = true + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchClaudeOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.claudeOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. + let cycleForce = options?.force ?? false + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) + ) + shouldContinue = false + cycleForce = true + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + if (grokSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + protected async fetchGrokOnly(options?: { force?: boolean }): Promise { + if (this.isFetching) { + if (options?.force) { + this.grokOnlyFetchQueued = true + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + + try { + let shouldContinue = true + while (shouldContinue) { + const signal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchGrokOnlyCycle(fetchSignal) + ) + shouldContinue = false + if (signal.aborted) { + break + } + if (this.fullFetchQueued) { + this.fullFetchQueued = false + const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchAllCycle(fetchSignal, { force: true }) + ) + if (fullSignal.aborted) { + break + } + continue + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + shouldContinue = true + } + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchCodexOnlyCycle(fetchSignal) + ) + if (codexSignal.aborted) { + break + } + } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => + this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) + ) + if (claudeSignal.aborted) { + break + } + } + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } +} diff --git a/src/main/rate-limits/service/service-fetch-targets.ts b/src/main/rate-limits/service/service-fetch-targets.ts new file mode 100644 index 00000000000..2d316294dbc --- /dev/null +++ b/src/main/rate-limits/service/service-fetch-targets.ts @@ -0,0 +1,171 @@ +import { RateLimitServiceResultPolicy } from './service-result-policy' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchKimiRateLimits } from '../kimi-fetcher' +import { + isSystemDefaultClaudeAuth, + type ClaudeRuntimeAuthPreparation, + type CodexAccountSelectionTarget, + type MiniMaxResolvedConfig, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type ProviderRateLimits, + type RateLimitState, + toErrorMessage +} from './service-types' + +export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResultPolicy { + protected resolveCodexHome(target?: CodexAccountSelectionTarget): { + skip: boolean + homePath: string | null + } { + const resolution = this.codexHomePathResolver?.(target) + if (!resolution) { + return { skip: false, homePath: null } + } + return resolution.kind === 'skip' + ? { skip: true, homePath: null } + : { skip: false, homePath: resolution.codexHomePath } + } + + // Why: resolving a WSL home probes wsl.exe, so it must not run before the other + // providers' fetches are started; chaining keeps the no-resolver path immediate. + protected fetchKimiWithResolvedHome(): Promise { + const pendingHome = this.kimiHomeResolver?.() + return pendingHome + ? pendingHome.then((home) => fetchKimiRateLimits({ home })) + : fetchKimiRateLimits({ home: undefined }) + } + + protected isSameCodexTarget( + left: NormalizedCodexAccountSelectionTarget, + right: NormalizedCodexAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected isSameClaudeTarget( + left: NormalizedClaudeAccountSelectionTarget, + right: NormalizedClaudeAccountSelectionTarget + ): boolean { + return left.runtime === right.runtime && left.wslDistro === right.wslDistro + } + + protected getCodexProvenance( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null + ): string { + const targetKey = target.runtime === 'wsl' ? `wsl:${target.wslDistro ?? '__default__'}` : 'host' + return codexHomePath ? `${targetKey}:managed:${codexHomePath}` : `${targetKey}:system` + } + + protected getMissingWslCodexHomeResult( + target: NormalizedCodexAccountSelectionTarget + ): ProviderRateLimits | null { + if (target.runtime !== 'wsl') { + return null + } + return { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: `WSL Codex home unavailable for ${target.wslDistro ?? 'default distro'}`, + status: 'error' + } + } + + protected async fetchCodexResetResultState( + target: NormalizedCodexAccountSelectionTarget, + codexHomePath: string | null, + stateBeforeReset: RateLimitState + ): Promise { + const controller = this.beginFetchCycle() + let fresh: ProviderRateLimits + try { + fresh = await fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal: controller.signal + }) + } catch (error) { + fresh = { + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: toErrorMessage(error), + status: 'error' + } + } finally { + this.finishFetchCycle(controller) + } + + const scopedCodex = this.applyStalePolicy(fresh, stateBeforeReset.codex) + const currentCodexHome = this.resolveCodexHome(target) + // Why: a skip has no provenance to compare, so treat it as no longer active + // rather than publishing this result against the system-default lane. + const stillActive = + !currentCodexHome.skip && + this.isSameCodexTarget(this.codexFetchTarget, target) && + this.getCodexProvenance(target, currentCodexHome.homePath) === + this.getCodexProvenance(target, codexHomePath) + if (stillActive) { + // Why: this post-redemption read is newer than every Codex fetch that + // started before it, so invalidate those results before publishing it. + this.codexFetchGeneration += 1 + this.trackActiveFailureStreak('codex', fresh) + this.updateState({ + ...this.state, + codex: this.applyStalePolicy(fresh, this.state.codex) + }) + } + + // Why: the caller must receive the redeemed target even if the global UI + // switched targets while the provider mutation was in flight. + return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target } + } + + protected shouldAllowCodexPtyFallback(): boolean { + // Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh. + return process.platform !== 'win32' + } + + protected shouldAllowClaudePtyFallback( + authPreparation: ClaudeRuntimeAuthPreparation | undefined + ): boolean { + // Why: Windows hidden PTY support is less reliable than host/WSL shells. + if (process.platform === 'win32') { + return false + } + // Why: system-default Claude isn't Orca-managed; refresh may read existing OAuth but must not launch Claude and trigger auth/browser flows. + return !isSystemDefaultClaudeAuth(authPreparation) + } + + protected shouldAllowClaudeUsagePanelSupplement(): boolean { + // Why: keep this supplement off on Windows where hidden PTYs are still less reliable. + return process.platform !== 'win32' + } + + protected resolveMiniMaxConfig(): MiniMaxResolvedConfig { + try { + return { + config: this.miniMaxConfigResolver?.() ?? { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: null + } + } catch (error) { + // Why: one unreadable cookie must not abort every provider's refresh; surface it as MiniMax-only state instead. + return { + config: { + sessionCookie: '', + groupId: '', + models: 'general' + }, + error: toErrorMessage(error) + } + } + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts new file mode 100644 index 00000000000..7104face38f --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -0,0 +1,215 @@ +import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' +import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { + protected async runFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + const prepared = await this.prepareFetchAllCycle(signal, options) + if (!prepared) { + return + } + const { + claudeTarget, + claudeGeneration, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } = prepared + if (signal.aborted) { + return + } + + const claude = + claudeResult.status === 'fulfilled' + ? claudeResult.value + : ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + claudeResult.reason instanceof Error ? claudeResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const codex = + codexResult.status === 'fulfilled' + ? codexResult.value + : ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + codexResult.reason instanceof Error ? codexResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const gemini = + geminiResult.status === 'fulfilled' + ? geminiResult.value + : ({ + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + geminiResult.reason instanceof Error ? geminiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + const antigravity = deriveAntigravityRateLimits(gemini) + + const opencodeGo = + opencodeGoResult.status === 'fulfilled' + ? opencodeGoResult.value + : ({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + updatedAt: Date.now(), + error: + opencodeGoResult.reason instanceof Error + ? opencodeGoResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const kimi = + kimiResult.status === 'fulfilled' + ? kimiResult.value + : ({ + provider: 'kimi', + session: null, + weekly: null, + updatedAt: Date.now(), + error: kimiResult.reason instanceof Error ? kimiResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const miniMax = + miniMaxResult.status === 'fulfilled' + ? miniMaxResult.value + : ({ + provider: 'minimax', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + miniMaxResult.reason instanceof Error + ? miniMaxResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + + const latestCodexHome = this.resolveCodexHome(codexTarget) + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + // Why: a finishing skip has no provenance, so an in-flight result must never be + // applied as though the target had become the system default (#STA-4422). + const shouldApplyCodex = + !codexFetchGated && + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + const codexBecameUnavailable = + !codexFetchGated && latestCodexHome.skip && codexGeneration === this.codexFetchGeneration + // Why: a gated cycle made no Claude attempt; applying its passthrough result would grow the failure streak and reset stale-policy clocks for free. + const shouldApplyClaude = + !claudeFetchGated && + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + const shouldApplyOpencode = opencodeGeneration === this.opencodeFetchGeneration + const shouldApplyMiniMax = miniMaxGeneration === this.minimaxFetchGeneration + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.trackActiveFailureStreak('gemini', gemini) + this.trackActiveFailureStreak('antigravity', antigravity) + if (shouldApplyOpencode) { + this.trackActiveFailureStreak('opencode-go', opencodeGo) + } + this.trackActiveFailureStreak('kimi', kimi) + if (shouldApplyMiniMax) { + this.trackActiveFailureStreak('minimax', miniMax) + } + + // Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account. + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude, + codex: shouldApplyCodex + ? this.applyStalePolicy(codex, previousState.codex) + : codexBecameUnavailable + ? codexStateBeforeFetch + : this.state.codex, + gemini: this.applyStalePolicy(gemini, previousState.gemini), + opencodeGo: shouldApplyOpencode + ? opencodeConfigChanged + ? opencodeGo + : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) + : this.state.opencodeGo, + kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), + minimax: shouldApplyMiniMax + ? miniMaxConfigChanged + ? miniMax + : this.applyStalePolicy(miniMax, previousState.minimax) + : this.state.minimax + }) + + const grokResult = await grokResultPromise + if (signal.aborted) { + return + } + const grok = + grokResult.status === 'fulfilled' + ? grokResult.value + : ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: grokResult.reason instanceof Error ? grokResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts new file mode 100644 index 00000000000..1bbf3bf497d --- /dev/null +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -0,0 +1,204 @@ +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import { fetchMiniMaxRateLimits } from '../minimax-fetcher' +import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' +import { RateLimitServiceFetchPolicy } from './service-fetch-policy' +import type { + ClaudeRuntimeAuthPreparation, + InternalRateLimitState, + NormalizedClaudeAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget, + ProviderRateLimits +} from './service-types' + +export type FetchAllCyclePrepared = { + claudeTarget: NormalizedClaudeAccountSelectionTarget + claudeGeneration: number + claudeAuthPreparation: ClaudeRuntimeAuthPreparation | undefined + claudeProvenance: string + codexTarget: NormalizedCodexAccountSelectionTarget + previousState: InternalRateLimitState + codexFetchGated: boolean + codexStateBeforeFetch: ProviderRateLimits | null + codexProvenance: string | null + codexGeneration: number + opencodeConfigChanged: boolean + opencodeGeneration: number + miniMaxConfigChanged: boolean + miniMaxGeneration: number + claudeFetchGated: boolean + results: [ + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult, + PromiseSettledResult + ] + grokResultPromise: Promise< + { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } + > +} + +export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { + protected async prepareFetchAllCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return null + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return null + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const codexTarget = this.codexFetchTarget + const previousState = this.state + // Why: a skipped Codex poll must not stop the other providers' cycle, so gate + // only the Codex slot instead of returning early (#STA-4422). + const codexHome = this.resolveCodexHome(codexTarget) + const codexFetchGated = codexHome.skip + const codexHomePath = codexHome.homePath + const codexStateBeforeFetch = + previousState.codex?.status === 'fetching' ? null : previousState.codex + const codexProvenance = codexFetchGated + ? null + : this.getCodexProvenance(codexTarget, codexHomePath) + const codexGeneration = this.codexFetchGeneration + const openCodeGoConfig = this.openCodeGoConfigResolver?.() + const cookie = openCodeGoConfig?.sessionCookie ?? '' + const workspaceIdOverride = openCodeGoConfig?.workspaceIdOverride ?? '' + const miniMaxConfigResult = this.resolveMiniMaxConfig() + const miniMaxCookie = miniMaxConfigResult.config.sessionCookie + const miniMaxGroupId = miniMaxConfigResult.config.groupId + const miniMaxModels = miniMaxConfigResult.config.models + const geminiCliOAuthEnabled = this.geminiCliOAuthEnabledResolver?.() ?? false + // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + // Discard stale data on config change — it belongs to a different session/workspace. + const currentConfigHash = `${cookie}|${workspaceIdOverride}` + const opencodeConfigChanged = currentConfigHash !== this.lastOpencodeConfigHash + if (opencodeConfigChanged) { + this.lastOpencodeConfigHash = currentConfigHash + this.opencodeFetchGeneration += 1 + } + const opencodeGeneration = this.opencodeFetchGeneration + + const currentMiniMaxConfigHash = `${miniMaxCookie}|${miniMaxGroupId}|${miniMaxModels}|${miniMaxConfigResult.error ?? ''}` + const miniMaxConfigChanged = currentMiniMaxConfigHash !== this.lastMiniMaxConfigHash + if (miniMaxConfigChanged) { + this.lastMiniMaxConfigHash = currentMiniMaxConfigHash + this.minimaxFetchGeneration += 1 + } + const miniMaxGeneration = this.minimaxFetchGeneration + + // Mark all providers fetching while keeping previous data visible (Codex is cleared separately on account change). + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude'), + // Why: a gated Codex cycle makes no attempt; a "fetching" chip would never settle. + codex: codexFetchGated + ? codexStateBeforeFetch + : this.withFetchingStatus(previousState.codex, 'codex'), + gemini: this.withFetchingStatus(previousState.gemini, 'gemini'), + opencodeGo: opencodeConfigChanged + ? this.withFetchingStatus(null, 'opencode-go') + : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), + kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), + minimax: miniMaxConfigChanged + ? this.withFetchingStatus(null, 'minimax') + : this.withFetchingStatus(previousState.minimax, 'minimax'), + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const missingWslCodexHome = + codexFetchGated || codexHomePath ? null : this.getMissingWslCodexHomeResult(codexTarget) + const grokResultPromise = fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + const claudeFetchGated = + !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) + + const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = + await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels + }) + ]) + + if (signal.aborted) { + return null + } + return { + claudeTarget, + claudeGeneration, + claudeAuthPreparation, + claudeProvenance, + codexTarget, + previousState, + codexFetchGated, + codexStateBeforeFetch, + codexProvenance, + codexGeneration, + opencodeConfigChanged, + opencodeGeneration, + miniMaxConfigChanged, + miniMaxGeneration, + claudeFetchGated, + results: [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult + ], + grokResultPromise + } + } +} diff --git a/src/main/rate-limits/service/service-inactive-accounts.ts b/src/main/rate-limits/service/service-inactive-accounts.ts new file mode 100644 index 00000000000..5d977991af4 --- /dev/null +++ b/src/main/rate-limits/service/service-inactive-accounts.ts @@ -0,0 +1,246 @@ +import { fetchManagedAccountUsage } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { RateLimitServicePolling } from './service-polling' +import { + INACTIVE_CODEX_PROBE_STAGGER_MS, + INACTIVE_FETCH_DEBOUNCE_MS, + delayUnlessAborted +} from './service-types' + +export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling { + async fetchInactiveClaudeAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveClaudeFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveClaudeState() + if (this.inactiveClaudeFetching.size > 0) { + return + } + const accounts = this.inactiveClaudeAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + const fetchGeneration = this.inactiveClaudeAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + + for (const account of accounts) { + this.inactiveClaudeFetching.add(account.id) + } + this.pushToRenderer() + + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + try { + const fresh = await fetchManagedAccountUsage(account, { + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeFetching.delete(account.id) + if (!this.isCurrentInactiveClaudeAccount(account.id)) { + this.inactiveClaudeCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveClaudeCache.get(account.id) ?? null + this.inactiveClaudeCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch keeps one Keychain/network error from aborting the remaining accounts in the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveClaudeAccountsGeneration || + !this.isCurrentInactiveClaudeAccount(account.id) + ) { + this.inactiveClaudeCache.delete(account.id) + } + } + this.inactiveClaudeFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveClaudeAccountsGeneration) { + this.lastInactiveClaudeFetchAt = Date.now() + } + } finally { + this.finishFetchCycle(controller) + } + } + + async fetchInactiveCodexAccountsOnOpen(): Promise { + if (Date.now() - this.lastInactiveCodexFetchAt < INACTIVE_FETCH_DEBOUNCE_MS) { + return + } + this.pruneInactiveCodexState() + if (this.inactiveCodexFetchInFlight) { + return + } + const accounts = this.inactiveCodexAccountsResolver?.() ?? [] + if (accounts.length === 0) { + return + } + // Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results. + const fetchGeneration = this.inactiveCodexAccountsGeneration + const controller = this.beginFetchCycle() + const signal = controller.signal + this.inactiveCodexFetchInFlight = true + + let staggerNextProbe = false + try { + for (const account of accounts) { + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + if (staggerNextProbe) { + await delayUnlessAborted(INACTIVE_CODEX_PROBE_STAGGER_MS, signal) + // Why: the account set can change while the stagger delay runs. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + } + const home = account.resolveHome() + if (home.kind === 'skip') { + continue + } + staggerNextProbe = true + this.inactiveCodexFetching.add(account.id) + this.pushToRenderer() + try { + // Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location. + // Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough. + const fresh = await fetchCodexRateLimits({ + codexHomePath: home.managedHomePath, + allowPtyFallback: false, + signal + }) + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexFetching.delete(account.id) + if (!this.isCurrentInactiveCodexAccount(account.id)) { + this.inactiveCodexCache.delete(account.id) + } + this.pushToRenderer() + continue + } + const cached = this.inactiveCodexCache.get(account.id) ?? null + this.inactiveCodexCache.set(account.id, this.applyStalePolicy(fresh, cached)) + } catch { + // Why: per-account try/catch prevents one failure from aborting the batch. + if ( + signal.aborted || + fetchGeneration !== this.inactiveCodexAccountsGeneration || + !this.isCurrentInactiveCodexAccount(account.id) + ) { + this.inactiveCodexCache.delete(account.id) + } + } + this.inactiveCodexFetching.delete(account.id) + this.pushToRenderer() + } + + if (!signal.aborted && fetchGeneration === this.inactiveCodexAccountsGeneration) { + this.lastInactiveCodexFetchAt = Date.now() + } + } finally { + this.inactiveCodexFetchInFlight = false + this.finishFetchCycle(controller) + } + } + + evictInactiveClaudeCache(accountId: string): void { + this.inactiveClaudeAccountsGeneration += 1 + this.inactiveClaudeCache.delete(accountId) + this.inactiveClaudeFetching.delete(accountId) + this.pushToRenderer() + } + + protected isCurrentInactiveClaudeAccount(accountId: string): boolean { + return (this.inactiveClaudeAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected isCurrentInactiveCodexAccount(accountId: string): boolean { + return (this.inactiveCodexAccountsResolver?.() ?? []).some( + (account) => account.id === accountId + ) + } + + protected pruneInactiveClaudeState(): void { + const currentIds = new Set( + (this.inactiveClaudeAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveClaudeCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeCache.delete(accountId) + } + } + for (const accountId of this.inactiveClaudeFetching) { + if (!currentIds.has(accountId)) { + this.inactiveClaudeFetching.delete(accountId) + } + } + } + + protected pruneInactiveCodexState(): void { + const currentIds = new Set( + (this.inactiveCodexAccountsResolver?.() ?? []).map((account) => account.id) + ) + for (const accountId of this.inactiveCodexCache.keys()) { + if (!currentIds.has(accountId)) { + this.inactiveCodexCache.delete(accountId) + } + } + for (const accountId of this.inactiveCodexFetching) { + if (!currentIds.has(accountId)) { + this.inactiveCodexFetching.delete(accountId) + } + } + } + + evictInactiveCodexCache(accountId: string): void { + // Why: clear only this account, not the generation — bumping it would discard sibling fetches still in flight and their fresh results. + this.inactiveCodexCache.delete(accountId) + this.inactiveCodexFetching.delete(accountId) + this.pushToRenderer() + } +} diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts new file mode 100644 index 00000000000..c861726cc52 --- /dev/null +++ b/src/main/rate-limits/service/service-polling.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFetchQueue } from './service-fetch-queue' +import { + ACTIVE_FAILURE_REFETCH_MS, + DEFERRED_STARTUP_ACTIVE_REFRESH_MS, + INDIVIDUALLY_REFRESHABLE_PROVIDERS, + MAX_ACTIVE_FAILURE_REFETCH_MS, + MIN_REFETCH_MS, + normalizePollingInterval, + type ActiveProviderState, + type ActiveRateLimitProvider, + type ActiveWindowRefreshPlan, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue { + setPollingInterval(ms: number): void { + this.pollInterval = normalizePollingInterval(ms) + if (this.timer) { + this.stopTimer() + this.startTimer() + } + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + protected startTimer(): void { + this.stopTimer() + this.timer = setInterval(() => { + if (!this.shouldBackgroundPoll()) { + return + } + void this.fetchAll() + }, this.pollInterval) + } + + protected stopTimer(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + protected scheduleDeferredStartupRefresh(): void { + this.clearDeferredStartupRefresh() + this.deferredStartupRefreshTimer = setTimeout(() => { + this.deferredStartupRefreshTimer = null + void this.refreshIfWindowActive() + }, DEFERRED_STARTUP_ACTIVE_REFRESH_MS) + } + + protected clearDeferredStartupRefresh(): void { + if (this.deferredStartupRefreshTimer) { + clearTimeout(this.deferredStartupRefreshTimer) + this.deferredStartupRefreshTimer = null + } + } + + protected shouldBackgroundPoll(): boolean { + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return false + } + // Why: these fetches only power in-app UI; skip polling when hidden/minimized/unfocused to save CLI/API budget (refresh on activate). + if (!this.mainWindow.isVisible() || this.mainWindow.isMinimized()) { + return false + } + return this.mainWindow.isFocused() + } + + protected getActiveProviderState(): ActiveProviderState[] { + // Why: key by provider so a new provider is compile-forced an entry — a missing one silently never recovers from a startup error. + const byProvider: Record = { + claude: this.state.claude, + codex: this.state.codex, + gemini: this.state.gemini, + 'opencode-go': this.state.opencodeGo, + kimi: this.state.kimi, + minimax: this.state.minimax, + grok: this.state.grok, + antigravity: this.state.antigravity + } + return Object.entries(byProvider).map(([provider, limits]) => ({ + provider: provider as ActiveRateLimitProvider, + limits + })) + } + + protected getActiveWindowRefreshPlan(now: number): ActiveWindowRefreshPlan { + const retryableFailures: ActiveRateLimitProvider[] = [] + for (const { provider, limits } of this.getActiveProviderState()) { + if (!limits || limits.status === 'idle' || limits.status === 'fetching') { + return { kind: 'full' } + } + if (limits.status === 'ok' || limits.status === 'unavailable') { + if (now - limits.updatedAt >= MIN_REFETCH_MS) { + return { kind: 'full' } + } + continue + } + // Why: a failed startup read is not fresh data; keep it eligible for activation recovery, throttled per provider. + if (limits.status === 'error') { + // Why: the server told us when to come back (Retry-After); retrying earlier burns the endpoint's budget and keeps the 429 alive. + if (this.isRetryAfterActive(limits)) { + continue + } + const lastRetryAt = this.lastActiveFailureRetryAtByProvider[provider] + const throttleMs = INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ? Math.min( + ACTIVE_FAILURE_REFETCH_MS * + 2 ** Math.max(0, this.activeFailureStreakByProvider[provider] - 1), + MAX_ACTIVE_FAILURE_REFETCH_MS + ) + : MIN_REFETCH_MS + if (now - lastRetryAt >= throttleMs) { + retryableFailures.push(provider) + } + } + } + + if (retryableFailures.length === 0) { + return { kind: 'none' } + } + return { kind: 'providers', providers: retryableFailures } + } + + protected async runActiveWindowRefreshPlan(plan: ActiveWindowRefreshPlan): Promise { + if (plan.kind === 'none') { + return + } + if (plan.kind === 'full') { + // Why: a full fetch retries failing providers too; restart their retry clocks so the individual failure lane doesn't fire ahead of backoff. + // Why: gated on !isFetching — the fetchAll below no-ops mid-flight, so don't consume the retry throttle for free. + if (!this.isFetching) { + const now = Date.now() + for (const { provider, limits } of this.getActiveProviderState()) { + if (limits?.status === 'error') { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + } + } + await this.fetchAll() + return + } + + // Why: an in-flight fetch will refresh these; skip without consuming the per-provider retry throttle so the next activation retries. + if (this.isFetching) { + return + } + + const now = Date.now() + for (const provider of plan.providers) { + this.lastActiveFailureRetryAtByProvider[provider] = now + } + + const canRefreshIndividually = plan.providers.every((provider) => + INDIVIDUALLY_REFRESHABLE_PROVIDERS.has(provider) + ) + if (!canRefreshIndividually) { + await this.fetchAll() + return + } + + // Why: recover partial failures of dedicated-fetch providers without re-reading healthy providers still inside their debounce. + if (plan.providers.includes('claude')) { + await this.fetchClaudeOnly() + } + if (plan.providers.includes('codex')) { + await this.fetchCodexOnly() + } + if (plan.providers.includes('grok')) { + await this.fetchGrokOnly() + } + } + + protected async refreshIfWindowActive(): Promise { + if (!this.shouldBackgroundPoll()) { + return + } + const plan = this.getActiveWindowRefreshPlan(Date.now()) + await this.runActiveWindowRefreshPlan(plan) + } +} diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts new file mode 100644 index 00000000000..75c56d96e67 --- /dev/null +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -0,0 +1,183 @@ +import { RateLimitServiceFullCycleApplication } from './service-full-cycle-application' +import { fetchClaudeRateLimits } from '../claude-fetcher' +import { fetchCodexRateLimits } from '../codex-fetcher' +import { fetchGrokRateLimits } from '../grok-fetcher' +import { readGrokAuthSession } from '../grok-auth' +import type { ProviderRateLimits } from './service-types' + +export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication { + protected async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const codexTarget = this.codexFetchTarget + const codexGeneration = this.codexFetchGeneration + const codexHome = this.resolveCodexHome(codexTarget) + // Why: return before the "fetching" mark — a skipped cycle never settles it (#STA-4422). + if (codexHome.skip) { + if ( + codexGeneration === this.codexFetchGeneration && + this.state.codex?.status === 'fetching' + ) { + this.updateState({ ...this.state, codex: null }) + } + return + } + const codexHomePath = codexHome.homePath + const codexProvenance = this.getCodexProvenance(codexTarget, codexHomePath) + const previousState = this.state + + this.updateState({ + ...previousState, + codex: this.withFetchingStatus(previousState.codex, 'codex') + }) + + const missingWslCodexHome = codexHomePath + ? null + : this.getMissingWslCodexHomeResult(codexTarget) + const codex = await ( + missingWslCodexHome + ? Promise.resolve(missingWslCodexHome) + : fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + }) + ).catch((err): ProviderRateLimits => ({ + provider: 'codex', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestCodexHome = this.resolveCodexHome(codexTarget) + if (latestCodexHome.skip && codexGeneration === this.codexFetchGeneration) { + this.updateState({ + ...this.state, + codex: previousState.codex?.status === 'fetching' ? null : previousState.codex + }) + return + } + const shouldApplyCodex = + !latestCodexHome.skip && + codexGeneration === this.codexFetchGeneration && + codexProvenance === this.getCodexProvenance(codexTarget, latestCodexHome.homePath) + + if (shouldApplyCodex) { + this.trackActiveFailureStreak('codex', codex) + } + this.updateState({ + ...this.state, + codex: shouldApplyCodex ? this.applyStalePolicy(codex, previousState.codex) : this.state.codex + }) + } + + protected async runFetchClaudeOnlyCycle( + signal: AbortSignal, + options?: { force?: boolean } + ): Promise { + if (signal.aborted) { + return + } + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. + if (!options?.force && this.shouldSkipAutomatedClaudeFetch(this.state.claude)) { + return + } + const claudeTarget = this.claudeFetchTarget + // Why: capture before the resolver await so an account switch during it invalidates both the snapshot and the state apply. + const claudeGeneration = this.claudeFetchGeneration + const claudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + this.rememberClaudeAuthSnapshot(claudeAuthPreparation, claudeGeneration, claudeTarget) + const claudeProvenance = claudeAuthPreparation?.provenance ?? 'system' + const previousState = this.state + + this.updateState({ + ...previousState, + claude: this.withFetchingStatus(previousState.claude, 'claude') + }) + + const claude = await fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }).catch((err): ProviderRateLimits => ({ + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget) + if (signal.aborted) { + return + } + const latestClaudeProvenance = latestClaudeAuthPreparation?.provenance ?? 'system' + const shouldApplyClaude = + claudeGeneration === this.claudeFetchGeneration && + claudeProvenance === latestClaudeProvenance && + this.isSameClaudeTarget(claudeTarget, this.claudeFetchTarget) + + if (shouldApplyClaude) { + this.trackActiveFailureStreak('claude', claude) + } + this.updateState({ + ...this.state, + claude: shouldApplyClaude + ? this.resolveClaudeFetchApply(claude, previousState.claude) + : this.state.claude + }) + } + + protected async runFetchGrokOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const previousState = this.state + const grokAuthReadResult = readGrokAuthSession() + this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + + this.updateState({ + ...previousState, + grok: this.withFetchingStatus(previousState.grok, 'grok') + }) + + const grok = await fetchGrokRateLimits({ + signal, + authReadResult: grokAuthReadResult + }).catch((err): ProviderRateLimits => ({ + provider: 'grok', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + })) + + if (signal.aborted) { + return + } + + this.trackActiveFailureStreak('grok', grok) + this.updateState({ + ...this.state, + grok: this.applyStalePolicy(grok, previousState.grok) + }) + } +} diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts new file mode 100644 index 00000000000..db00053fdcf --- /dev/null +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -0,0 +1,112 @@ +import { RateLimitServiceFetchControl } from './service-fetch-control' +import { + MAX_ACTIVE_FAILURE_STREAK, + RATE_LIMITED_STALE_THRESHOLD_MS, + STALE_THRESHOLD_MS, + type ActiveRateLimitProvider, + type ProviderRateLimits +} from './service-types' + +export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetchControl { + protected applyStalePolicy( + fresh: ProviderRateLimits, + previous: ProviderRateLimits | null + ): ProviderRateLimits { + // Fresh data is fine — use it + if (fresh.status === 'ok') { + return { + ...fresh, + usageMetadata: { + ...fresh.usageMetadata, + lastSuccessfulSource: + fresh.usageMetadata?.source ?? fresh.usageMetadata?.lastSuccessfulSource + } + } + } + + // Explicitly unavailable (e.g. setting cleared): discard stale data so the UI shows the provider as disabled/unconfigured. + if (fresh.status === 'unavailable') { + return fresh + } + + const previousHasData = Boolean( + previous?.session || + previous?.weekly || + previous?.fableWeekly || + previous?.monthly || + (previous?.buckets && previous.buckets.length > 0) + ) + + // No previous data to fall back on + if (!previous || !previousHasData) { + return fresh + } + + // Previous data is too old — don't show stale data + const staleThresholdMs = + fresh.usageMetadata?.failureKind === 'rate-limited' + ? RATE_LIMITED_STALE_THRESHOLD_MS + : STALE_THRESHOLD_MS + if (Date.now() - previous.updatedAt > staleThresholdMs) { + return fresh + } + + // Why: keep showing a recent snapshot through repeated transient failures until it ages out, so the bar doesn't flap to empty. + return { + ...previous, + error: fresh.error, + status: 'error', + usageMetadata: { + ...previous.usageMetadata, + ...fresh.usageMetadata, + lastSuccessfulSource: + previous.usageMetadata?.lastSuccessfulSource ?? previous.usageMetadata?.source + } + } + } + + protected trackActiveFailureStreak( + provider: ActiveRateLimitProvider, + fresh: ProviderRateLimits + ): void { + if (fresh.status === 'error') { + this.activeFailureStreakByProvider[provider] = Math.min( + this.activeFailureStreakByProvider[provider] + 1, + MAX_ACTIVE_FAILURE_STREAK + ) + return + } + if (fresh.status === 'ok' || fresh.status === 'unavailable') { + this.activeFailureStreakByProvider[provider] = 0 + } + } + + protected withFetchingStatus( + current: ProviderRateLimits | null, + provider: + | 'claude' + | 'codex' + | 'gemini' + | 'opencode-go' + | 'kimi' + | 'minimax' + | 'grok' + | 'antigravity' + ): ProviderRateLimits { + if (!current) { + return { + provider, + session: null, + weekly: null, + updatedAt: 0, + error: null, + status: 'fetching' + } + } + // Why: keep a settled chip visible during background refetch so a persistently failing provider doesn't flash "…" → error each cycle. + if (current.status === 'ok' || current.status === 'error' || current.status === 'unavailable') { + return current + } + return { ...current, status: 'fetching' } + } +} diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts new file mode 100644 index 00000000000..c7e9aa4751a --- /dev/null +++ b/src/main/rate-limits/service/service-state.ts @@ -0,0 +1,165 @@ +import type { BrowserWindow } from 'electron' +import type { + InactiveAccountUsage, + ProviderRateLimits, + RateLimitState +} from '../../../shared/rate-limit-types' +import { + type ActiveRateLimitProvider, + type InactiveCodexAccountInfo, + type InternalRateLimitState, + type CodexHomePathResolver, + type KimiHomeResolver, + type ClaudeAuthPreparationResolver, + type OpenCodeGoRateLimitConfig, + type MiniMaxRateLimitConfig, + type GeminiCliOAuthEnabledResolver, + type NormalizedCodexAccountSelectionTarget, + type NormalizedClaudeAccountSelectionTarget, + type InactiveClaudeAccountInfo, + type NetworkProxySettings, + DEFAULT_POLL_MS +} from './service-types' +import { readGrokAuthSession } from '../grok-auth' + +export abstract class RateLimitServiceState { + protected state: InternalRateLimitState = { + claude: null, + codex: null, + gemini: null, + opencodeGo: null, + kimi: null, + antigravity: null, + minimax: null, + grok: null + } + protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected pollInterval: number = DEFAULT_POLL_MS + protected timer: ReturnType | null = null + protected deferredStartupRefreshTimer: ReturnType | null = null + // Why: throttle repeated focus/show/restore events so one outage doesn't create a tight provider retry loop. + protected lastActiveFailureRetryAtByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. + protected activeFailureStreakByProvider: Record = { + claude: 0, + codex: 0, + gemini: 0, + 'opencode-go': 0, + kimi: 0, + minimax: 0, + grok: 0, + antigravity: 0 + } + protected mainWindow: BrowserWindow | null = null + protected detachWindowListeners: (() => void) | null = null + protected isFetching = false + protected fullFetchQueued = false + protected codexOnlyFetchQueued = false + protected claudeOnlyFetchQueued = false + protected grokOnlyFetchQueued = false + protected activeFetchAbortControllers = new Set() + protected fetchIdleResolvers: (() => void)[] = [] + protected codexFetchGeneration = 0 + protected claudeFetchGeneration = 0 + // Why: statusline ingest must attribute live windows to the selected account without re-running the side-effectful auth sync per post. + protected lastClaudeAuthSnapshot: { configDir: string | null; provenance: string } | null = null + protected opencodeFetchGeneration = 0 + protected minimaxFetchGeneration = 0 + protected lastOpencodeConfigHash = '' + protected lastMiniMaxConfigHash = '' + protected codexHomePathResolver: CodexHomePathResolver | null = null + protected codexFetchTarget: NormalizedCodexAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + // Why: resolved per cycle — the local-account runtime policy can flip between fetches. + protected kimiHomeResolver: KimiHomeResolver | null = null + protected claudeAuthPreparationResolver: ClaudeAuthPreparationResolver | null = null + protected claudeFetchTarget: NormalizedClaudeAccountSelectionTarget = { + runtime: 'host', + wslDistro: null + } + protected openCodeGoConfigResolver: (() => OpenCodeGoRateLimitConfig) | null = null + protected miniMaxConfigResolver: (() => MiniMaxRateLimitConfig) | null = null + protected geminiCliOAuthEnabledResolver: GeminiCliOAuthEnabledResolver | null = null + protected inactiveClaudeAccountsResolver: (() => InactiveClaudeAccountInfo[]) | null = null + protected inactiveCodexAccountsResolver: (() => InactiveCodexAccountInfo[]) | null = null + protected networkProxySettingsResolver: (() => NetworkProxySettings) | null = null + protected inactiveClaudeCache = new Map() + protected inactiveCodexCache = new Map() + protected inactiveClaudeFetching = new Set() + protected inactiveCodexFetching = new Set() + protected inactiveCodexFetchInFlight = false + protected lastInactiveClaudeFetchAt = 0 + protected inactiveClaudeAccountsGeneration = 0 + protected lastInactiveCodexFetchAt = 0 + protected inactiveCodexAccountsGeneration = 0 + protected stateListeners = new Set<(state: RateLimitState) => void>() + + constructor() {} + + onStateChange(listener: (state: RateLimitState) => void): () => void { + this.stateListeners.add(listener) + return () => { + this.stateListeners.delete(listener) + } + } + + protected abstract getState(): RateLimitState + + protected buildInactiveArray( + cache: Map, + fetching: Set + ): InactiveAccountUsage[] { + const result: InactiveAccountUsage[] = [] + for (const [accountId, limits] of cache) { + result.push({ + accountId, + rateLimits: limits, + updatedAt: limits.updatedAt, + isFetching: fetching.has(accountId) + }) + } + // Why: include fetching-but-uncached accounts so the renderer shows a loading indicator for newly added accounts. + for (const accountId of fetching) { + if (!cache.has(accountId)) { + result.push({ + accountId, + rateLimits: null, + updatedAt: 0, + isFetching: true + }) + } + } + return result + } + + protected updateState(next: InternalRateLimitState): void { + this.state = next + this.pushToRenderer() + } + + protected pushToRenderer(): void { + const state = this.getState() + for (const listener of this.stateListeners) { + try { + listener(state) + } catch { + // ignore — one bad listener must not break the others + } + } + if (!this.mainWindow || this.mainWindow.isDestroyed()) { + return + } + this.mainWindow.webContents.send('rateLimits:update', state) + } +} diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts new file mode 100644 index 00000000000..0b38bd39433 --- /dev/null +++ b/src/main/rate-limits/service/service-types.ts @@ -0,0 +1,163 @@ +import type { ProviderRateLimits } from '../../../shared/rate-limit-types' +import type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +import type { ClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +import type { KimiHomeResolution } from '../../kimi/kimi-runtime-home' +import type { CodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +import type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type { + CodexRateLimitResetResult, + RateLimitState, + ProviderRateLimits, + InactiveAccountUsage, + RateLimitRuntimeTarget +} from '../../../shared/rate-limit-types' +export type { InactiveClaudeAccountInfo } from '../claude-fetcher' +export type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' +export type { NetworkProxySettings } from '../../../shared/network-proxy' +export type { ClaudeRuntimeAuthPreparation } from '../../claude-accounts/runtime-auth-service' +export type { + ClaudeAccountSelectionTarget, + NormalizedClaudeAccountSelectionTarget +} from '../../claude-accounts/runtime-selection' +export { normalizeClaudeAccountSelectionTarget } from '../../claude-accounts/runtime-selection' +export type { + CodexAccountSelectionTarget, + NormalizedCodexAccountSelectionTarget +} from '../../codex-accounts/runtime-selection' +export { normalizeCodexAccountSelectionTarget } from '../../codex-accounts/runtime-selection' +export type { CodexRateLimitHomeResolution } from '../../codex-accounts/runtime-home-service' + +export type InactiveCodexAccountInfo = { + id: string + resolveHome: () => { kind: 'ready'; managedHomePath: string } | { kind: 'skip' } +} + +export type CodexHomePathResolver = ( + target?: CodexAccountSelectionTarget +) => CodexRateLimitHomeResolution +export type KimiHomeResolver = () => Promise +export type ClaudeAuthPreparationResolver = ( + target?: ClaudeAccountSelectionTarget +) => Promise + +export type OpenCodeGoRateLimitConfig = { + sessionCookie: string + workspaceIdOverride: string +} + +export type MiniMaxRateLimitConfig = { + sessionCookie: string + groupId: string + models: string +} + +export type MiniMaxResolvedConfig = { + config: MiniMaxRateLimitConfig + error: string | null +} + +export type GeminiCliOAuthEnabledResolver = () => boolean +export type ActiveRateLimitProvider = ProviderRateLimits['provider'] +export type ActiveProviderState = { + provider: ActiveRateLimitProvider + limits: ProviderRateLimits | null +} +export type ActiveWindowRefreshPlan = + | { kind: 'none' } + | { kind: 'full' } + | { kind: 'providers'; providers: ActiveRateLimitProvider[] } + +// Why: Claude's usage endpoint has a tight budget and quota is only informational; prefer a recent snapshot over polling into 429s. +export const DEFAULT_POLL_MS = 15 * 60 * 1000 // 15 minutes +export const MIN_POLL_MS = 30 * 1000 // 30 seconds — renderer input should never create a tight loop. +export const MAX_POLL_MS = 2_147_483_647 // Max safe setInterval delay before Node clamps back to 1ms. +export const MIN_REFETCH_MS = 5 * 60 * 1000 // 5 minutes — debounce resume/manual refresh bursts +export const ACTIVE_FAILURE_REFETCH_MS = MIN_POLL_MS +// Why: retrying a persistent failure at the 30s floor hammers endpoints into 429s; back off per failure, capped at the poll cadence. +export const MAX_ACTIVE_FAILURE_REFETCH_MS = DEFAULT_POLL_MS +export const MAX_ACTIVE_FAILURE_STREAK = 8 +// Why: these providers have a dedicated fetch cycle, so an activation retry refreshes just the failing one; others force a full fetchAll. +export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ + 'claude', + 'codex', + 'grok' +]) +export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped +// Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". +export const RATE_LIMITED_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000 +// Why: statusline posts arrive on every turn; skip renderer pushes for identical windows so streaming sessions don't spam state updates. +export const LIVE_CLAUDE_INGEST_DEDUPE_MS = 30 * 1000 +export const INACTIVE_FETCH_DEBOUNCE_MS = 60 * 1000 // 60 seconds — debounce fetch-on-open +// Why: each inactive Codex probe spawns a real codex process inside that +// account's live credential home; pace them out instead of bursting every +// account the moment the switcher opens. +export const INACTIVE_CODEX_PROBE_STAGGER_MS = 2_000 +export const DEFERRED_STARTUP_ACTIVE_REFRESH_MS = 1000 + +// Why: inactive account arrays are derived from provider caches on demand in getState()/pushToRenderer(). +export type InternalRateLimitState = { + claude: ProviderRateLimits | null + codex: ProviderRateLimits | null + gemini: ProviderRateLimits | null + opencodeGo: ProviderRateLimits | null + kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null + minimax: ProviderRateLimits | null + grok: ProviderRateLimits | null +} + +export function normalizePollingInterval(ms: number): number { + if (!Number.isFinite(ms)) { + return DEFAULT_POLL_MS + } + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, ms)) +} + +export function isSystemDefaultClaudeAuth( + authPreparation: ClaudeRuntimeAuthPreparation | undefined +): boolean { + // Why: fetch cycles treat missing Claude auth as system-default; align the PTY gate so refresh can't trigger auth flows. + if (!authPreparation) { + return true + } + const provenance = authPreparation?.provenance + return provenance === 'system' || Boolean(provenance?.endsWith(':system')) +} + +export function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function normalizeClaudeConfigDir(dir: string | null | undefined): string | null { + // Why: normalize mixed Windows separators for path attribution; preserve Linux case sensitivity. + const trimmed = dir?.trim().replace(/\\/g, '/').replace(/\/+$/, '') + return trimmed || null +} + +export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const onAbort = (): void => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +export function isSameUsageWindow( + a: ProviderRateLimits['session'], + b: ProviderRateLimits['session'] +): boolean { + if (!a || !b) { + return a === b + } + return a.usedPercent === b.usedPercent && a.resetsAt === b.resetsAt +} diff --git a/src/main/runtime/browser-tab-create-publication.test.ts b/src/main/runtime/browser-tab-create-publication.test.ts index ee892f2d5ab..03a60472d85 100644 --- a/src/main/runtime/browser-tab-create-publication.test.ts +++ b/src/main/runtime/browser-tab-create-publication.test.ts @@ -91,7 +91,14 @@ function createPublicationHost(registeredTabs: readonly [string, number][] = [[' } function browserCommandsSource(): string { - return readFileSync(join(__dirname, 'orca-runtime-browser.ts'), 'utf8') + // The command adapter is split by browser-tab operation; keep the census over + // both owners instead of the thin compatibility facade. + return [ + 'runtime-browser-commands-browser-tab-create.ts', + 'runtime-browser-commands-browser-tab-list.ts' + ] + .map((fileName) => readFileSync(join(__dirname, fileName), 'utf8')) + .join('\n') } describe('publishCreatedBrowserSessionTab', () => { diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 272ff07c80a..ab0868b1345 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1,2294 +1,5 @@ -/* eslint-disable max-lines -- Why: this file is a command adapter for one external surface, Agent Browser automation. It stays separate from OrcaRuntimeService so runtime state does not grow further while browser routing remains easy to scan in one place. */ -import { randomUUID } from 'node:crypto' -import { ipcMain, webContents, type BrowserWindow } from 'electron' -import type { - BrowserBackResult, - BrowserCaptureStartResult, - BrowserCheckResult, - BrowserCaptureStopResult, - BrowserClearResult, - BrowserClickResult, - BrowserConsoleResult, - BrowserCookieDeleteResult, - BrowserCookieGetResult, - BrowserCookieSetResult, - BrowserDetectProfilesResult, - BrowserDragResult, - BrowserEvalResult, - BrowserFillResult, - BrowserFocusResult, - BrowserGeolocationResult, - BrowserGotoResult, - BrowserHoverResult, - BrowserInterceptDisableResult, - BrowserInterceptEnableResult, - BrowserKeypressResult, - BrowserNetworkLogResult, - BrowserPdfResult, - BrowserProfileClearDefaultCookiesResult, - BrowserProfileCreateResult, - BrowserProfileDeleteResult, - BrowserProfileImportFromBrowserResult, - BrowserProfileListResult, - BrowserReloadResult, - BrowserScreenshotResult, - BrowserScreencastResult, - BrowserScrollResult, - BrowserSelectAllResult, - BrowserSelectResult, - BrowserSnapshotResult, - BrowserTabCurrentResult, - BrowserTabListResult, - BrowserTabProfileCloneResult, - BrowserTabProfileShowResult, - BrowserTabSetProfileResult, - BrowserTabShowResult, - BrowserTabSwitchResult, - BrowserTypeResult, - BrowserUploadResult, - BrowserViewportResult, - BrowserWaitResult -} from '../../shared/runtime-types' -import type { - BrowserCertificateProceedResult, - BrowserSessionUserAgentMode -} from '../../shared/browser-workspace-types' -import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' -import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol' -import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' -import type { ExecutionHostId } from '../../shared/execution-host' -import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' -import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' -import type { BrowserBackend } from '../browser/browser-backend' -import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' -import { BrowserError } from '../browser/cdp-bridge' -import { startBrowserScreencast } from '../browser/browser-screencast-stream' -import { - browserScreencastFrameBudgetsEqual, - mergeBrowserScreencastFrameBudgets -} from '../browser/browser-screencast-frame-budget' -import type { - BrowserScreencastFrameBudget, - BrowserScreencastSession, - BrowserScreencastViewport -} from '../browser/browser-screencast-stream-types' -import { browserSessionRegistry } from '../browser/browser-session-registry' -import { - detectInstalledBrowsers, - importCookiesFromBrowser, - selectBrowserProfile -} from '../browser/browser-cookie-import' -import { - waitForTabRegistration, - waitForWorktreeTabRegistration -} from '../ipc/browser-tab-registration-wait' -import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' -import { - INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, - recordScreencastSubscriberSend, - screencastSubscriberIsGhost, - type ScreencastSubscriberDeliveryState -} from './browser-screencast-ghost-subscriber-eviction' -import { - publishCreatedBrowserSessionTab, - publishSwitchedBrowserSessionTab, - resolveBrowserTabCreateFocus, - type BrowserSessionTabSelectionOptions -} from './browser-tab-create-publication' -import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' -import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' -import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection' -import { - closeRuntimeBrowserClientPage, - createRuntimeBrowserClientPage, - navigateRuntimeBrowserClientPage -} from './runtime-browser-client-page-creation' -import type { - RuntimeBrowserClientPage, - RuntimeBrowserPageRegistry -} from './runtime-browser-page-registry' +import { RuntimeBrowserCommandsWithListLogicalBrowserTabs } from './runtime-browser-commands-list-logical-browser-tabs' -export type BrowserCommandTargetParams = { - worktree?: string - page?: string -} - -type ResolvedBrowserCommandTarget = { - worktreeId?: string - browserPageId?: string -} - -type ResolvedBrowserPageWebContents = { - browserPageId: string - webContents: Electron.WebContents -} - -type BrowserScreencastParams = { - format: 'jpeg' | 'png' - quality?: number - maxWidth?: number - maxHeight?: number - viewportWidth?: number - viewportHeight?: number - deviceScaleFactor?: number - mobile?: boolean - everyNthFrame?: number - minFrameIntervalMs?: number -} & BrowserCommandTargetParams - -type BrowserScreencastStartResult = { - subscriptionId: string - ready: Extract - // The frame budget belongs to the shared page, not to one subscriber's handle. - session: Omit - // Why: callers gate frames until they have emitted `ready`, and the snapshot captured - // for a joining subscriber lands inside that window. This replays it once the gate opens. - flushPendingFrame: () => void -} - -type ActiveBrowserScreencastSubscriber = { - sendBinary: (bytes: Uint8Array) => boolean | void - emit?: (event: BrowserScreencastResult) => void - done: Promise - resolveDone: () => void - viewport: BrowserScreencastViewport - budget: BrowserScreencastFrameBudget - pendingFrame: Uint8Array | null - // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new - // socket never reuses the old id, so a reconnecting device would stack a second subscription. - pairedDeviceId?: string - delivery: ScreencastSubscriberDeliveryState -} - -type ActiveBrowserScreencastPage = { - format: 'jpeg' | 'png' - session: BrowserScreencastSession | null - started: Promise - stopping: boolean - subscribers: Map - viewportOwnerSubscriptionId: string | null - appliedBudget: BrowserScreencastFrameBudget -} - -async function applySharedScreencastFrameBudget( - active: ActiveBrowserScreencastPage, - session: BrowserScreencastSession -): Promise { - const merged = mergeBrowserScreencastFrameBudgets( - Array.from(active.subscribers.values(), (subscriber) => subscriber.budget) - ) - if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) { - return - } - active.appliedBudget = merged - await session.updateFrameBudget(merged) -} - -function normalizeScreencastViewport(params: BrowserScreencastParams): BrowserScreencastViewport { - return { - viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840), - viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160), - deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4), - mobile: params.mobile === true - } -} - -function normalizeScreencastFrameBudget( - params: BrowserScreencastParams -): BrowserScreencastFrameBudget { - return { - quality: clampInteger(params.quality, 10, 100, 70), - maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440), - maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200), - everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), - minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0) - } -} - -function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean { - return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined -} - -function clampInteger( - value: number | undefined, - min: number, - max: number, - fallback: number -): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return fallback - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -function clampOptionalInteger( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, Math.round(value))) -} - -function clampOptionalNumber( - value: number | undefined, - min: number, - max: number -): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined - } - return Math.min(max, Math.max(min, value)) -} - -export type RuntimeBrowserCommandHost = { - getAgentBrowserBridge(): AgentBrowserBridge | null - resolveWorktreeSelector(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserWorkspace(selector: string): Promise<{ - id: string - repoId?: string - hostId?: ExecutionHostId - }> - resolveBrowserNetworkExecutionHost(worktree?: { - id: string - repoId?: string - hostId?: ExecutionHostId - }): BrowserNetworkExecutionHost | Promise - getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry - getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry - getAuthoritativeWindow(): BrowserWindow - getAvailableAuthoritativeWindow(): BrowserWindow | null - // Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing. - getOffscreenBrowserBackend(): BrowserBackend | null - // Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal. - markHeadlessBrowserSessionTabActive?( - worktreeId: string | undefined, - browserPageId: string, - options?: BrowserSessionTabSelectionOptions - ): void - notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void - /** True when a runtime-owned session row for that page existed and was retired. */ - retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void -} - -export class RuntimeBrowserCommands { - private readonly activeScreencastsByPageId = new Map() - - constructor(private readonly host: RuntimeBrowserCommandHost) {} - - private requireAgentBrowserBridge(): AgentBrowserBridge { - const bridge = this.host.getAgentBrowserBridge() - if (!bridge) { - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - return bridge - } - - /** - * Retires a session row for a page nothing here can close any more. - * - * A client-hosted page whose runtime record is gone -- released as unrecoverable, or created by a - * build that kept its records in memory only -- still leaves a row every paired device can see, - * and every close path below is keyed on a live guest this runtime does not have. Without this - * the row's X fails closed and the ghost outlives the browser it named. - */ - private retireGhostBrowserSessionRow( - worktreeId: string | undefined, - browserPageId: string - ): boolean { - return ( - worktreeId !== undefined && - this.host.retireRuntimeOwnedBrowserSessionTab?.(worktreeId, browserPageId) === true - ) - } - - private hasLiveRegisteredBrowserTab( - bridge: AgentBrowserBridge, - worktreeId: string | undefined - ): boolean { - for (const [, webContentsId] of bridge.getRegisteredTabs(worktreeId)) { - const guest = webContents.fromId(webContentsId) - if (guest && !guest.isDestroyed()) { - return true - } - } - return false - } - - private hasLiveRegisteredBrowserPage( - bridge: AgentBrowserBridge, - worktreeId: string | undefined, - browserPageId: string - ): boolean { - const webContentsId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) - if (webContentsId == null) { - return false - } - const guest = webContents.fromId(webContentsId) - return Boolean(guest && !guest.isDestroyed()) - } - - // Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id. - private async resolveBrowserWorktreeId(selector?: string): Promise { - if (!selector) { - // Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest. - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, undefined)) { - try { - await this.ensureBrowserWorktreeActive(undefined) - } catch { - // Window may not exist yet (e.g. during startup or in tests) - } - } - return undefined - } - - const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id - // Why: explicit selectors are user intent, so resolution errors surface (not silently widen scope); only activation stays best-effort. - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) { - try { - await this.ensureBrowserWorktreeActive(worktreeId) - } catch { - // Fall through with the validated worktree id so routing stays scoped to the caller's explicit selector. - } - } - return worktreeId - } - - private async resolveBrowserCommandTarget( - params: BrowserCommandTargetParams - ): Promise { - const browserPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined - if (!browserPageId) { - return { - worktreeId: await this.resolveBrowserWorktreeId(params.worktree) - } - } - - const worktreeId = params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id - : undefined - const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { - try { - await this.ensureBrowserPageActive(worktreeId, browserPageId) - } catch { - // Fall through with the explicit page target; downstream routing surfaces a clear "tab not found" error if wake fails. - } - } - return { - // Why: an explicit browserPageId is already a stable tab identity, so don't auto-resolve cwd worktree scoping on top of it. - worktreeId, - browserPageId - } - } - - private resolveBrowserPageWebContents( - worktreeId: string | undefined, - browserPageId: string | undefined - ): ResolvedBrowserPageWebContents { - const bridge = this.requireAgentBrowserBridge() - const resolvedPageId = browserPageId ?? bridge.getActivePageId(worktreeId) - if (!resolvedPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const webContentsId = bridge.getRegisteredTabs(worktreeId).get(resolvedPageId) - if (webContentsId == null) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedPageId} was not found${scope}` - ) - } - const guest = webContents.fromId(webContentsId) - if (!guest || guest.isDestroyed()) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedPageId} is no longer available` - ) - } - return { browserPageId: resolvedPageId, webContents: guest } - } - - // Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane. - private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {}) - // Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep. - await waitForWorktreeTabRegistration(worktreeId) - } - - private async ensureBrowserPageActive( - worktreeId: string | undefined, - browserPageId: string - ): Promise { - const win = this.host.getAuthoritativeWindow() - win.webContents.send( - 'browser:activateView', - worktreeId ? { worktreeId, browserPageId } : { browserPageId } - ) - await waitForTabRegistration(browserPageId) - } - - // Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. - private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { - try { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:navigation-update', { browserPageId, url, title }) - } catch { - // Window may not exist during shutdown - } - } - - // Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree. - private notifyRendererBrowserPaneFocus( - worktreeId: string | undefined, - browserPageId: string - ): void { - try { - const win = this.host.getAuthoritativeWindow() - win.webContents.send('browser:pane-focus', { - worktreeId: worktreeId ?? null, - browserPageId - }) - } catch { - // Window may not exist during shutdown - } - } - - async browserSnapshot(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) - } - - async browserClick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) - // Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page. - const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) - if (page) { - this.notifyRendererNavigation(page.browserPageId, page.url, page.title) - } - return result - } - - async browserGoto( - params: { url: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) - } - return result - } - - async browserFill( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fill( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserType( - params: { input: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().type( - params.input, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelect( - params: { - element: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().select( - params.element, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserScroll( - params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scroll( - params.direction, - params.amount, - target.worktreeId, - target.browserPageId - ) - } - - async browserBack(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.back(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserReload(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const bridge = this.requireAgentBrowserBridge() - const result = await bridge.reload(target.worktreeId, target.browserPageId) - const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) - if (pageId) { - this.notifyRendererNavigation(pageId, result.url, result.title) - } - return result - } - - async browserScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().screenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // The single leave path: an explicit stop, a ghost eviction and a same-device replacement all - // unwind through here, so viewport hand-off, budget release and stream teardown cannot drift. - private leaveScreencastSubscriber( - active: ActiveBrowserScreencastPage, - subscriptionId: string, - session: BrowserScreencastSession - ): void { - const subscriber = active.subscribers.get(subscriptionId) - if (!subscriber) { - return - } - active.subscribers.delete(subscriptionId) - subscriber.resolveDone() - if (active.viewportOwnerSubscriptionId === subscriptionId) { - const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => - hasScreencastViewportSize(candidate.viewport) - ) - active.viewportOwnerSubscriptionId = fallback?.[0] ?? null - if (fallback) { - void session.updateViewport(fallback[1].viewport).catch(() => {}) - } - } - if (active.subscribers.size === 0) { - active.stopping = true - session.stop() - return - } - // Why: a departed subscriber's caps would otherwise pin the shared stream for - // the rest of its life, long after the client that asked for them is gone. - void applySharedScreencastFrameBudget(active, session).catch(() => {}) - } - - async browserScreencast( - params: BrowserScreencastParams, - stream: { - sendBinary: (bytes: Uint8Array) => boolean | void - emit?: (event: BrowserScreencastResult) => void - pairedDeviceId?: string - } - ): Promise { - if (await this.resolveClientHostedBrowserPage(params)) { - throw new BrowserError( - 'browser_error', - 'Client-hosted browser pages do not support server screencast.' - ) - } - const target = await this.resolveBrowserCommandTarget(params) - const { browserPageId, webContents: guest } = this.resolveBrowserPageWebContents( - target.worktreeId, - target.browserPageId - ) - const subscriptionId = `browser-screencast:${browserPageId}:${randomUUID()}` - const viewport = normalizeScreencastViewport(params) - const budget = normalizeScreencastFrameBudget(params) - let resolveSubscriberDone!: () => void - const subscriberDone = new Promise((resolve) => { - resolveSubscriberDone = resolve - }) - let createdPageStream = false - let active = this.activeScreencastsByPageId.get(browserPageId) - while (active?.stopping) { - await active.session?.done - active = this.activeScreencastsByPageId.get(browserPageId) - } - if (!active) { - createdPageStream = true - const subscribers = new Map() - const record = { - format: params.format, - session: null, - stopping: false, - subscribers, - viewportOwnerSubscriptionId: null, - appliedBudget: budget - } as ActiveBrowserScreencastPage - record.started = startBrowserScreencast(guest, { - format: params.format, - ...budget, - ...viewport, - onFrame: (bytes) => { - const ghosts: string[] = [] - for (const [subscriptionId, subscriber] of record.subscribers) { - // A slow viewer drops this frame without stalling every other viewer, but the - // newest refusal is retained so a gate that opens later can still be filled. - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) - if (screencastSubscriberIsGhost(subscriber.delivery)) { - ghosts.push(subscriptionId) - } - } - // Evicting after the fan-out keeps a teardown that stops the session from cutting the - // remaining viewers out of this frame. - for (const subscriptionId of ghosts) { - if (record.session) { - this.leaveScreencastSubscriber(record, subscriptionId, record.session) - } - } - return true - }, - onEvent: (event) => { - for (const subscriber of record.subscribers.values()) { - subscriber.emit?.(event) - } - }, - onError: (message) => { - for (const subscriber of record.subscribers.values()) { - subscriber.emit?.({ type: 'error', message }) - } - } - }) - active = record - this.activeScreencastsByPageId.set(browserPageId, record) - void record.started - .then((session) => { - record.session = session - return session.done - }) - .finally(() => { - if (this.activeScreencastsByPageId.get(browserPageId) === record) { - this.activeScreencastsByPageId.delete(browserPageId) - } - for (const subscriber of record.subscribers.values()) { - subscriber.resolveDone() - } - record.subscribers.clear() - }) - .catch(() => {}) - } - active.subscribers.set(subscriptionId, { - sendBinary: stream.sendBinary, - emit: stream.emit, - done: subscriberDone, - resolveDone: resolveSubscriberDone, - viewport, - budget, - pendingFrame: null, - pairedDeviceId: stream.pairedDeviceId, - delivery: INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY - }) - // Why: normalizeScreencastViewport keeps undefined dimensions, so a sizeless - // subscriber taking ownership would clear the emulation for every viewer. - if (hasScreencastViewportSize(viewport)) { - active.viewportOwnerSubscriptionId = subscriptionId - } - let session: BrowserScreencastSession - try { - session = await active.started - } catch (error) { - active.subscribers.delete(subscriptionId) - resolveSubscriberDone() - throw error - } - // Why: a device that force-quit and reconnected arrives on a fresh socket, so the - // connection-keyed replacement upstream cannot see its old subscription. Run this after the - // joiner is registered — the page then never empties mid-replacement and stops the stream. - if (stream.pairedDeviceId !== undefined) { - // Deleting the entry being visited is well defined for a Map, and no other entry is touched. - for (const [candidateId, candidate] of active.subscribers) { - if (candidateId !== subscriptionId && candidate.pairedDeviceId === stream.pairedDeviceId) { - this.leaveScreencastSubscriber(active, candidateId, session) - } - } - } - if (!createdPageStream) { - if (active.viewportOwnerSubscriptionId === subscriptionId) { - await session.updateViewport(viewport) - } - await applySharedScreencastFrameBudget(active, session) - } - return { - subscriptionId, - flushPendingFrame: () => { - const subscriber = active.subscribers.get(subscriptionId) - const bytes = subscriber?.pendingFrame - if (!subscriber || !bytes) { - return - } - const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) - subscriber.pendingFrame = delivered ? null : bytes - // The replay is this subscriber's first chance to reach its socket, so it is also where - // an eviction-eligible delivery history starts. - subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) - }, - session: { - done: subscriberDone, - stop: () => this.leaveScreencastSubscriber(active, subscriptionId, session), - updateViewport: session.updateViewport - }, - ready: { - type: 'ready', - subscriptionId, - browserPageId, - format: active.format, - tab: this.describeBrowserTab(browserPageId, target.worktreeId) - } - } - } - - async browserEval( - params: { expression: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().evaluate( - params.expression, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabList(params: { worktree?: string }): Promise { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - const clientPages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) - let bridgeWorktreeId = workspaceId - if (this.host.getAgentBrowserBridge()) { - try { - bridgeWorktreeId = await this.resolveBrowserWorktreeId(params.worktree) - } catch (error) { - if (clientPages.length === 0) { - throw error - } - } - } - return { tabs: this.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) } - } - - async browserProceedCertificate( - params: { challengeId: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - if (!target.browserPageId) { - return { ok: false, reason: 'missing' } - } - return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) - } - - async browserTabShow(params: { page: string; worktree?: string }): Promise { - const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) - if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) - const tab = this.listLogicalBrowserTabs( - clientPage.workspaceId, - this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId) - ).find((candidate) => candidate.browserPageId === clientPage.browserPageId) - if (!tab) { - throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) - } - return { tab } - } - const target = await this.resolveBrowserCommandTarget(params) - return { tab: this.describeBrowserTab(params.page, target.worktreeId) } - } - - async browserTabCurrent(params: { worktree?: string }): Promise { - const tab = (await this.browserTabList(params)).tabs.find((candidate) => candidate.active) - if (!tab) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - return { tab } - } - - async browserTabSwitch( - params: { - index?: number - focus?: boolean - } & BrowserCommandTargetParams - ): Promise { - const listed = await this.browserTabList({ worktree: params.worktree }) - const switchedIndex = params.page - ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) - : (params.index ?? -1) - const selected = listed.tabs[switchedIndex] - if (!selected) { - const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` - throw new BrowserError( - 'browser_tab_not_found', - `${label} out of range (0-${listed.tabs.length - 1})` - ) - } - const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) - if (clientPage) { - this.host - .getRuntimeBrowserPageRegistry() - .activatePage(clientPage.browserPageId, clientPage.placement) - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId: clientPage.browserPageId, - worktreeId: clientPage.workspaceId, - focus: params.focus - }) - return { switched: switchedIndex, browserPageId: clientPage.browserPageId } - } - const bridge = this.requireAgentBrowserBridge() - const worktreeId = - typeof selected.worktreeId === 'string' - ? selected.worktreeId - : params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) - this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() - if (worktreeId) { - this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) - } - // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). - const focusWorktreeId = - worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'bridge', - browserPageId: result.browserPageId, - worktreeId: focusWorktreeId, - focus: params.focus - }) - if (params.focus) { - this.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId) - } - return { ...result, switched: switchedIndex } - } - - async browserHover( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().hover( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserDrag( - params: { - from: string - to: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().drag( - params.from, - params.to, - target.worktreeId, - target.browserPageId - ) - } - - async browserUpload( - params: { element: string; files: string[] } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().upload( - params.element, - params.files, - target.worktreeId, - target.browserPageId - ) - } - - async browserWait( - params: { - selector?: string - timeout?: number - text?: string - url?: string - load?: string - fn?: string - state?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const { worktree: _, page: __, ...options } = params - return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) - } - - async browserCheck( - params: { element: string; checked: boolean } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().check( - params.element, - params.checked, - target.worktreeId, - target.browserPageId - ) - } - - async browserFocus( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().focus( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserClear( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clear( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserSelectAll( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().selectAll( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserKeypress( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keypress( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserPdf(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) - } - - async browserFullScreenshot( - params: { - format?: 'png' | 'jpeg' - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().fullPageScreenshot( - params.format, - target.worktreeId, - target.browserPageId - ) - } - - // ── Cookie management ── - - async browserCookieGet( - params: { url?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieGet( - params.url, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieSet( - params: { - name: string - value: string - domain?: string - path?: string - secure?: boolean - httpOnly?: boolean - sameSite?: string - expires?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieSet( - params, - target.worktreeId, - target.browserPageId - ) - } - - async browserCookieDelete( - params: { - name: string - domain?: string - url?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().cookieDelete( - params.name, - params.domain, - params.url, - target.worktreeId, - target.browserPageId - ) - } - - // ── Viewport ── - - async browserSetViewport( - params: { - width: number - height: number - deviceScaleFactor?: number - mobile?: boolean - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setViewport( - params.width, - params.height, - params.deviceScaleFactor, - params.mobile, - target.worktreeId, - target.browserPageId - ) - } - - // ── Geolocation ── - - async browserSetGeolocation( - params: { - latitude: number - longitude: number - accuracy?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setGeolocation( - params.latitude, - params.longitude, - params.accuracy, - target.worktreeId, - target.browserPageId - ) - } - - // ── Request interception ── - - async browserInterceptEnable( - params: { - patterns?: string[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptEnable( - params.patterns, - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptDisable( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptDisable( - target.worktreeId, - target.browserPageId - ) - } - - async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) - } - - // ── Console/network capture ── - - async browserCaptureStart( - params: BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) - } - - async browserCaptureStop(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) - } - - async browserConsoleLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().consoleLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - async browserNetworkLog( - params: { limit?: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().networkLog( - params.limit, - target.worktreeId, - target.browserPageId - ) - } - - // ── Additional core commands ── - - async browserDblclick( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dblclick( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserForward(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) - } - - async browserScrollIntoView( - params: { element: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().scrollIntoView( - params.element, - target.worktreeId, - target.browserPageId - ) - } - - async browserGet( - params: { - what: string - selector?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().get( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - async browserIs( - params: { what: string; selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().is( - params.what, - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── Keyboard insert text ── - - async browserKeyboardInsertText( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().keyboardInsertText( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Mouse commands ── - - async browserMouseMove( - params: { x: number; y: number } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseMove( - params.x, - params.y, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseDown( - params: { button?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseDown( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseClick( - params: { - x: number - y: number - button?: string - radius?: number - modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseClick( - params.x, - params.y, - params.button, - target.worktreeId, - target.browserPageId, - clampOptionalNumber(params.radius, 0, 64), - params.modifiers - ) - } - - async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseUp( - params.button, - target.worktreeId, - target.browserPageId - ) - } - - async browserMouseWheel( - params: { - dy: number - dx?: number - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().mouseWheel( - params.dy, - params.dx, - target.worktreeId, - target.browserPageId - ) - } - - // ── Find (semantic locators) ── - - async browserFind( - params: { - locator: string - value: string - action: string - text?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().find( - params.locator, - params.value, - params.action, - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Set commands ── - - async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setDevice( - params.name, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetOffline( - params: { state?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setOffline( - params.state, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetHeaders( - params: { headers: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setHeaders( - params.headers, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetCredentials( - params: { - user: string - pass: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setCredentials( - params.user, - params.pass, - target.worktreeId, - target.browserPageId - ) - } - - async browserSetMedia( - params: { - colorScheme?: string - reducedMotion?: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().setMedia( - params.colorScheme, - params.reducedMotion, - target.worktreeId, - target.browserPageId - ) - } - - // ── Clipboard commands ── - - async browserClipboardRead(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) - } - - async browserClipboardWrite( - params: { text: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().clipboardWrite( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - // ── Dialog commands ── - - async browserDialogAccept( - params: { text?: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogAccept( - params.text, - target.worktreeId, - target.browserPageId - ) - } - - async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) - } - - // ── Storage commands ── - - async browserStorageLocalGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageLocalClear( - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionGet( - params: { key: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionGet( - params.key, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionSet( - params: { - key: string - value: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionSet( - params.key, - params.value, - target.worktreeId, - target.browserPageId - ) - } - - async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().storageSessionClear( - target.worktreeId, - target.browserPageId - ) - } - - // ── Download command ── - - async browserDownload( - params: { - selector: string - path: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().download( - params.selector, - params.path, - target.worktreeId, - target.browserPageId - ) - } - - // ── Highlight command ── - - async browserHighlight( - params: { selector: string } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().highlight( - params.selector, - target.worktreeId, - target.browserPageId - ) - } - - // ── New: exec passthrough + tab lifecycle ── - - async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { - const target = await this.resolveBrowserCommandTarget(params) - return this.requireAgentBrowserBridge().exec( - params.command, - target.worktreeId, - target.browserPageId - ) - } - - async browserTabCreate( - params: { - url?: string - worktree?: string - page?: string - profileId?: string - waitForRegistration?: boolean - activate?: boolean - navigation?: RuntimeNavigationTarget - targetGroupId?: string - placement?: BrowserPageCreationPlacement - }, - caller?: { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } - ): Promise<{ browserPageId: string }> { - const url = params.url ?? 'about:blank' - const focus = resolveBrowserTabCreateFocus({ - activate: params.activate, - navigation: params.navigation, - clientKind: caller?.clientKind - }) - const worktree = params.worktree - ? params.placement?.kind === 'client' - ? await this.host.resolveBrowserWorkspace(params.worktree) - : await this.host.resolveWorktreeSelector(params.worktree) - : undefined - const worktreeId = worktree?.id - const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) - if (!sessionPartition) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - if (params.placement?.kind === 'client') { - if (!caller?.pairedDeviceId) { - throw new BrowserError( - 'forbidden', - 'Client-hosted browser pages require an authenticated paired runtime.' - ) - } - if (!worktree) { - throw new BrowserError( - 'invalid_argument', - 'Client-hosted browser pages require an explicit workspace.' - ) - } - const browserPageId = params.page ?? randomUUID() - const executionHost = await this.host.resolveBrowserNetworkExecutionHost(worktree) - const authority = this.host.getBrowserHostLeaseRegistry() - const browserProfileId = params.profileId ?? browserSessionRegistry.getDefaultProfile().id - const created = await createRuntimeBrowserClientPage(authority, { - browserPageId, - browserHostClientId: params.placement.browserHostClientId, - pairedDeviceId: caller.pairedDeviceId, - browserProfileId, - executionHost, - workspaceId: worktree.id - }) - const pages = this.host.getRuntimeBrowserPageRegistry() - pages.publishClientPage({ - browserPageId, - workspaceId: worktree.id, - browserProfileId, - executionHostKey: browserNetworkExecutionHostKey(executionHost), - placement: created.placement, - pairedDeviceId: caller.pairedDeviceId, - url: 'about:blank', - loading: url !== 'about:blank', - active: focus.startsActive - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId, - worktreeId: worktree.id, - focus, - clientNavigationId: caller.pairedDeviceId, - targetGroupId: params.targetGroupId - }) - if (url !== 'about:blank') { - try { - await navigateRuntimeBrowserClientPage(authority, { - browserPageId, - placement: created.placement, - url - }) - pages.updatePage(browserPageId, created.placement, { url, loading: false }) - } catch { - pages.updatePage(browserPageId, created.placement, { loading: false }) - } - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) - } - return { browserPageId } - } - // Why: headless serve has no renderer , so back the page with a main-process offscreen WebContents instead. - if (!this.host.getAvailableAuthoritativeWindow()) { - const offscreen = this.host.getOffscreenBrowserBackend() - if (!offscreen) { - throw new BrowserError('browser_error', 'This host does not support browser panes.') - } - // Why: the offscreen backend registers synchronously, so there is no webview-mount wait. - const created = await offscreen.createTab({ - url, - worktreeId, - profileId: params.profileId, - ...(params.page ? { browserPageId: params.page } : {}) - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'offscreen', - browserPageId: created.browserPageId, - worktreeId, - focus, - ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), - targetGroupId: params.targetGroupId - }) - return { browserPageId: created.browserPageId } - } - const { browserPageId } = await this.createBrowserTabInRenderer( - url, - worktreeId, - params.profileId, - params.profileId ? sessionPartition : undefined, - focus.focusesHost, - params.page - ) - - // Why: the webview must mount and register before the tab is operable, so wait here (returning the ID anyway on timeout). - if (params.waitForRegistration !== false) { - try { - await waitForTabRegistration(browserPageId) - } catch { - // Tab exists in the renderer even if the webview hasn't mounted; subsequent commands surface a clear error if it never loads. - } - } - - const bridge = this.requireAgentBrowserBridge() - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'renderer', - browserPageId, - worktreeId, - focus, - ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), - targetGroupId: params.targetGroupId - }) - - // Why: the webview loads about:blank first; route navigation through the bridge so its registered owner remains authoritative. - if (url && url !== 'about:blank') { - const navigate = async (): Promise => { - const result = await bridge.goto(url, worktreeId, browserPageId) - this.notifyRendererNavigation(browserPageId, result.url, result.title) - if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) - } - } - if (params.waitForRegistration === true) { - void navigate().catch(() => {}) - return { browserPageId } - } - try { - await navigate() - } catch { - // Tab exists but navigation failed — caller can retry with explicit goto - } - } - - return { browserPageId } - } - - async browserOpenUrlOnClient(params: { - url: string - worktree: string - }): Promise<{ browserPageId: string }> { - const protocol = new URL(params.url).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') - } - const lease = this.host - .getBrowserHostLeaseRegistry() - .select(undefined, [ - BROWSER_HOST_WEBVIEW_CAPABILITY, - BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY - ]) - return this.browserTabCreate( - { - url: params.url, - worktree: params.worktree, - activate: true, - navigation: 'caller', - placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } - }, - { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } - ) - } - - async browserTabSetProfile( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const browserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!browserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. - const profile = - browserSessionRegistry.getProfile(params.profileId) ?? - (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - - // Why: short-circuit no-op switches so the renderer doesn't needlessly tear down and remount the webview. - const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' - if (currentProfileId === profile.id) { - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - const win = this.host.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabSetProfileReply', handler) - reject(new Error('Tab profile update timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabSetProfileReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve() - } - } - ipcMain.on('browser:tabSetProfileReply', handler) - win.webContents.send('browser:requestTabSetProfile', { - requestId, - browserPageId, - profileId: profile.id, - sessionPartition: profile.partition - }) - }) - - // Why: profile change remounts the webview; wait for re-register so follow-up commands see the new profile and an attached guest. - try { - await waitForTabRegistration(browserPageId) - } catch { - // Best-effort: re-register won't fire while the worktree is hidden; downstream commands retry once the pane re-mounts. - } - - return { - browserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserTabProfileShow(params: { - page: string - worktree?: string - }): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const tab = this.describeBrowserTab(params.page, target.worktreeId) - return { - browserPageId: tab.browserPageId, - worktreeId: tab.worktreeId ?? null, - profileId: tab.profileId ?? null, - profileLabel: tab.profileLabel ?? null - } - } - - async browserTabProfileClone( - params: { - profileId: string - } & BrowserCommandTargetParams - ): Promise { - const target = await this.resolveBrowserCommandTarget(params) - const sourceBrowserPageId = - target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) - if (!sourceBrowserPageId) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') - } - const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - throw new BrowserError( - 'invalid_argument', - `Browser profile ${params.profileId} was not found` - ) - } - const created = await this.createBrowserTabInRenderer( - sourceTab.url, - sourceTab.worktreeId ?? target.worktreeId, - profile.id, - profile.partition - ) - // Why: wait for the cloned tab's webview to register so the returned browserPageId is operable by the next CLI call. - try { - await waitForTabRegistration(created.browserPageId) - } catch { - // Best-effort: registration may not fire if the worktree is hidden. - } - return { - browserPageId: created.browserPageId, - sourceBrowserPageId, - profileId: profile.id, - profileLabel: profile.label - } - } - - async browserProfileList(): Promise { - return { profiles: browserSessionRegistry.listProfiles() } - } - - async browserProfileCreate(params: { - label: string - scope: 'isolated' | 'imported' - userAgentMode?: BrowserSessionUserAgentMode - }): Promise { - return { - profile: await browserSessionRegistry.createProfile(params.scope, params.label, { - userAgentMode: params.userAgentMode - }) - } - } - - async browserProfileDelete(params: { profileId: string }): Promise { - return { - deleted: await browserSessionRegistry.deleteProfile(params.profileId), - profileId: params.profileId - } - } - - async browserProfileDetectBrowsers(): Promise { - return { - // Why: expose only display metadata; filesystem paths and keychain identifiers stay on the runtime server. - browsers: detectInstalledBrowsers().map((browser) => ({ - family: browser.family, - label: browser.label, - profiles: browser.profiles, - selectedProfile: browser.selectedProfile - })) - } - } - - async browserProfileImportFromBrowser(params: { - profileId: string - browserFamily: string - browserProfile?: string - supportsPartitionSkippedCookies?: true - }): Promise { - const profile = browserSessionRegistry.getProfile(params.profileId) - if (!profile) { - return { ok: false, reason: 'Session profile not found.' } - } - if ( - params.browserProfile && - (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) - ) { - return { ok: false, reason: 'Invalid browser profile name.' } - } - - const browsers = detectInstalledBrowsers() - let browser = browsers.find((candidate) => candidate.family === params.browserFamily) - if (!browser) { - return { ok: false, reason: 'Browser not found on this system.' } - } - - if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { - const reselected = selectBrowserProfile(browser, params.browserProfile) - if (!reselected) { - return { - ok: false, - reason: `No cookies database found for profile "${params.browserProfile}".` - } - } - browser = reselected - } - - const result = await importCookiesFromBrowser(browser, profile.partition, { - canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true - }) - if (!result.ok) { - return result - } - - const profileName = - browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? - browser.selectedProfile - browserSessionRegistry.updateProfileSource(params.profileId, { - browserFamily: browser.family, - profileName, - importedAt: Date.now() - }) - return { ...result, profileId: params.profileId } - } - - async browserProfileClearDefaultCookies(): Promise { - return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } - } - - async browserTabClose(params: { - index?: number - page?: string - worktree?: string - }): Promise<{ closed: boolean }> { - const pages = this.host.getRuntimeBrowserPageRegistry() - let clientPage = params.page ? pages.getPage(params.page) : undefined - if (clientPage) { - await this.assertClientPageWorkspace(clientPage, params.worktree) - } else if (!params.page) { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - if (pages.listPages(workspaceId).length > 0) { - const tab = - params.index !== undefined - ? (await this.browserTabList({ worktree: params.worktree })).tabs[params.index] - : (await this.browserTabCurrent({ worktree: params.worktree })).tab - clientPage = tab ? pages.getPage(tab.browserPageId) : undefined - } - } - if (clientPage) { - const authority = this.host.getBrowserHostLeaseRegistry() - // Why: a retained page whose host quit has no placement left to command, and asking the - // absent host first would refuse the close and strand the tab with no way to dismiss it. - if (authority.getPlacement(clientPage.browserPageId)) { - await closeRuntimeBrowserClientPage(authority, { - browserPageId: clientPage.browserPageId, - placement: clientPage.placement - }) - } - if (!pages.retirePage(clientPage.browserPageId, clientPage.placement)) { - throw new Error('browser_page_placement_stale') - } - if (this.host.retireRuntimeOwnedBrowserSessionTab) { - this.host.retireRuntimeOwnedBrowserSessionTab( - clientPage.workspaceId, - clientPage.browserPageId - ) - } else { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(clientPage.workspaceId) - } - return { closed: true } - } - const namedPageId = - typeof params.page === 'string' && params.page.length > 0 ? params.page : null - const explicitPage = namedPageId !== null - const bridge = this.host.getAgentBrowserBridge() - if (!bridge) { - // Why before the refusal: a runtime with no browser session cannot be holding this page - // either, but it can still be carrying the session row that names it. - if ( - namedPageId && - params.worktree && - this.retireGhostBrowserSessionRow( - (await this.host.resolveWorktreeSelector(params.worktree)).id, - namedPageId - ) - ) { - return { closed: true } - } - throw new BrowserError('browser_no_tab', 'No browser session is active') - } - const worktreeId = explicitPage - ? params.worktree - ? (await this.host.resolveWorktreeSelector(params.worktree)).id - : undefined - : await this.resolveBrowserWorktreeId(params.worktree) - - let tabId: string | null = null - if (namedPageId !== null) { - tabId = namedPageId - } else if (params.index !== undefined) { - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - if (params.index < 0 || params.index >= entries.length) { - throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) - } - tabId = entries[params.index][0] - } else { - // Why: try the bridge first; fall back to the renderer for tabs whose webview hasn't mounted yet (e.g. just created). - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) - if (activeEntry) { - tabId = activeEntry[0] - } - } - - // Why: headless serve has no renderer to ask, so destroy the offscreen page directly. - const authoritativeWindow = this.host.getAvailableAuthoritativeWindow() - const offscreen = authoritativeWindow ? null : this.host.getOffscreenBrowserBackend() - if (offscreen) { - // Why: resolve the active page for implicit close so we don't report success while closing nothing. - const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) - if (!resolvedTabId) { - return { closed: false } - } - if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) { - return { closed: true } - } - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${resolvedTabId} was not found${scope}` - ) - } - await offscreen.closeTab(resolvedTabId) - // Why: closeTab only destroys the guest; without retirement, paired clients keep a - // dead session tab until an unrelated republish (closeMobileSessionTab already retires). - if (worktreeId) { - if (this.host.retireRuntimeOwnedBrowserSessionTab) { - this.host.retireRuntimeOwnedBrowserSessionTab(worktreeId, resolvedTabId) - } else { - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) - } - } - return { closed: true } - } - - if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) { - if (this.retireGhostBrowserSessionRow(worktreeId, tabId)) { - return { closed: true } - } - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError('browser_tab_not_found', `Browser page ${tabId} was not found${scope}`) - } - - const win = authoritativeWindow ?? this.host.getAuthoritativeWindow() - const requestId = randomUUID() - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCloseReply', handler) - reject(new Error('Tab close timed out')) - }, 10_000) - - const handler = ( - _event: Electron.IpcMainEvent, - reply: { requestId: string; error?: string; code?: 'browser_tab_not_found' } - ): void => { - if (reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCloseReply', handler) - if (reply.error) { - reject( - reply.code === 'browser_tab_not_found' - ? new BrowserError('browser_tab_not_found', reply.error) - : new Error(reply.error) - ) - } else { - resolve() - } - } - ipcMain.on('browser:tabCloseReply', handler) - // Why: pass worktreeId so the renderer scopes the close correctly instead of falling back to the globally active tab in the wrong worktree. - win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) - }) - - return { closed: true } - } - - private enrichBrowserTabInfo( - tab: BrowserTabListResult['tabs'][number] - ): BrowserTabListResult['tabs'][number] { - const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) - const profile = - browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? - browserSessionRegistry.getDefaultProfile() - return { - ...tab, - worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, - profileId: profile.id, - profileLabel: profile.label - } - } - - private listLogicalBrowserTabs( - worktreeId: string | undefined, - clientPages: readonly RuntimeBrowserClientPage[] - ): BrowserTabListResult['tabs'] { - const clientPageActive = clientPages.some((page) => page.active) - const bridge = this.host.getAgentBrowserBridge() - const serverTabs = - bridge && typeof bridge.tabList === 'function' - ? bridge.tabList(worktreeId).tabs.map((tab) => ({ - ...this.enrichBrowserTabInfo(tab), - active: clientPageActive ? false : tab.active - })) - : [] - const clientTabs = clientPages.map((page, offset) => { - const profile = - browserSessionRegistry.getProfile(page.browserProfileId) ?? - browserSessionRegistry.getDefaultProfile() - return { - browserPageId: page.browserPageId, - index: serverTabs.length + offset, - url: page.url, - title: page.title, - active: page.active, - loadError: null, - certificateFailure: null, - worktreeId: page.workspaceId, - profileId: profile.id, - profileLabel: profile.label - } - }) - const tabs = [...serverTabs, ...clientTabs] - if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { - tabs[0] = { ...tabs[0]!, active: true } - } - return tabs.map((tab, index) => ({ ...tab, index })) - } - - private async assertClientPageWorkspace( - page: RuntimeBrowserClientPage, - selector: string | undefined - ): Promise { - if (!selector) { - return - } - const workspace = await this.host.resolveBrowserWorkspace(selector) - if (workspace.id !== page.workspaceId) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${page.browserPageId} was not found in this worktree` - ) - } - } - - private async resolveClientHostedBrowserPage( - params: BrowserCommandTargetParams - ): Promise { - const pages = this.host.getRuntimeBrowserPageRegistry() - if (params.page) { - const page = pages.getPage(params.page) - if (page) { - await this.assertClientPageWorkspace(page, params.worktree) - } - return page - } - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - return pages.listPages(workspaceId).find((page) => page.active) - } - - private describeBrowserTab( - browserPageId: string, - explicitWorktreeId?: string - ): BrowserTabListResult['tabs'][number] { - const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) - const tab = this.requireAgentBrowserBridge() - .tabList(worktreeId) - .tabs.find((entry) => entry.browserPageId === browserPageId) - if (!tab) { - const scope = worktreeId ? ' in this worktree' : '' - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${browserPageId} was not found${scope}` - ) - } - return this.enrichBrowserTabInfo(tab) - } - - private async createBrowserTabInRenderer( - url: string, - worktreeId: string | undefined, - profileId: string | undefined, - sessionPartition: string | undefined, - activate?: boolean, - requestedPageId?: string - ): Promise<{ browserPageId: string }> { - const win = this.host.getAuthoritativeWindow() - const requestId = randomUUID() - - const browserPageId = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - ipcMain.removeListener('browser:tabCreateReply', handler) - reject(new Error('Tab creation timed out')) - }, 10_000) - - const handler = ( - event: Electron.IpcMainEvent, - reply: { requestId: string; browserPageId?: string; error?: string } - ): void => { - if (event.sender !== win.webContents || reply.requestId !== requestId) { - return - } - clearTimeout(timer) - ipcMain.removeListener('browser:tabCreateReply', handler) - if (reply.error) { - reject(new Error(reply.error)) - } else { - resolve(reply.browserPageId!) - } - } - ipcMain.on('browser:tabCreateReply', handler) - win.webContents.send('browser:requestTabCreate', { - requestId, - url, - worktreeId, - ...(requestedPageId ? { browserPageId: requestedPageId } : {}), - // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. - sessionProfileId: profileId, - sessionPartition, - activate - }) - }) - - return { browserPageId } - } -} +export class RuntimeBrowserCommands extends RuntimeBrowserCommandsWithListLogicalBrowserTabs {} +export type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +export type { RuntimeBrowserCommandHost } from './runtime-browser-commands-browser-command-target-params' diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 7f73de87109..5a3ff3107c5 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -1,2870 +1,13 @@ -/* eslint-disable max-lines -- Why: filesystem, editor-file, and search commands share the same local/SSH path authorization rules. Keeping that IO adapter together prevents separate command paths from drifting on safety checks. */ -import type { ChildProcess } from 'node:child_process' -import { randomUUID } from 'node:crypto' -import { watch as watchFs } from 'node:fs' -import type { FileHandle } from 'node:fs/promises' -import { - chmod, - constants, - copyFile, - lstat, - mkdir, - open, - readdir, - rename, - realpath, - rm, - stat, - writeFile -} from 'node:fs/promises' -import { homedir, tmpdir } from 'node:os' -import { basename, dirname, extname, join } from 'node:path' -import type { SearchOptions, SearchResult } from '../../shared/code-search-types' -import type { DirEntry, FsChangeEvent, MarkdownDocument } from '../../shared/filesystem-entry-types' -import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' -import { - isPathInsideOrEqual, - isRuntimePathAbsolute, - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison, - relativePathInsideRoot, - resolveRuntimePath -} from '../../shared/cross-platform-path' -import { - REMOTE_RPC_MAX_CONTENT_BYTES, - remoteRpcResultExceedsContentBudget -} from '../../shared/remote-rpc-content-budget' -import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' -import { sortDirEntries } from '../../shared/file-name-sort' -import type { - RuntimeFileListResult, - RuntimeFileOpenResult, - RuntimeFileReadChunkResult, - RuntimeFilePreviewResult, - RuntimeFileReadResult, - RuntimeNativeChatFileContext, - RuntimeTerminalPathResolution -} from '../../shared/runtime-types' -import { - closeFileExplorerWatcherInWatcherProcess, - watchFileExplorerInWatcherProcess -} from './file-watcher-host' -import { wslAwareSpawn } from '../git/runner' -import { parseWslPath, toWindowsWslPath } from '../wsl' -import { resolveAuthorizedPath } from '../ipc/filesystem-auth' -import { isENOENT } from '../ipc/filesystem-path-containment' -import { listQuickOpenFiles } from '../ipc/filesystem-list-files' -import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' -import { isQuickOpenQueryTooLarge, QuickOpenPathRanker } from '../../shared/quick-open-path-search' -import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' -import { searchWithGitGrep } from '../ipc/filesystem-search-git' -import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' -import { checkRgAvailable } from '../ipc/rg-availability' -import { - absorbPendingRipgrepSpawnError, - isRipgrepUnavailableExit, - killSpawnedRipgrepProcess -} from '../../shared/ripgrep-process-availability' -import { - listMarkdownDocuments, - markdownDocumentsFromRelativePaths -} from '../ipc/markdown-documents' -import { - buildRgArgs, - createAccumulator, - DEFAULT_SEARCH_MAX_RESULTS, - finalize, - ingestRgJsonLine, - SEARCH_TIMEOUT_MS -} from '../../shared/text-search' -import type { Store } from '../persistence' -import { - getSshFilesystemProvider, - onSshFilesystemProviderRegistered, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import type { FileReadLimits, FileStat, IFilesystemProvider } from '../providers/types' -import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' -import { - isWatcherProcessFailure, - WatcherProcessFailure -} from '../ipc/parcel-watcher-process-failure' -import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' -import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read' -import { - rankRuntimeMobileFilePaths, - RuntimeMobileFilePathSearchCache -} from './runtime-mobile-file-path-search' -import { beginWatcherInstall } from '../ipc/watcher-removal-gate' -import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' -import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' -import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' -import { - NodeFileReadTooLargeError, - readNodeFileWithinLimit -} from '../../shared/node-bounded-file-reader' -import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' -import { - readAuthorizedDocPreviewFile, - type DocPreviewFileAccessRequest, - type DocPreviewFileAccessResult -} from '../../shared/doc-preview-file-access' - -const MOBILE_FILE_LIST_LIMIT = 5000 -// Legacy SSH relays cannot enforce a byte budget; 32 max-length paths stay under one 4 MiB frame. -const QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT = 32 -const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 -const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 -const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 -const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 -const LOCAL_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 -const PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES = Buffer.byteLength( - JSON.stringify({ - content: '', - isBinary: true, - isImage: true, - mimeType: 'application/octet-stream' - }), - 'utf8' -) -const PREVIEW_CONTENT_FIELDS = ['content'] as const - -function previewableBinaryByteLimit(maxContentBytes: number): number { - const base64Bytes = Math.max(0, maxContentBytes - PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES) - return Math.floor(base64Bytes / 4) * 3 -} - -// Why: the stream reader aborts an over-cap read with a raw protocol message; clients key on -// `file_too_large`, so translate it here rather than surfacing internal stream wording. -async function readPreviewFileWithinCap( - provider: IFilesystemProvider, - filePath: string, - limits: FileReadLimits -): Promise { - try { - return await provider.readFile(filePath, limits) - } catch (error) { - if (error instanceof FileReadCapExceededError) { - throw new Error('file_too_large') - } - throw error - } -} - -function assertPreviewWithinTransportBudget( - result: RuntimeFilePreviewResult, - maxContentBytes: number | undefined -): RuntimeFilePreviewResult { - if ( - maxContentBytes !== undefined && - remoteRpcResultExceedsContentBudget(result, maxContentBytes, PREVIEW_CONTENT_FIELDS) - ) { - throw new Error('file_too_large') - } - return result -} - -// Why: previews are reachable only over RPC and base64 inflates them 4/3, so derive the cap from the -// transport ceiling — a hardcoded 10 MiB serializes past the outbound envelope and kills the socket. -export const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = previewableBinaryByteLimit( - REMOTE_RPC_MAX_CONTENT_BYTES -) -const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 -export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 -const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 -const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 -const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED = - 'Remote file changes require a newer Orca client. Update the paired client and try again.' - -function assertRuntimeFileMutationExpectation( - connectionId: string | undefined, - expectedExecutionHostId: string | undefined, - expectedSshTargetId: string | undefined, - expectedSshConnectionGeneration: number | undefined -): void { - if (!expectedExecutionHostId) { - throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED) - } - const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' - if (expectedExecutionHostId !== actualExecutionHostId) { - throw new Error('Workspace host changed; refresh and try again') - } - assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration) -} -// Why: files.watch cleanup is synchronous RPC; track native Parcel unsubscribes so shutdown can drain them. -const pendingRuntimeFileWatcherUnsubscribes = new Set>() - -type RuntimeFileWatcherLease = { - suspend(): Promise - resume(): Promise - forget(): void -} -const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() -// Why: the provider's dispose() stops each watch registration without firing its terminal callback, -// so a dropped SSH transport leaves this watch silently dead — a reconnect's fresh provider is the -// only signal it can be rebuilt from. Keyed like the leases so worktree removal can drop it. -const sshFileExplorerWatchRearms = new Map void>>() -const MOBILE_BINARY_EXTENSIONS = new Set([ - '.avif', - '.bmp', - '.gif', - '.heic', - '.ico', - '.jpeg', - '.jpg', - '.mov', - '.mp3', - '.mp4', - '.pdf', - '.png', - '.webp', - '.zip' -]) -// Mirror of mobile classifyMobileArtifact's image set; SVG/PDF excluded because RN can't decode those data URIs. -const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.ico' -]) - -type RuntimeFileStatLike = { - size?: number - dev?: number - ino?: number - nlink?: number - mtime?: number | Date - mtimeMs?: number - isDirectory?: () => boolean -} - -type TerminalFileGrant = { - id: string - worktreeId: string - absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string - clientId?: string - expiresAt: number - statIdentity: string | null - readOnly: boolean - provenance: 'terminal-output' | 'native-chat' - expiryTimer?: ReturnType -} - -function isMobilePreviewableImagePath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.svg': 'image/svg+xml', - '.webp': 'image/webp', - '.bmp': 'image/bmp', - '.ico': 'image/x-icon', - '.pdf': 'application/pdf' -} - -function trackRuntimeFileWatcherUnsubscribe( - rootPath: string, - unsubscribe: () => Promise -): Promise { - const promise = Promise.resolve() - .then(unsubscribe) - .finally(() => { - pendingRuntimeFileWatcherUnsubscribes.delete(promise) - }) - pendingRuntimeFileWatcherUnsubscribes.add(promise) - void promise.catch((err: unknown) => { - console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) - }) - return promise -} - -function normalizeRuntimeWatcherRoot(rootPath: string): string { - return normalizeRuntimePathForComparison(rootPath) -} - -function runtimeWatcherReleaseKey( - runtimeId: string, - connectionId: string | undefined, - rootPath: string -): string { - // Why: identical absolute paths exist on local and multiple SSH hosts; scope teardown to the host that owns it. - return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) -} - -/** - * Keep an SSH file-explorer watch alive across reconnects. - * - * Why: the previous provider's unwatch handle belongs to the dead transport, so reinstalling on the - * fresh provider is the only way the subscription comes back. Callers get an overflow because the - * events lost while the watch was down can't be replayed. - */ -function armSshFileExplorerWatchRearm(args: { - runtimeId: string - connectionId: string - rootPath: string - callback: (events: FsChangeEvent[]) => void - onTerminalError: (error: Error) => void - signal?: AbortSignal - initialUnwatch: () => void -}): { unsubscribe: () => Promise } { - const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) - let currentUnwatch = args.initialUnwatch - let stopped = false - let reinstalling: Promise | null = null - - const reinstall = async (): Promise => { - const provider = getSshFilesystemProvider(args.connectionId) - if (stopped || !provider) { - return - } - // Why: the old handle is scoped to the dead transport; closing it here would only risk - // unwatching the root we just re-registered on the new one. - const nextUnwatch = await provider.watch(args.rootPath, args.callback, { - signal: args.signal, - onTerminalError: args.onTerminalError - }) - if (stopped) { - nextUnwatch() - return - } - currentUnwatch = nextUnwatch - args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) - } - - const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { - if (registeredId !== args.connectionId || stopped) { - return - } - // Why: reconnect storms can register repeatedly; chain so a second one can't double-install. - const attempt = (reinstalling ?? Promise.resolve()) - .then(reinstall) - .catch((error: unknown) => { - args.onTerminalError(error instanceof Error ? error : new Error(String(error))) - }) - .finally(() => { - if (reinstalling === attempt) { - reinstalling = null - } - }) - reinstalling = attempt - }) - - const stop = (): void => { - stopped = true - unsubscribeRearm() - const rearms = sshFileExplorerWatchRearms.get(key) - rearms?.delete(stop) - if (rearms?.size === 0) { - sshFileExplorerWatchRearms.delete(key) - } - } - const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() - rearms.add(stop) - sshFileExplorerWatchRearms.set(key, rearms) - - return { - unsubscribe: () => { - stop() - const close = async (): Promise => currentUnwatch() - // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the - // unwatch being issued on the same turn the lease releases it. - return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() - } - } -} - -function stopSshFileExplorerWatchRearms(key: string): void { - for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { - stop() - } -} - -function registerRuntimeFileWatcherRelease( - runtimeId: string, - connectionId: string | undefined, - rootPaths: string[], - unsubscribe: () => Promise, - restart: () => Promise<() => Promise>, - onRestoreError: (error: Error) => void -): () => Promise { - const keys = Array.from( - new Set( - rootPaths.map((rootPath) => runtimeWatcherReleaseKey(runtimeId, connectionId, rootPath)) - ) - ) - let currentUnsubscribe: (() => Promise) | null = unsubscribe - let releasePromise: Promise | null = null - let physicalExitPromise: Promise | null = null - let resumePromise: Promise | null = null - let stopPromise: Promise | null = null - let logicallyStopped = false - const removeLease = (): void => { - for (const key of keys) { - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - leases?.delete(lease) - if (leases?.size === 0) { - runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) - } - } - } - const suspend = (): Promise => { - if (releasePromise) { - return releasePromise - } - const release = currentUnsubscribe - if (!release) { - return Promise.resolve() - } - const attempt = trackRuntimeFileWatcherUnsubscribe(rootPaths[0], release) - releasePromise = attempt - void attempt.then( - () => { - if (currentUnsubscribe === release) { - currentUnsubscribe = null - } - releasePromise = null - }, - (error: unknown) => { - if (isWatcherProcessFailure(error) && error.physicalExit) { - const physicalExit = error.physicalExit.then(() => { - if (currentUnsubscribe === release) { - currentUnsubscribe = null - } - releasePromise = null - if (physicalExitPromise === physicalExit) { - physicalExitPromise = null - } - if (logicallyStopped) { - removeLease() - } - }) - physicalExitPromise = physicalExit - } else { - // Why: a synchronous close failure retains the native owner so a later removal or unsubscribe can retry the same handle. - releasePromise = null - } - } - ) - return attempt - } - const lease: RuntimeFileWatcherLease = { - suspend, - resume: () => { - if (logicallyStopped || (currentUnsubscribe && !physicalExitPromise)) { - return Promise.resolve() - } - if (resumePromise) { - return physicalExitPromise ? Promise.resolve() : resumePromise - } - // Why: a timed-out child still owns native handles until physical exit; join that owner before starting a replacement. - const resumesAfterPhysicalExit = physicalExitPromise !== null - const attempt = Promise.resolve(physicalExitPromise ?? releasePromise) - .then(async () => { - if (logicallyStopped) { - return - } - const nextUnsubscribe = await restart() - if (logicallyStopped) { - await nextUnsubscribe() - return - } - currentUnsubscribe = nextUnsubscribe - }) - .catch((error: unknown) => { - const restoreError = error instanceof Error ? error : new Error(String(error)) - queueMicrotask(() => onRestoreError(restoreError)) - throw restoreError - }) - .finally(() => { - resumePromise = null - }) - resumePromise = attempt - if (resumesAfterPhysicalExit) { - void attempt.catch(() => {}) - return Promise.resolve() - } - return attempt - }, - forget: () => { - logicallyStopped = true - removeLease() - } - } - for (const key of keys) { - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) ?? new Set() - leases.add(lease) - runtimeFileWatcherLeasesByOwnerAndRoot.set(key, leases) - } - return () => { - if (stopPromise) { - return stopPromise - } - logicallyStopped = true - const release = - resumePromise && !physicalExitPromise - ? Promise.resolve(resumePromise) - .catch(() => undefined) - .then(suspend) - : suspend() - const attempt = release.then(removeLease).catch((error: unknown) => { - stopPromise = null - throw error - }) - stopPromise = attempt - return attempt - } -} - -export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { - await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) -} - -export function _getRuntimeFileWatcherReleaseCountForTests(): number { - const leases = new Set() - for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { - for (const lease of rootLeases) { - leases.add(lease) - } - } - return leases.size -} - -export function _resetRuntimeFileWatcherLeasesForTests(): void { - const leases = new Set() - for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { - for (const lease of rootLeases) { - leases.add(lease) - } - } - for (const lease of leases) { - lease.forget() - } - for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { - stopSshFileExplorerWatchRearms(key) - } - runtimeFileWatcherLeasesByOwnerAndRoot.clear() -} - -export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } -export type ResolvedRuntimeFileTarget = { - worktree: ResolvedRuntimeFileWorktree - connectionId?: string -} - -export function getRuntimeFileTargetExecutionHostId( - target: ResolvedRuntimeFileTarget -): ExecutionHostId { - return ( - target.worktree.hostId ?? - (target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local') - ) -} - -export type RuntimeFileCommandHost = { - getRuntimeId(): string - requireStore(): Store - resolveWorktreeSelector(selector: string): Promise - resolveRuntimeFileTarget(selector: string): Promise - resolveKnownWorkspaceFileTarget?( - absolutePath: string, - executionHostId: ExecutionHostId - ): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null> - resolveTerminalCwd?(terminalHandle: string): string | null | Promise - resolveTerminalContext?( - terminalHandle: string - ): { worktreeId: string; connectionId: string | null } | null - resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise - hasRecentTerminalOutputPath?( - terminalHandle: string, - pathText: string, - absolutePath: string - ): boolean | Promise - hasRecentNativeChatOutputPath?( - worktreeId: string, - context: RuntimeNativeChatFileContext, - pathText: string, - absolutePath: string - ): boolean | Promise - resolveRuntimeGitTarget( - selector: string - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> - openFile( - worktreeId: string, - filePath: string, - relativePath: string, - runtimeEnvironmentId?: string | null - ): void - openDiff( - worktreeId: string, - filePath: string, - relativePath: string, - staged: boolean, - runtimeEnvironmentId?: string | null - ): void -} - -export class RuntimeFileCommands { - private activeRuntimeTextSearches = new Map() - private terminalFileGrants = new Map() - private mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( - MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, - MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS - ) - - constructor(private readonly host: RuntimeFileCommandHost) {} - - async listMobileFiles( - worktreeSelector: string, - options: { signal?: AbortSignal } = {} - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const files = connectionId - ? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal) - : await listQuickOpenFiles(worktree.path, store, undefined, options.signal) - const entries = files - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - .slice(0, MOBILE_FILE_LIST_LIMIT) - .map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })) - - return { - worktree: worktree.id, - rootPath: worktree.path, - files: entries, - totalCount: files.length, - truncated: files.length > MOBILE_FILE_LIST_LIMIT - } - } - - async searchMobileFilePaths( - worktreeSelector: string, - query: string, - limit: number - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` - const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { - const listed = connectionId - ? await this.listRemoteMobileFiles( - worktree.path, - connectionId, - MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 - ) - : await listQuickOpenFiles( - worktree.path, - store, - undefined, - undefined, - MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 - ) - const safePaths = listed - .filter((relativePath) => isSafeMobileRelativePath(relativePath)) - .sort((a, b) => a.localeCompare(b)) - return { - paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), - totalCount: safePaths.length, - truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT - } - }) - const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) - return { - worktree: worktree.id, - rootPath: worktree.path, - files: matches.paths.map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })), - totalCount: matches.totalCount, - truncated: inventory.truncated || matches.totalCount > limit - } - } - - async searchQuickOpenFilePaths( - worktreeSelector: string, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - const result = - !query.trim() || isQuickOpenQueryTooLarge(query) - ? { paths: [], totalCount: 0, truncated: false } - : connectionId - ? await this.searchRemoteQuickOpenFilePaths( - worktree.path, - connectionId, - query, - limit, - excludePaths, - signal - ) - : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { - query, - limit, - excludePaths, - signal - }) - return { - worktree: worktree.id, - rootPath: worktree.path, - files: result.paths.map((relativePath) => ({ - relativePath, - basename: basenameFromRelativePath(relativePath), - kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) - })), - totalCount: result.totalCount, - truncated: result.truncated - } - } - - async openMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - // Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile. - const kind = isMobilePreviewableImagePath(relativePath) - ? 'image' - : isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - if (kind === 'binary') { - return { worktree: worktree.id, relativePath, kind, opened: false } - } - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - // Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab. - await this.assertMobileOpenTargetExists(filePath, connectionId) - // Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId. - this.host.openFile(worktree.id, filePath, relativePath, undefined) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - private async assertMobileOpenTargetExists( - filePath: string, - connectionId?: string - ): Promise { - try { - await (connectionId - ? this.statRemoteTerminalPath(filePath, connectionId) - : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) - } catch (error) { - if ( - isENOENT(error) || - (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) - ) { - throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) - } - throw error - } - } - - async openMobileDiff( - worktreeSelector: string, - relativePath: string, - staged: boolean - ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - const kind = isMobileBinaryPath(relativePath) - ? 'binary' - : isMobileMarkdownPath(relativePath) - ? 'markdown' - : 'text' - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - // Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId. - this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined) - return { worktree: worktree.id, relativePath, kind, opened: true } - } - - async readMobileFile( - worktreeSelector: string, - relativePath: string - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - if (!isSafeMobileRelativePath(relativePath)) { - throw new Error('invalid_relative_path') - } - if (isMobileBinaryPath(relativePath)) { - throw new Error('binary_file') - } - - const filePath = joinWorktreeRelativePath(worktree.path, relativePath) - const content = connectionId - ? await this.readRemoteMobileFile(filePath, connectionId) - : await readLocalMobileFile(filePath, store) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: worktree.id, - relativePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - // Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root. - async resolveTerminalPath( - worktreeSelector: string, - pathText: string, - cwd?: string | null, - clientId?: string, - terminalHandle?: string | null, - crossWorkspace?: boolean, - nativeChatContext?: RuntimeNativeChatFileContext | null - ): Promise { - const store = this.host.requireStore() - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const { worktree, connectionId } = target - // Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap. - const normalizedTerminalHandle = - terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null - const terminalCwd = normalizedTerminalHandle - ? await this.host.resolveTerminalCwd?.(normalizedTerminalHandle) - : null - const terminalFileUriHostname = normalizedTerminalHandle - ? await this.host.resolveTerminalFileUriHostname?.(normalizedTerminalHandle) - : null - const base = terminalCwd || (cwd && cwd.trim().length > 0 ? cwd : worktree.path) - - const empty: RuntimeTerminalPathResolution = { - worktree: worktree.id, - relativePath: null, - absolutePath: null, - exists: false, - isDirectory: false - } - - // Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home. - const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\') - if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) { - return empty - } - const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText - const absolutePath = resolveTerminalAbsolutePath({ - base, - expanded, - worktreePath: worktree.path, - connectionId, - terminalFileUriHostname - }) - const relativePath = relativePathInsideRoot(worktree.path, absolutePath) - // Why: clients that predate crossWorkspace reuse their own worktree id for the - // follow-up files.open, so retargeting to a sibling workspace must be opt-in. - const knownWorkspaceTarget = - crossWorkspace && relativePath === null - ? await this.host.resolveKnownWorkspaceFileTarget?.( - absolutePath, - getRuntimeFileTargetExecutionHostId(target) - ) - : null - const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree - const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId - const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath - - try { - if ( - ownedRelativePath !== null && - (ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath)) - ) { - const stats = ownedConnectionId - ? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId) - : await stat(await resolveAuthorizedPath(absolutePath, store)) - return { - worktree: ownedWorktree.id, - relativePath: ownedRelativePath, - absolutePath, - exists: true, - isDirectory: stats.isDirectory(), - openTarget: stats.isDirectory() - ? undefined - : { - kind: 'worktree-file', - provider: ownedConnectionId ? 'ssh' : 'local', - relativePath: ownedRelativePath, - absolutePath - } - } - } - - if ( - nativeChatContext && - (await this.host.hasRecentNativeChatOutputPath?.( - worktree.id, - nativeChatContext, - pathText, - absolutePath - )) - ) { - const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId) - return await this.resolveAbsoluteFileGrant({ - worktreeId: worktree.id, - artifactPath, - connectionId, - clientId, - readOnly: true, - provenance: 'native-chat' - }) - } - - // Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths. - if (!normalizedTerminalHandle || !terminalCwd) { - return { ...empty, relativePath, absolutePath } - } - const terminalContext = this.host.resolveTerminalContext?.(normalizedTerminalHandle) - if ( - !terminalContext || - terminalContext.worktreeId !== worktree.id || - (terminalContext.connectionId ?? undefined) !== connectionId - ) { - return { ...empty, relativePath, absolutePath } - } - const artifactPath = await this.resolveAllowedTerminalArtifactPath({ - absolutePath, - connectionId, - worktreePath: worktree.path - }) - if (!artifactPath) { - return { ...empty, relativePath, absolutePath } - } - if ( - !(await this.host.hasRecentTerminalOutputPath?.( - normalizedTerminalHandle, - provenancePathCandidate(pathText, absolutePath), - artifactPath - )) - ) { - return { ...empty, relativePath, absolutePath } - } - return await this.resolveAbsoluteFileGrant({ - worktreeId: worktree.id, - artifactPath, - rejectedAbsolutePath: absolutePath, - connectionId, - clientId - }) - } catch (error) { - // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. - if ( - isENOENT(error) || - (ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) - ) { - return { - ...empty, - worktree: ownedWorktree.id, - relativePath: ownedRelativePath, - absolutePath - } - } - throw error - } - } - - // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). - private static isRemoteNotFoundErrorMessage(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /\bENOENT\b|no such file|not found|does not exist/i.test(message) - } - - private async statRemoteTerminalPath( - absolutePath: string, - connectionId: string - ): Promise boolean }> { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const stats = await provider.stat(absolutePath) - return { ...stats, isDirectory: () => stats.type === 'directory' } - } - - private async resolveAllowedTerminalArtifactPath(args: { - absolutePath: string - connectionId?: string - worktreePath: string - }): Promise { - if (args.connectionId) { - return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId) - } - return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) - } - - private async resolveNativeChatArtifactPath( - absolutePath: string, - connectionId?: string - ): Promise { - if (!connectionId) { - return canonicalPathForArtifactComparison(absolutePath) - } - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.realpath(absolutePath) - } - - private async resolveAbsoluteFileGrant(args: { - worktreeId: string - artifactPath: string - rejectedAbsolutePath?: string - connectionId?: string - clientId?: string - readOnly?: boolean - provenance?: TerminalFileGrant['provenance'] - }): Promise { - const stats = args.connectionId - ? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) - : await this.statLocalTerminalPath(args.artifactPath) - const isDirectory = stats.isDirectory() - if (!isDirectory && isTerminalArtifactHardLinked(stats)) { - return { - worktree: args.worktreeId, - relativePath: null, - absolutePath: args.rejectedAbsolutePath ?? args.artifactPath, - exists: false, - isDirectory: false - } - } - const grant = isDirectory - ? null - : this.createTerminalFileGrant({ - worktreeId: args.worktreeId, - absolutePath: args.artifactPath, - provider: args.connectionId ? 'ssh' : 'local', - connectionId: args.connectionId, - clientId: args.clientId, - readOnly: args.readOnly === true, - provenance: args.provenance ?? 'terminal-output', - stats - }) - return { - worktree: args.worktreeId, - relativePath: null, - absolutePath: args.artifactPath, - exists: true, - isDirectory, - openTarget: grant - ? { - kind: 'absolute-file', - provider: grant.provider, - absolutePath: args.artifactPath, - grantId: grant.id, - ...(grant.readOnly ? { readOnly: true } : {}) - } - : undefined - } - } - - private async resolveAllowedRemoteTerminalArtifactPath( - absolutePath: string, - connectionId: string - ): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const roots = ['/tmp', '/private/tmp'] - const providerTempDir = await provider.getTempDir?.().catch(() => null) - if (providerTempDir) { - roots.push(providerTempDir) - } - if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) { - return null - } - const [realArtifactPath, ...realRoots] = await Promise.all([ - provider.realpath(absolutePath), - ...roots.map((root) => provider.realpath(root).catch(() => root)) - ]) - // Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary. - return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath)) - ? realArtifactPath - : null - } - - private async statLocalTerminalPath( - absolutePath: string - ): Promise boolean }> { - await assertLocalTerminalArtifactPathStillCanonical(absolutePath) - const handle = await open(absolutePath, 'r') - try { - return handle.stat() - } finally { - await handle.close() - } - } - - private createTerminalFileGrant(args: { - worktreeId: string - absolutePath: string - provider: 'local' | 'ssh' - connectionId?: string - clientId?: string - readOnly?: boolean - provenance: TerminalFileGrant['provenance'] - stats: RuntimeFileStatLike - }): TerminalFileGrant { - assertTerminalArtifactNotHardLinked(args.stats) - const grant: TerminalFileGrant = { - id: randomUUID(), - worktreeId: args.worktreeId, - absolutePath: args.absolutePath, - provider: args.provider, - ...(args.connectionId ? { connectionId: args.connectionId } : {}), - ...(args.clientId ? { clientId: args.clientId } : {}), - expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, - statIdentity: terminalFileStatIdentity(args.stats), - readOnly: args.readOnly === true, - provenance: args.provenance - } - this.terminalFileGrants.set(grant.id, grant) - this.scheduleTerminalFileGrantExpiry(grant) - return grant - } - - private async requireTerminalFileGrant( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string - ): Promise<{ grant: TerminalFileGrant; target: ResolvedRuntimeFileTarget }> { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - this.pruneExpiredTerminalFileGrants() - const grant = this.terminalFileGrants.get(grantId) - if (!grant) { - throw new Error('terminal_file_grant_expired') - } - if (grant.expiresAt <= Date.now()) { - this.releaseTerminalFileGrant(grantId, grant) - throw new Error('terminal_file_grant_expired') - } - if ( - grant.worktreeId !== target.worktree.id || - grant.absolutePath !== absolutePath || - grant.connectionId !== target.connectionId || - grant.clientId !== clientId - ) { - throw new Error('terminal_file_grant_mismatch') - } - return { grant, target } - } - - private refreshTerminalFileGrant(grant: TerminalFileGrant): void { - grant.expiresAt = Date.now() + TERMINAL_FILE_GRANT_TTL_MS - this.scheduleTerminalFileGrantExpiry(grant) - } - - private pruneExpiredTerminalFileGrants(): void { - const now = Date.now() - for (const [id, grant] of this.terminalFileGrants) { - if (grant.expiresAt <= now) { - this.releaseTerminalFileGrant(id, grant) - } - } - } - - revokeTerminalFileGrantsForClient(clientId: string): void { - for (const [id, grant] of this.terminalFileGrants) { - if (grant.clientId === clientId) { - this.releaseTerminalFileGrant(id, grant) - } - } - } - - private releaseTerminalFileGrant(id: string, grant: TerminalFileGrant): void { - this.terminalFileGrants.delete(id) - if (grant.expiryTimer) { - clearTimeout(grant.expiryTimer) - grant.expiryTimer = undefined - } - } - - private scheduleTerminalFileGrantExpiry(grant: TerminalFileGrant): void { - if (grant.expiryTimer) { - clearTimeout(grant.expiryTimer) - } - grant.expiryTimer = setTimeout( - () => { - if (this.terminalFileGrants.get(grant.id) === grant && grant.expiresAt <= Date.now()) { - this.releaseTerminalFileGrant(grant.id, grant) - } - }, - Math.max(1, grant.expiresAt - Date.now()) - ) - grant.expiryTimer.unref?.() - } - - async readTerminalArtifactFile( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string - ): Promise { - const { grant, target } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (isMobileBinaryPath(grant.absolutePath)) { - throw new Error('binary_file') - } - let content: string - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - content = await this.readRemoteTerminalArtifactFile( - provider, - grant, - MOBILE_FILE_READ_MAX_BYTES - ) - } else { - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - content = await readLocalTerminalArtifactFileFromHandle(handle, grant) - } finally { - await handle.close() - } - } - this.refreshTerminalFileGrant(grant) - const truncated = truncateMobileFilePreview(content) - - return { - worktree: target.worktree.id, - relativePath: grant.absolutePath, - content: truncated.content, - truncated: truncated.truncated, - byteLength: truncated.byteLength - } - } - - async readTerminalArtifactPreview( - worktreeSelector: string, - grantId: string, - absolutePath: string, - clientId?: string, - maxContentBytes?: number - ): Promise { - const { grant } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (grant.connectionId) { - const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) - this.refreshTerminalFileGrant(grant) - return assertPreviewWithinTransportBudget( - await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes), - maxContentBytes - ) - } - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - const preview = await readLocalTerminalArtifactPreviewFromHandle( - handle, - grant, - maxContentBytes - ) - this.refreshTerminalFileGrant(grant) - return assertPreviewWithinTransportBudget(preview, maxContentBytes) - } finally { - await handle.close() - } - } - - async writeTerminalArtifactFile( - worktreeSelector: string, - grantId: string, - absolutePath: string, - content: string, - clientId?: string - ): Promise<{ ok: true }> { - if (Buffer.byteLength(content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const { grant } = await this.requireTerminalFileGrant( - worktreeSelector, - grantId, - absolutePath, - clientId - ) - if (grant.readOnly) { - throw new Error('terminal_file_grant_read_only') - } - if (isMobileBinaryPath(grant.absolutePath)) { - throw new Error('binary_file') - } - if (grant.connectionId) { - const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant) - if (fileStat.type === 'directory') { - throw new Error('Cannot write to a directory') - } - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - if (!provider.writeTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - const nextStat = await provider.writeTerminalArtifact( - grant.absolutePath, - content, - this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES) - ) - grant.statIdentity = terminalFileStatIdentity(nextStat) - this.refreshTerminalFileGrant(grant) - return { ok: true } - } - - let originalMode: number | null = null - const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - const fileStats = await handle.stat() - originalMode = fileStats.mode - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - assertTerminalFileGrantFresh(grant, fileStats) - if ( - isBinaryBuffer(await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1)) - ) { - throw new Error('binary_file') - } - } finally { - await handle.close() - } - const tempPath = join( - dirname(grant.absolutePath), - `.${basename(grant.absolutePath)}.${randomUUID()}.tmp` - ) - try { - await writeFile(tempPath, content, { encoding: 'utf-8', flag: 'wx' }) - if (typeof originalMode === 'number') { - await chmod(tempPath, originalMode & 0o7777) - } - const freshHandle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) - try { - assertTerminalFileGrantFresh(grant, await freshHandle.stat()) - } finally { - await freshHandle.close() - } - await rename(tempPath, grant.absolutePath) - grant.statIdentity = terminalFileStatIdentity( - await this.statLocalTerminalPath(grant.absolutePath) - ) - this.refreshTerminalFileGrant(grant) - return { ok: true } - } finally { - await rm(tempPath, { force: true }).catch(() => {}) - } - } - - private async readRemoteTerminalArtifactPreview( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxContentBytes: number | undefined - ): Promise { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes) - if ( - !preview.isBinary && - Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - preview.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(preview.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return preview - } - - private async readRemoteTerminalArtifactFile( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } - - private async readRemoteTerminalArtifact( - provider: IFilesystemProvider, - grant: TerminalFileGrant, - maxBytes: number - ): Promise { - if (!provider.readTerminalArtifact) { - throw new Error('terminal_file_grant_unavailable') - } - return provider.readTerminalArtifact( - grant.absolutePath, - this.terminalArtifactAccessOptions(grant, maxBytes) - ) - } - - private terminalArtifactAccessOptions( - grant: TerminalFileGrant, - maxBytes: number - ): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } { - return { - expectedRealPath: grant.absolutePath, - expectedStatIdentity: grant.statIdentity, - maxBytes - } - } - - private async assertRemoteTerminalFileGrantFreshForRead( - grant: TerminalFileGrant - ): Promise { - const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant) - return provider - } - - private async assertRemoteTerminalFileGrantFresh( - grant: TerminalFileGrant - ): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> { - const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) - const fileStat = await provider.stat(grant.absolutePath) - assertTerminalFileGrantFresh(grant, fileStat) - return { provider, fileStat } - } - - private async assertRemoteTerminalFileGrantPathStillCanonical( - grant: TerminalFileGrant - ): Promise { - if (!grant.connectionId) { - throw new Error('terminal_file_grant_mismatch') - } - const provider = getSshFilesystemProvider(grant.connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const canonicalPath = - grant.provenance === 'native-chat' - ? await provider.realpath(grant.absolutePath) - : await this.resolveAllowedRemoteTerminalArtifactPath( - grant.absolutePath, - grant.connectionId - ) - // Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path. - if (canonicalPath !== grant.absolutePath) { - throw new Error('terminal_file_grant_stale') - } - return provider - } - - async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: re-sort locally — the remote relay may be an older build with - // lexicographic ordering. - return sortDirEntries(await provider.readDir(target.path)) - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const entries = await readdir(dirPath, { withFileTypes: true }) - const mapped = entries.map((entry) => ({ - name: entry.name, - isDirectory: isRuntimeDirectoryEntry(entry), - isSymlink: entry.isSymbolicLink() - })) - return sortDirEntries(mapped) - } - - async watchFileExplorer( - worktreeSelector: string, - callback: (events: FsChangeEvent[]) => void, - onTerminalError: (error: Error) => void = () => undefined, - signal?: AbortSignal - ): Promise<() => Promise> { - const target = await this.resolveFileExplorerPath(worktreeSelector, '') - const open = async (): Promise<{ - unsubscribe: () => Promise - rootPaths: string[] - }> => { - const finishInstall = beginWatcherInstall(target.path, target.connectionId) - try { - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - // Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out. - const close = await provider.watch(target.path, callback, { signal, onTerminalError }) - const rearm = armSshFileExplorerWatchRearm({ - runtimeId: this.host.getRuntimeId(), - connectionId: target.connectionId, - rootPath: target.path, - callback, - onTerminalError, - signal, - initialUnwatch: close - }) - return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } - } - - const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const rootStats = await stat(rootPath) - if (!rootStats.isDirectory()) { - throw new Error('not_a_directory') - } - if (process.platform === 'win32') { - const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError) - return { unsubscribe: close, rootPaths: [target.path, rootPath] } - } - // Why: the forked watcher keeps the blocking crawl and native faults out of the main/`serve` process (issues #5308, #8212). - const dispose = await watchFileExplorerInWatcherProcess( - rootPath, - callback, - onTerminalError, - signal - ) - return { unsubscribe: dispose, rootPaths: [target.path, rootPath] } - } finally { - finishInstall() - } - } - const initial = await open() - return registerRuntimeFileWatcherRelease( - this.host.getRuntimeId(), - target.connectionId, - initial.rootPaths, - initial.unsubscribe, - async () => (await open()).unsubscribe, - onTerminalError - ) - } - - async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - await Promise.all(Array.from(leases, (lease) => lease.suspend())) - } - if (!connectionId) { - // Why: setup can fail before registerRuntimeFileWatcherRelease publishes its callback while the child owner still lives. - const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) - await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath) - } - } - - async restoreFileExplorerWatchersAfterFailedRemoval( - rootPath: string, - connectionId?: string - ): Promise { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - await Promise.all(Array.from(leases, (lease) => lease.resume())) - } - } - - forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { - const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) - // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted - // worktree and re-watch it on the next reconnect. - stopSshFileExplorerWatchRearms(key) - const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) - if (leases) { - for (const lease of Array.from(leases)) { - lease.forget() - } - } - } - - async readFileExplorerPreview( - worktreeSelector: string, - relativePath: string, - maxContentBytes?: number - ): Promise { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStats = await provider.stat(target.path) - if (fileStats.size > binaryMaxBytes) { - throw new Error('file_too_large') - } - const result = await readPreviewFileWithinCap(provider, target.path, { - maxBinaryBytes: binaryMaxBytes, - maxTextBytes: MOBILE_FILE_READ_MAX_BYTES - }) - // Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so - // hold it to the same decoded limit the local branch enforces before reading. - if ( - !result.isBinary && - Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES - ) { - throw new Error('file_too_large') - } - if ( - result.isBinary && - maxContentBytes !== undefined && - Buffer.byteLength(result.content, 'utf8') > maxContentBytes - ) { - throw new Error('file_too_large') - } - return assertPreviewWithinTransportBudget(result, maxContentBytes) - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] - const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES - let buffer: Buffer - try { - buffer = (await readNodeFileWithinLimit(filePath, maxBytes)).buffer - } catch (error) { - if (error instanceof NodeFileReadTooLargeError) { - throw new Error('file_too_large') - } - throw error - } - if (mimeType) { - return assertPreviewWithinTransportBudget( - { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType - }, - maxContentBytes - ) - } - - if (isBinaryBuffer(buffer)) { - return assertPreviewWithinTransportBudget({ content: '', isBinary: true }, maxContentBytes) - } - return assertPreviewWithinTransportBudget( - { content: buffer.toString('utf-8'), isBinary: false }, - maxContentBytes - ) - } - - async readDocPreviewFile( - worktreeSelector: string, - relativePath: string, - entryRelativePath: string, - implicitRootRelativePath: string | null, - authorizedRootRelativePaths: string[], - maxContentBytes?: number - ): Promise { - const relativePaths = [ - '', - entryRelativePath, - relativePath, - ...(implicitRootRelativePath === null ? [] : [implicitRootRelativePath]), - ...authorizedRootRelativePaths - ] - const [boundary, entry, target, ...authorityRoots] = await this.resolveFileExplorerPaths( - worktreeSelector, - relativePaths - ) - const implicitRoot = implicitRootRelativePath === null ? null : authorityRoots[0] - const authorizedRoots = authorityRoots.slice(implicitRoot === null ? 0 : 1) - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - const request: DocPreviewFileAccessRequest = { - boundaryPath: boundary.path, - entryPath: entry.path, - implicitRootPath: implicitRoot?.path ?? null, - authorizedRootPaths: authorizedRoots.map((root) => root.path), - targetPath: target.path, - maxTextBytes: MOBILE_FILE_READ_MAX_BYTES, - maxBinaryBytes: binaryMaxBytes - } - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId && !provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - if (target.connectionId && !provider?.readDocPreviewFile) { - throw new Error('Secure document previews require a newer SSH relay') - } - const result = provider?.readDocPreviewFile - ? await provider.readDocPreviewFile(request) - : await readAuthorizedDocPreviewFile(request) - return assertPreviewWithinTransportBudget(result, maxContentBytes) - } - - async readFileExplorerChunk( - worktreeSelector: string, - relativePath: string, - offset: number, - length: number - ): Promise { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(target.path) - if (fileStat.type === 'directory') { - throw new Error('Cannot download a directory') - } - return readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length) - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const fileStats = await stat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot download a directory') - } - const handle = await open(filePath, 'r') - try { - const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) - const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) - const chunk = buffer.subarray(0, bytesRead) - return { - contentBase64: chunk.toString('base64'), - bytesRead, - eof: offset + bytesRead >= fileStats.size - } - } finally { - await handle.close() - } - } - - async writeFileExplorerFile( - worktreeSelector: string, - relativePath: string, - content: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFile(target.path, content) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - try { - const fileStats = await lstat(filePath) - if (fileStats.isDirectory()) { - throw new Error('Cannot write to a directory') - } - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } - await writeFile(filePath, content, 'utf-8') - return { ok: true } - } - - async writeFileExplorerFileBase64( - worktreeSelector: string, - relativePath: string, - contentBase64: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const content = Buffer.from(contentBase64, 'base64') - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFileBase64(target.path, contentBase64) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, content, { flag: 'wx' }) - return { ok: true } - } - - async writeFileExplorerFileBase64Chunk( - worktreeSelector: string, - relativePath: string, - contentBase64: string, - append: boolean, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const content = Buffer.from(contentBase64, 'base64') - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.writeFileBase64Chunk(target.path, contentBase64, append) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) - return { ok: true } - } - - async createFileExplorerFile( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createFile(target.path) - return { ok: true } - } - - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirname(filePath), { recursive: true }) - try { - await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) - } catch (error) { - rethrowRuntimeFileCreateError(error, filePath) - } - return { ok: true } - } - - async createFileExplorerDir( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createDir(target.path) - return { ok: true } - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await assertRuntimePathDoesNotExist(dirPath) - await mkdir(dirPath, { recursive: false }) - return { ok: true } - } - - async createFileExplorerDirNoClobber( - worktreeSelector: string, - relativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.createDirNoClobber(target.path) - return { ok: true } - } - - const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - await mkdir(dirPath, { recursive: false }) - return { ok: true } - } - - async commitFileExplorerUpload( - worktreeSelector: string, - tempRelativePath: string, - finalRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ - tempRelativePath, - finalRelativePath - ]) - assertRuntimeFileMutationExpectation( - tempTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = tempTarget.connectionId - ? getSshFilesystemProvider(tempTarget.connectionId) - : null - if (tempTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.copy(tempTarget.path, finalTarget.path) - await provider.deletePath(tempTarget.path, false).catch(() => {}) - return { ok: true } - } - - const store = this.host.requireStore() - const tempPath = await resolveAuthorizedPath(tempTarget.path, store) - const finalPath = await resolveAuthorizedPath(finalTarget.path, store) - await mkdir(dirname(finalPath), { recursive: true }) - await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL) - await rm(tempPath, { force: true }) - return { ok: true } - } - - async renameFileExplorerPath( - worktreeSelector: string, - oldRelativePath: string, - newRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ - oldRelativePath, - newRelativePath - ]) - assertRuntimeFileMutationExpectation( - oldTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = oldTarget.connectionId - ? getSshFilesystemProvider(oldTarget.connectionId) - : null - if (oldTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.renameNoClobber(oldTarget.path, newTarget.path) - return { ok: true } - } - - const store = this.host.requireStore() - const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) - const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) - await renameLocalPathSerializedByDestination(oldPath, newPath) - return { ok: true } - } - - async copyFileExplorerPath( - worktreeSelector: string, - sourceRelativePath: string, - destinationRelativePath: string, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths( - worktreeSelector, - [sourceRelativePath, destinationRelativePath] - ) - assertRuntimeFileMutationExpectation( - sourceTarget.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = sourceTarget.connectionId - ? getSshFilesystemProvider(sourceTarget.connectionId) - : null - if (sourceTarget.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.copy(sourceTarget.path, destinationTarget.path) - return { ok: true } - } - - const store = this.host.requireStore() - const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, { - preserveSymlink: true - }) - const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, { - preserveSymlink: true - }) - await mkdir(dirname(destinationPath), { recursive: true }) - // Why: COPYFILE_EXCL preserves the no-clobber invariant of the local shell copy IPC (caller already deconflicts names). - await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) - return { ok: true } - } - - async deleteFileExplorerPath( - worktreeSelector: string, - relativePath: string, - recursive?: boolean, - expectedSshConnectionGeneration?: number, - expectedSshTargetId?: string, - expectedExecutionHostId?: string - ): Promise<{ ok: true }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - assertRuntimeFileMutationExpectation( - target.connectionId, - expectedExecutionHostId, - expectedSshTargetId, - expectedSshConnectionGeneration - ) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - await provider.deletePath(target.path, recursive) - return { ok: true } - } - - const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), { - preserveSymlink: true - }) - // Why: a non-local runtime has no client Trash; this delete is permanent, so the renderer confirms before calling. - await rm(targetPath, { recursive: recursive === true, force: true }) - return { ok: true } - } - - async searchRuntimeFiles( - worktreeSelector: string, - options: Omit - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - const rootPath = target.worktree.path - const searchOptions = { ...options, rootPath } - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - return provider.search(searchOptions) - } - return this.searchLocalRuntimeFiles(rootPath, searchOptions) - } - - async listRuntimeFiles( - worktreeSelector: string, - options: { - excludePaths?: string[] - maxContentBytes?: number - maxResults?: number - signal?: AbortSignal - } = {} - ): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - return [] - } - const maxResults = - options.maxResults ?? - (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) - const files = await provider.listFiles(target.worktree.path, { - excludePaths: options.excludePaths, - maxResults, - signal: options.signal - }) - return options.maxContentBytes === undefined - ? files - : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) - } - return listQuickOpenFiles( - target.worktree.path, - this.host.requireStore(), - options.excludePaths, - options.signal, - options.maxResults, - options.maxContentBytes - ) - } - - async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const relativePaths = await provider.listFiles(target.worktree.path) - return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) - } - return listMarkdownDocuments(target.worktree.path) - } - - async statRuntimeFile( - worktreeSelector: string, - relativePath: string - ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { - const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) - const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null - if (target.connectionId) { - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(target.path) - return { - size: fileStat.size, - isDirectory: fileStat.type === 'directory', - mtime: fileStat.mtime - } - } - const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) - const stats = await stat(filePath) - return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } - } - - private async searchLocalRuntimeFiles( - rootPath: string, - options: SearchOptions - ): Promise { - const store = this.host.requireStore() - const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) - const localGitOptions = getLocalGitOptionsForRegisteredWorktree( - store, - rootPath, - authorizedRootPath - ) - const maxResults = Math.max( - 1, - Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) - ) - const wslInfo = parseWslPath(authorizedRootPath) - if ( - (wslInfo || localGitOptions.wslDistro) && - !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) - ) { - return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) - } - - return new Promise((resolvePromise) => { - const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` - const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - const previousChild = this.activeRuntimeTextSearches.get(searchKey) - if (previousChild) { - killSpawnedRipgrepProcess(previousChild) - } - - const acc = createAccumulator() - let stdoutBuffer = '' - let resolved = false - let processErrorObserved = false - let unavailableExitObserved = false - let child: ChildProcess | null = null - const transformAbsPath = wslInfo - ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) - : undefined - - const finish = (result: SearchResult | PromiseLike): void => { - if (resolved) { - return - } - resolved = true - if (this.activeRuntimeTextSearches.get(searchKey) === child) { - this.activeRuntimeTextSearches.delete(searchKey) - } - cleanupListeners() - resolvePromise(result) - } - const resolveOnce = (): void => finish(finalize(acc)) - const resolveWithoutRipgrep = (): void => - finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) - - let killTimeout: ReturnType | null = null - const cleanupListeners = (): void => { - if (killTimeout) { - clearTimeout(killTimeout) - killTimeout = null - } - child?.stdout?.off('data', onStdoutData) - child?.stderr?.off('data', onStderrData) - child?.off('error', onError) - child?.off('close', onClose) - if (child) { - absorbPendingRipgrepSpawnError(child, { - errorObserved: processErrorObserved, - unavailableExitObserved - }) - } - } - - const processLine = (line: string): void => { - const verdict = ingestRgJsonLine( - line, - authorizedRootPath, - acc, - maxResults, - transformAbsPath - ) - if (verdict === 'stop' && child) { - killSpawnedRipgrepProcess(child) - } - } - - const nextChild = wslAwareSpawn('rg', rgArgs, { - cwd: authorizedRootPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), - stdio: ['ignore', 'pipe', 'pipe'] - }) - child = nextChild - this.activeRuntimeTextSearches.set(searchKey, nextChild) - - nextChild.stdout!.setEncoding('utf-8') - const onStdoutData = (chunk: string): void => { - stdoutBuffer += chunk - const lines = stdoutBuffer.split('\n') - stdoutBuffer = lines.pop() ?? '' - for (const line of lines) { - processLine(line) - } - } - const onStderrData = (): void => { - // Drain stderr so rg cannot block on a full pipe. - } - const onError = (): void => { - processErrorObserved = true - if (child && isRipgrepUnavailableExit(child, null, null)) { - resolveWithoutRipgrep() - return - } - resolveOnce() - } - const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { - if ( - child && - isRipgrepUnavailableExit(child, code, signal, { - classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) - }) - ) { - unavailableExitObserved = true - resolveWithoutRipgrep() - return - } - if (stdoutBuffer) { - processLine(stdoutBuffer) - } - resolveOnce() - } - - nextChild.stdout!.on('data', onStdoutData) - nextChild.stderr!.on('data', onStderrData) - nextChild.once('error', onError) - nextChild.once('close', onClose) - - killTimeout = setTimeout(() => { - acc.truncated = true - if (child) { - killSpawnedRipgrepProcess(child) - } - resolveOnce() - }, SEARCH_TIMEOUT_MS) - }) - } - - private async resolveFileExplorerPath( - worktreeSelector: string, - relativePath: string - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { - const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) - return target - } - - private async resolveFileExplorerPaths( - worktreeSelector: string, - relativePaths: readonly string[] - ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> { - const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) - return relativePaths.map((relativePath) => ({ - worktree: target.worktree, - path: joinWorktreeRelativePath( - target.worktree.path, - normalizeRuntimeRelativePath(relativePath) - ), - connectionId: target.connectionId - })) - } - - private async listRemoteMobileFiles( - rootPath: string, - connectionId: string, - maxResults?: number, - signal?: AbortSignal - ): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return [] - } - return provider.listFiles(rootPath, { maxResults, signal }) - } - - private async searchRemoteQuickOpenFilePaths( - rootPath: string, - connectionId: string, - query: string, - limit: number, - excludePaths?: string[], - signal?: AbortSignal - ): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - return { paths: [], totalCount: 0, truncated: false } - } - if (!(await provider.supportsQuickOpenSearch?.({ signal }))) { - // Old relays ignore searchQuery. Keep the compatibility request below the - // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. - const legacyFiles = await provider.listFiles(rootPath, { - excludePaths, - maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, - signal - }) - const ranker = new QuickOpenPathRanker(query, limit) - for (const file of legacyFiles) { - ranker.consider(file) - } - const result = ranker.result() - return { - ...result, - truncated: - legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit - } - } - const files = await provider.listFiles(rootPath, { - excludePaths, - maxResults: limit + 1, - searchQuery: query, - signal - }) - return { - paths: files.slice(0, limit), - totalCount: files.length, - truncated: files.length > limit - } - } - - private async readRemoteMobileFile(filePath: string, connectionId: string): Promise { - const provider = getSshFilesystemProvider(connectionId) - if (!provider) { - throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) - } - const fileStat = await provider.stat(filePath) - // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - const result = await provider.readFile(filePath) - if (result.isBinary) { - throw new Error('binary_file') - } - return result.content - } -} - -function watchWindowsRuntimeFileExplorer( - rootPath: string, - callback: (events: FsChangeEvent[]) => void, - onTerminalError: (error: Error) => void -): () => Promise { - let disposed = false - let timer: ReturnType | null = null - let closeStarted = false - const physicalClose = new PhysicalExitTracker() - - const emitOverflow = (): void => { - timer = null - if (disposed) { - return - } - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } - - const scheduleOverflow = (): void => { - if (disposed) { - return - } - if (timer) { - clearTimeout(timer) - } - timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS) - } - - // Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead. - const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) - const onClose = (): void => { - watcher.removeListener('error', onError) - physicalClose.markExited() - } - const onError = (err: Error): void => { - console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) - if (timer) { - clearTimeout(timer) - timer = null - } - watcher.removeListener('close', onClose) - watcher.removeListener('error', onError) - // Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof. - physicalClose.markExited() - if (!disposed) { - try { - callback([{ kind: 'overflow', absolutePath: rootPath }]) - } finally { - onTerminalError(err) - } - } - } - watcher.once('close', onClose) - watcher.on('error', onError) - - return async () => { - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - if (!closeStarted) { - try { - watcher.close() - } catch (err) { - console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) - throw err - } - closeStarted = true - } - try { - await physicalClose.waitForExit( - WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS, - () => new Error('Windows watcher did not close before deletion deadline') - ) - } catch (error) { - // Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root. - throw new WatcherProcessFailure( - error instanceof Error ? error.message : String(error), - 'supervisor', - 'process_unavailable', - physicalClose.exitedPromise - ) - } - } -} - -export function isSafeMobileRelativePath(relativePath: string): boolean { - if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { - return false - } - const parts = relativePath.replace(/\\/g, '/').split('/') - return parts.every((part) => part !== '' && part !== '.' && part !== '..') -} - -function isMobileMarkdownPath(relativePath: string): boolean { - return /\.(md|mdx|markdown)$/i.test(relativePath) -} - -function isMobileBinaryPath(relativePath: string): boolean { - const basename = basenameFromRelativePath(relativePath) - const dotIndex = basename.lastIndexOf('.') - if (dotIndex <= 0) { - return false - } - return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) -} - -function basenameFromRelativePath(relativePath: string): string { - const normalized = relativePath.replace(/\\/g, '/') - return normalized.slice(normalized.lastIndexOf('/') + 1) -} - -function isRuntimeDirectoryEntry(entry: { - isDirectory(): boolean - isSymbolicLink(): boolean -}): boolean { - // Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them). - if (entry.isSymbolicLink()) { - return false - } - if (entry.isDirectory()) { - return true - } - return false -} - -function isBinaryBuffer(buffer: Buffer): boolean { - const len = Math.min(buffer.length, 8192) - for (let i = 0; i < len; i += 1) { - if (buffer[i] === 0) { - return true - } - } - return false -} - -async function assertRuntimePathDoesNotExist(targetPath: string): Promise { - try { - await lstat(targetPath) - throw new Error( - `A file or folder named '${basename(targetPath)}' already exists in this location` - ) - } catch (error) { - if (!isENOENT(error)) { - throw error - } - } -} - -function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never { - const name = basename(targetPath) - if (error instanceof Error && 'code' in error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'EEXIST') { - throw new Error(`A file or folder named '${name}' already exists in this location`) - } - if (code === 'EACCES' || code === 'EPERM') { - throw new Error(`Permission denied: unable to create '${name}'`) - } - } - throw error -} - -async function readLocalMobileFile(filePath: string, store: Store): Promise { - const authorizedPath = await resolveAuthorizedPath(filePath, store) - const fileStat = await stat(authorizedPath) - // Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views). - const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) - const handle = await open(authorizedPath, 'r') - try { - const buffer = Buffer.alloc(readLimit) - const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) - return buffer.subarray(0, bytesRead).toString('utf8') - } finally { - await handle.close() - } -} - -async function readLocalTerminalArtifactFileFromHandle( - handle: FileHandle, - grant: TerminalFileGrant -): Promise { - const fileStat = await handle.stat() - if (fileStat.isDirectory()) { - throw new Error('Cannot read a directory') - } - if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { - throw new Error('file_too_large') - } - assertTerminalFileGrantFresh(grant, fileStat) - const buffer = await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1) - if (isBinaryBuffer(buffer)) { - throw new Error('binary_file') - } - return buffer.toString('utf8') -} - -async function readLocalTerminalArtifactPreviewFromHandle( - handle: FileHandle, - grant: TerminalFileGrant, - maxContentBytes: number | undefined -): Promise { - const fileStats = await handle.stat() - if (fileStats.isDirectory()) { - throw new Error('Cannot preview a directory') - } - assertTerminalFileGrantFresh(grant, fileStats) - const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()] - if (mimeType) { - const binaryMaxBytes = - maxContentBytes === undefined - ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES - : previewableBinaryByteLimit(maxContentBytes) - if (fileStats.size > binaryMaxBytes) { - throw new Error('file_too_large') - } - const buffer = await readFileHandleBufferBounded(handle, binaryMaxBytes + 1) - if (buffer.byteLength > binaryMaxBytes) { - throw new Error('file_too_large') - } - return { - content: buffer.toString('base64'), - isBinary: true, - isImage: true, - mimeType - } - } - - const content = await readLocalTerminalArtifactFileFromHandle(handle, grant) - return { content, isBinary: false } -} - -async function assertLocalTerminalArtifactPathStillCanonical(filePath: string): Promise { - const currentPath = await canonicalPathForArtifactComparison(filePath) - if (currentPath !== filePath) { - throw new Error('terminal_file_grant_stale') - } -} - -async function openLocalTerminalArtifactGrant( - grant: TerminalFileGrant, - flags: number -): Promise { - await assertLocalTerminalArtifactPathStillCanonical(grant.absolutePath) - try { - return await open(grant.absolutePath, flags | OPEN_NOFOLLOW) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ELOOP') { - throw new Error('terminal_file_grant_stale') - } - throw error - } -} - -function resolveTerminalAbsolutePath(args: { - base: string - expanded: string - worktreePath: string - connectionId?: string - terminalFileUriHostname?: string | null -}): string { - const expanded = normalizeTerminalFileUriAuthorityPath( - args.expanded, - args.connectionId, - args.terminalFileUriHostname, - args.worktreePath - ) - const absolutePath = isRuntimePathAbsolute(expanded) - ? expanded - : resolveRuntimePath(args.base, expanded) - if (args.connectionId) { - return normalizeLeadingSlashDrivePath(absolutePath, args.worktreePath) - } - const wsl = parseWslPath(args.worktreePath) - if (wsl && absolutePath.startsWith('/') && !absolutePath.startsWith('//')) { - return toWindowsWslPath(absolutePath, wsl.distro) - } - return absolutePath -} - -function normalizeTerminalFileUriAuthorityPath( - pathText: string, - connectionId?: string, - terminalFileUriHostname?: string | null, - worktreePath?: string -): string { - if (!pathText.startsWith('//')) { - return pathText - } - const match = /^\/\/([^/\\]+)([/\\].*)$/.exec(pathText) - if (!match) { - return pathText - } - const host = match[1]!.toLowerCase() - if (terminalFileUriHostname && host === terminalFileUriHostname.toLowerCase() && connectionId) { - return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) - } - if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) { - return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) - } - // Why: without a verified host match, stripping the file-URI authority could open a same-path artifact on the wrong machine. - return pathText -} - -function provenancePathCandidate(pathText: string, absolutePath: string): string { - return pathText.startsWith('//') ? pathText : absolutePath -} - -function isLoopbackFileUriHostname(hostname: string): boolean { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' -} - -function normalizeLeadingSlashDrivePath(pathText: string, worktreePath?: string): string { - return worktreePath && - isWindowsAbsolutePathLike(worktreePath) && - /^\/[A-Za-z]:[\\/]/.test(pathText) - ? pathText.slice(1) - : pathText -} - -async function resolveAllowedLocalTerminalArtifactPath( - absolutePath: string, - worktreePath: string -): Promise { - const roots = await localTerminalArtifactRoots(worktreePath) - const canonicalPath = await canonicalPathForArtifactComparison(absolutePath) - return roots.some((root) => isPathInsideOrEqual(root, canonicalPath)) ? canonicalPath : null -} - -async function localTerminalArtifactRoots(worktreePath: string): Promise { - const roots = new Set([tmpdir()]) - if (process.platform !== 'win32') { - roots.add('/tmp') - roots.add('/private/tmp') - } - const wsl = parseWslPath(worktreePath) - if (wsl) { - roots.add(toWindowsWslPath('/tmp', wsl.distro)) - } - const canonicalRoots = await Promise.all( - Array.from(roots).map((root) => canonicalPathForArtifactComparison(root)) - ) - return Array.from(new Set([...roots, ...canonicalRoots])) -} - -async function canonicalPathForArtifactComparison(path: string): Promise { - try { - return await realpath(path) - } catch { - return path - } -} - -async function readFileHandleBufferBounded(handle: FileHandle, limit: number): Promise { - const buffer = Buffer.alloc(limit) - const { bytesRead } = await handle.read(buffer, 0, limit, 0) - return buffer.subarray(0, bytesRead) -} - -function terminalFileStatIdentity(stats: RuntimeFileStatLike): string | null { - const dev = typeof stats.dev === 'number' ? stats.dev : null - const ino = typeof stats.ino === 'number' ? stats.ino : null - const nlink = typeof stats.nlink === 'number' ? stats.nlink : null - const size = typeof stats.size === 'number' ? stats.size : null - const mtimeMs = - typeof stats.mtimeMs === 'number' - ? stats.mtimeMs - : typeof stats.mtime === 'number' - ? stats.mtime - : null - if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { - return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` - } - if (size !== null && mtimeMs !== null) { - return `${size}:${mtimeMs}` - } - return null -} - -function assertTerminalFileGrantFresh(grant: TerminalFileGrant, stats: RuntimeFileStatLike): void { - assertTerminalArtifactNotHardLinked(stats) - const nextIdentity = terminalFileStatIdentity(stats) - if (grant.statIdentity !== null && nextIdentity !== null && grant.statIdentity !== nextIdentity) { - throw new Error('terminal_file_grant_stale') - } -} - -function assertTerminalArtifactNotHardLinked(stats: RuntimeFileStatLike): void { - if (isTerminalArtifactHardLinked(stats)) { - throw new Error('terminal_file_grant_stale') - } -} - -function isTerminalArtifactHardLinked(stats: RuntimeFileStatLike): boolean { - return typeof stats.nlink === 'number' && stats.nlink > 1 -} - -function truncateMobileFilePreview(content: string): { - content: string - truncated: boolean - byteLength: number -} { - const buffer = Buffer.from(content, 'utf8') - if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { - return { content, truncated: false, byteLength: buffer.byteLength } - } - return { - content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), - truncated: true, - byteLength: buffer.byteLength - } -} +import { RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths } from './runtime-file-commands-search-remote-quick-open-file-paths' + +export class RuntimeFileCommands extends RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths {} +export { RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' +export { WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS } from './runtime-file-commands-mobile-file-list-limit' +export { awaitRuntimeFileWatcherUnsubscribes } from './runtime-file-commands-watcher-lifecycle' +export { _getRuntimeFileWatcherReleaseCountForTests } from './runtime-file-commands-watcher-lifecycle' +export { _resetRuntimeFileWatcherLeasesForTests } from './runtime-file-commands-watcher-lifecycle' +export type { ResolvedRuntimeFileWorktree } from './runtime-file-commands-watcher-lifecycle' +export type { ResolvedRuntimeFileTarget } from './runtime-file-commands-watcher-lifecycle' +export { getRuntimeFileTargetExecutionHostId } from './runtime-file-commands-watcher-lifecycle' +export type { RuntimeFileCommandHost } from './runtime-file-commands-host' +export { isSafeMobileRelativePath } from './runtime-file-commands-host' diff --git a/src/main/runtime/rpc/methods/orchestration-ask-methods.ts b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts new file mode 100644 index 00000000000..fb5194df87d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-methods.ts @@ -0,0 +1,168 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import { isGroupAddress } from '../../orchestration/groups' +import { AskParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' +import { askRemoteRunHome } from './orchestration-ask-remote' + +export const ORCHESTRATION_ASK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.ask', + params: AskParams, + handler: async ( + params, + { runtime, signal, orchestrationCapability, recordMutationReceipt } + ) => { + // Why: group addresses have no unambiguous first-answer authority. + if (params.to && isGroupAddress(params.to)) { + throw new Error( + 'ask does not support group addresses; use send for non-blocking fan-out questions' + ) + } + + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. + const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) + const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + return askRemoteRunHome({ + params: { ...params, timeoutMs }, + runtime, + signal, + orchestrationCapability, + recordMutationReceipt, + from, + paneKey: paneKey as string, + dispatchId: remoteAttachment.dispatch_id, + taskId: remoteAttachment.task_id + }) + } + const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) + if (!activeDispatch) { + throw new OrchestrationError( + 'dispatch_inactive', + 'ask requires an active supervised Dispatch.' + ) + } + if (activeDispatch.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: activeDispatch.id, + capability: orchestrationCapability, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + throw new OrchestrationError('dispatch_capability_invalid', authority.reason) + } + } + const options = + params.options + ?.split(',') + .map((s) => s.trim()) + .filter(Boolean) ?? [] + let question = params.resume ? db.getQuestion(params.resume) : undefined + if (params.resume) { + if (!question || question.dispatch_id !== activeDispatch.id) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.resume} does not belong to this active Dispatch.` + ) + } + } else { + const run = db.getRun(activeDispatch.run_id) + if (!run || run.legacy === 1) { + throw new OrchestrationError( + 'run_not_found', + `Run ${activeDispatch.run_id} was not found.` + ) + } + if (params.run && params.run !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` + ) + } + if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` + ) + } + const created = db.createQuestion({ + runId: run.id, + dispatchId: activeDispatch.id, + askerHandle: from, + question: params.question as string, + options + }) + question = created.question + runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) + } + + const questionId = question.message_id + recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const current = db.getQuestion(questionId) + if (!current || current.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its Dispatch is inactive.` + ) + } + if (current.status === 'answered') { + return { + answer: current.answer_body, + messageId: questionId, + answerMessageId: current.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { + timeoutMs: remainingMs, + signal + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-ask-remote.ts b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts new file mode 100644 index 00000000000..4b76e626e87 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-ask-remote.ts @@ -0,0 +1,129 @@ +import type { z } from 'zod' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' +import type { AskParams } from './orchestration-schemas' + +export async function askRemoteRunHome(args: { + params: z.infer + runtime: OrcaRuntimeService + signal?: AbortSignal + orchestrationCapability?: string + recordMutationReceipt?: (receipt: unknown) => void + from: string + paneKey: string + dispatchId: string + taskId: string +}): Promise { + const db = args.runtime.getOrchestrationDb() + const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + capability: args.orchestrationCapability, + paneKey: args.paneKey, + processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const options = + args.params.options + ?.split(',') + .map((option) => option.trim()) + .filter(Boolean) ?? [] + let questionId = args.params.resume + if (questionId) { + const existing = db.getRemoteQuestion(questionId) + if (!existing || existing.dispatch_id !== args.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${questionId} does not belong to this remote Dispatch.` + ) + } + } else { + const relay = db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'question', + payload: JSON.stringify({ + from: args.from, + subject: 'Question', + body: args.params.question as string, + type: 'question', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId: args.taskId, + dispatchId: args.dispatchId, + question: args.params.question, + options + }) + }), + remoteQuestion: true + }) + questionId = relay.message_id + } + args.recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const question = db.getRemoteQuestion(questionId) + if (!question || question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its remote Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + return { + answer: question.answer_body, + messageId: questionId, + answerMessageId: question.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (args.signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { + timeoutMs: remainingMs, + signal: args.signal + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-direct.ts b/src/main/runtime/rpc/methods/orchestration-check-direct.ts new file mode 100644 index 00000000000..1ac5ddeb3f3 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-direct.ts @@ -0,0 +1,79 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkDirectMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined +}): Promise { + const { params, runtime, db, handle, typeFilter, signal } = args + // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const consumeUnread = !showAll && params.peek !== true + const readAndReturn = () => { + const messages = showAll + ? db.getAllMessagesForHandle(handle, undefined, typeFilter) + : db.getUnreadMessages(handle, typeFilter) + if ( + consumeUnread && + messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', + { effectsApplied: false } + ) + } + let visibleMessages = messages + if (consumeUnread && messages.length > 0) { + // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. + visibleMessages = messages.map((message) => { + const reconciled = reconcileLifecycleMessage(db, message) + return reconciled.action === 'rejected' + ? (db.getMessageById(message.id) ?? message) + : message + }) + db.markAsRead(messages.map((message) => message.id)) + } + if (params.format || params.inject) { + const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') + return { messages: visibleMessages, formatted, count: visibleMessages.length } + } + return { messages: visibleMessages, count: visibleMessages.length } + } + + if (signal?.aborted) { + return { messages: [], count: 0 } + } + const result = readAndReturn() + if (result.count > 0 || !params.wait) { + return result + } + // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). + const waitResult = await runtime.waitForMessage(handle, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + if (signal?.aborted) { + return { messages: [], count: 0 } + } + if (waitResult === 'cancelled') { + throw new OrchestrationError( + 'consumer_fenced', + 'This direct mailbox became owned by a Run while the check was waiting.' + ) + } + return readAndReturn() +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-methods.ts b/src/main/runtime/rpc/methods/orchestration-check-methods.ts new file mode 100644 index 00000000000..d07be04d129 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-methods.ts @@ -0,0 +1,79 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { CheckParams } from './orchestration-schemas' +import { parseMessageTypes } from './orchestration-routing' +import { checkRunMailbox } from './orchestration-check-run' +import { checkWorkerMailbox } from './orchestration-check-worker' +import { checkDirectMailbox } from './orchestration-check-direct' + +export const ORCHESTRATION_CHECK_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.check', + params: CheckParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } + ) => { + const db = runtime.getOrchestrationDb() + const handle = params.terminal ?? 'unknown' + const typeFilter = parseMessageTypes(params.types) + + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (params.run || boundRun) { + return checkRunMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + }) + } + + const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey) + const remoteAttachment = + !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if ( + remoteAttachment && + !db.isRemoteAttachmentProcessCurrent({ + dispatchId: remoteAttachment.dispatch_id, + paneKey: paneKey ?? null, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` + ) + } + if (activeDispatch || remoteAttachment) { + return checkWorkerMailbox({ + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + }) + } + return checkDirectMailbox({ params, runtime, db, handle, typeFilter, signal }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-check-run.ts b/src/main/runtime/rpc/methods/orchestration-check-run.ts new file mode 100644 index 00000000000..140b58d1a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-run.ts @@ -0,0 +1,251 @@ +import type { MessageRow, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcContext } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { interruptedAcknowledgedCheck } from './orchestration-routing' +import { routeAllMailboxPages } from './orchestration-schemas' +import { resolveRunScope } from './orchestration-run-scope' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer + +export async function checkRunMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + orchestrationCompatibilityEvidence: RpcContext['orchestrationCompatibilityEvidence'] + recordMutationReceipt: ((receipt: unknown) => void) | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityEvidence, + recordMutationReceipt + } = args + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const generation = run.consumer_generation + const address = `run:${run.id}` + runtime.ensureOrchestrationFederationRelay(run.id) + await routeDirectSnapshot(run.id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) + ) + const coordinatorHandle = run.coordinator_handle + if (coordinatorHandle && coordinatorHandle !== handle) { + await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => + db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) + ) + } + revalidateLegacyCoordinator?.() + const currentRun = resolveRunScope(runtime, { + runId: run.id, + callerTerminalHandle: handle, + callerPaneKey: paneKey, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (currentRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while routing pending mail.' + ) + } + + const acknowledged = params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + if (acknowledged) { + recordMutationReceipt?.( + interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') + ) + } + if (params.all || (params.unread === false && !params.peek)) { + const messages = db.getRunMailboxHistory(run.id, 100, typeFilter) + const result = { + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null + } + if (params.format || params.inject) { + return { + ...result, + formatted: messages.map(formatMessageBanner).join('\n\n'), + runId: run.id + } + } + return { ...result, runId: run.id } + } + + const peekResult = (messages: MessageRow[]) => ({ + runId: run.id, + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + }) + const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) + const readDelivery = (wakeTypes?: MessageType[]) => + db.getOrCreateRunDelivery({ runId: run.id, consumerGeneration: generation, wakeTypes }) + let peeked = params.peek ? readPeek() : [] + if (params.peek && peeked.length > 0) { + return peekResult(peeked) + } + let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) + if (current) { + return { + runId: run.id, + deliveryId: current.delivery.id, + messages: current.messages, + count: current.messages.length, + replayed: current.replayed, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format || params.inject + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + if (!params.wait) { + if (params.peek) { + return peekResult([]) + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false + } + } + + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal, + exclusive: true + }) + try { + revalidateLegacyCoordinator?.() + } catch (error) { + if (!acknowledged) { + throw error + } + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + const latestRun = db.getRun(run.id) + if (!latestRun || latestRun.consumer_generation !== generation) { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') + } + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while waiting.' + ) + } + if (waitResult === 'waiter_exists') { + if (acknowledged) { + return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') + } + throw new OrchestrationError( + 'waiter_exists', + `Run ${run.id} already has an active actionable waiter.` + ) + } + if (waitResult === 'timed_out') { + if (params.peek) { + return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: true, + cancelled: false, + connectionLost: false + } + } + if (waitResult === 'cancelled') { + if (params.peek) { + return { + ...peekResult([]), + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + if (params.peek) { + peeked = readPeek() + return { ...peekResult(peeked), timedOut: false, cancelled: false, connectionLost: false } + } + current = readDelivery(typeFilter) + return { + runId: run.id, + deliveryId: current?.delivery.id ?? null, + messages: current?.messages ?? [], + count: current?.messages.length ?? 0, + replayed: current?.replayed ?? false, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format && current + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-check-worker.ts b/src/main/runtime/rpc/methods/orchestration-check-worker.ts new file mode 100644 index 00000000000..3d5e68dec75 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-check-worker.ts @@ -0,0 +1,192 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { formatMessageBanner } from '../../orchestration/formatter' +import { routeAllMailboxPages } from './orchestration-schemas' +import type { CheckParams } from './orchestration-schemas' +import type { z } from 'zod' + +type CheckParamsInput = z.infer +type ActiveDispatch = NonNullable> +type RemoteAttachment = NonNullable< + ReturnType +> + +export async function checkWorkerMailbox(args: { + params: CheckParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + handle: string + paneKey: string | undefined + typeFilter: MessageType[] | undefined + signal: AbortSignal | undefined + activeDispatch: ActiveDispatch | undefined + remoteAttachment: RemoteAttachment | undefined +}): Promise { + const { + params, + runtime, + db, + handle, + paneKey, + typeFilter, + signal, + activeDispatch, + remoteAttachment + } = args + const workerMailbox = activeDispatch + ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } + : remoteAttachment + ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } + : undefined + if (!workerMailbox) { + return undefined + } + const address = `dispatch:${workerMailbox.dispatchId}` + const routeDirectSnapshot = async ( + runId: string, + directHandle: string, + routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } + ): Promise => { + const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) + if (throughSequence !== undefined) { + await routeAllMailboxPages(() => routePage(throughSequence), signal) + } + } + const revalidateWorkerMailbox = async (): Promise => { + if (activeDispatch) { + const current = db.getActiveDispatchForIdentity(handle, paneKey) + if (current?.id === activeDispatch.id) { + return + } + } else if (remoteAttachment && paneKey) { + const current = db.findActiveRemoteAttachmentForPane(paneKey) + if ( + current?.dispatch_id === remoteAttachment.dispatch_id && + db.isRemoteAttachmentProcessCurrent({ + dispatchId: current.dispatch_id, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + return + } + } + const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) + const owningRunId = latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId + if ( + owningRunId && + (!latestDispatch || + (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) + ) { + const throughSequence = db.getLatestUnreadMessageSequence(address) + if (throughSequence !== undefined) { + const routedTypes = new Set() + const routePage = (): { routedCount: number; hasMore: boolean } => { + const routed = db.routeUnreadDispatchMailboxToRunMailbox( + workerMailbox.dispatchId, + owningRunId, + throughSequence + ) + for (const routedType of routed.types) { + routedTypes.add(routedType) + } + return routed + } + const notifyRoutedTypes = (): void => { + for (const routedType of routedTypes) { + runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) + } + routedTypes.clear() + } + try { + await routeAllMailboxPages(routePage, signal) + } catch (error) { + notifyRoutedTypes() + if (error instanceof OrchestrationError && error.code === 'request_aborted') { + setImmediate(() => { + void routeAllMailboxPages(routePage) + .catch(() => undefined) + .finally(notifyRoutedTypes) + }) + } + throw error + } + notifyRoutedTypes() + } + } + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` + ) + } + + if (activeDispatch) { + await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + handle, + throughSequence + ) + ) + const assigneeHandle = activeDispatch.assignee_handle + if (assigneeHandle && assigneeHandle !== handle) { + await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => + db.routeUnreadDirectMessagesToDispatchMailbox( + activeDispatch.id, + activeDispatch.run_id, + assigneeHandle, + throughSequence + ) + ) + } + } + await revalidateWorkerMailbox() + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const messages = showAll + ? db.getAllMessagesForHandle(address, 100, typeFilter) + : db.getUnreadMessages(address, typeFilter) + if (!showAll && params.peek !== true && messages.length > 0) { + db.markAsRead(messages.map((message) => message.id)) + } + if (messages.length > 0 || !params.wait) { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages, + count: messages.length, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + await revalidateWorkerMailbox() + if (waitResult === 'timed_out' || waitResult === 'cancelled') { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: [], + count: 0, + timedOut: waitResult === 'timed_out', + cancelled: waitResult === 'cancelled', + connectionLost: waitResult === 'cancelled' && signal?.aborted === true + } + } + const arrived = db.getUnreadMessages(address, typeFilter) + db.markAsRead(arrived.map((message) => message.id)) + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: arrived, + count: arrived.length, + ...(params.format || params.inject + ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts new file mode 100644 index 00000000000..ae21a4e5a46 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -0,0 +1,178 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' +import { resolveRunScope } from './orchestration-run-scope' +import { DispatchParams, DispatchShowParams } from './orchestration-schemas' + +export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.dispatch', + params: DispatchParams, + handler: async ( + params, + { + orchestrationCompatibilityEvidence, + runtime, + legacyCoordinatorRunId, + revalidateLegacyCoordinator + } + ) => { + const db = runtime.getOrchestrationDb() + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + if (task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${task.id} was not found in Run ${run.id}.` + ) + } + + // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. + if (params.dryRun) { + const maxDepth = runtime.getNestedWorkerMaxDepth() + const previewDepth = db.resolveChildDispatchDepth( + resolveDispatchCreator(runtime, params.from), + maxDepth + ) + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: 'ctx_dryrun', + canDispatchSubWorkers: previewDepth < maxDepth, + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: params.to ?? 'worker', + devMode: params.devMode, + ...(params.to + ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } + : {}) + }) + return { dispatch: null, injected: false, dryRun: true, preamble } + } + + if (!params.to) { + throw new Error('Missing --to') + } + const to = params.to + + if (task.status !== 'ready') { + throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) + } + + // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error(buildInjectRejectionMessage(to)) + } + } + + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) + const assigneePaneKey = + dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined + const processIncarnation = + dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation + ? dispatchAuthority.processIncarnation + : undefined + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + + revalidateLegacyCoordinator?.() + const ctx = db.createDispatchContext({ + taskId: params.task, + assigneeHandle: to, + assigneePaneKey, + launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, + processIncarnation, + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth() + }) + const dispatchCapability = params.inject + ? db.mintDispatchCapability({ + dispatchId: ctx.id, + paneKey: assigneePaneKey as string, + processIncarnation: processIncarnation as string + }) + : undefined + + // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: ctx.id, + canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle: to, + dispatchCapability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(to) + }) + + let injected = false + if (params.inject) { + try { + await runtime.sendTerminalAgentPrompt(to, preamble) + injected = true + } catch (err) { + db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) + throw err + } + } + + // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. + if (params.returnPreamble) { + return { dispatch: ctx, injected, preamble } + } + return { dispatch: ctx, injected } + } + }), + + defineMethod({ + name: 'orchestration.dispatchShow', + params: DispatchShowParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (!params.task) { + throw new Error('Missing --task') + } + const ctx = db.getDispatchContext(params.task) + + // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. + if (params.preamble) { + const task = db.getTask(params.task) + if (!task) { + throw new Error(`Task not found: ${params.task}`) + } + const workerHandle = ctx?.assignee_handle ?? 'worker' + const preamble = buildDispatchPreamble({ + taskId: task.id, + // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. + dispatchId: ctx?.id ?? 'ctx_preview', + canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), + taskSpec: task.spec, + coordinatorHandle: params.from ?? 'coordinator', + workerHandle, + devMode: params.devMode, + ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) + }) + return { dispatch: ctx ?? null, preamble } + } + + return { dispatch: ctx ?? null } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-message-methods.ts b/src/main/runtime/rpc/methods/orchestration-message-methods.ts new file mode 100644 index 00000000000..faf89669247 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-message-methods.ts @@ -0,0 +1,219 @@ +import { defineMethod, type RpcMethod } from '../core' +import type { TaskStatus } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract' +import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' +import { resolveRunScope } from './orchestration-run-scope' +import { + ReplyParams, + InboxParams, + TaskCreateParams, + TaskListParams, + TaskUpdateParams +} from './orchestration-schemas' + +export const ORCHESTRATION_MESSAGE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reply', + params: ReplyParams, + handler: async ( + params, + { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } + ) => { + const db = runtime.getOrchestrationDb() + const original = db.getMessageById(params.id) + if (!original) { + throw new Error(`Message not found: ${params.id}`) + } + if ( + legacyCoordinatorRunId && + (original.run_id !== legacyCoordinatorRunId || + (params.run !== undefined && params.run !== legacyCoordinatorRunId)) + ) { + throw new OrchestrationError( + 'request_mismatch', + `Message ${params.id} does not belong to this adopted Run.`, + { effectsApplied: false } + ) + } + if ( + original.run_id === ORCHESTRATION_LEGACY_RUN_ID || + original.delivery_contract === 'legacy_direct' || + original.delivery_contract === 'audit_only' + ) { + throw new OrchestrationError( + 'legacy_read_only', + 'Legacy orchestration messages are inspect-only; no reply was applied.', + { effectsApplied: false } + ) + } + + const question = db.getQuestion(params.id) + if (question) { + const run = resolveRunScope(runtime, { + runId: params.run ?? question.run_id, + callerTerminalHandle: params.from, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const answered = db.answerQuestion({ + messageId: question.message_id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: params.body + }) + const federated = db.getFederatedDispatch(question.dispatch_id) + if (federated) { + db.enqueueFederationRelay({ + dispatchId: question.dispatch_id, + direction: 'to_worker', + kind: 'reply', + payload: JSON.stringify({ + questionId: question.message_id, + answerMessageId: answered.message.id, + body: params.body + }) + }) + runtime.ensureOrchestrationFederationRelay(run.id) + } else { + runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') + } + return { + message: answered.message, + question: answered.question, + duplicate: answered.duplicate + } + } + + db.markAsRead([original.id]) + + const reply = db.insertMessage({ + from: params.from ?? original.to_handle, + to: original.from_handle, + subject: `Re: ${original.subject}`, + body: params.body, + threadId: original.thread_id ?? original.id, + runId: original.run_id + }) + + runtime.notifyMessageArrived(reply.to_handle, reply.type) + return { message: reply } + } + }), + + defineMethod({ + name: 'orchestration.inbox', + params: InboxParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). + const messages = params.terminal + ? db.getAllMessagesForHandle(params.terminal, params.limit) + : db.getInbox(params.limit) + return { messages, count: messages.length } + } + }), + + defineMethod({ + name: 'orchestration.taskCreate', + params: TaskCreateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const creatorAuthority = params.callerTerminalHandle + ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + : null + const task = db.createTask({ + spec: params.spec, + taskTitle: params.taskTitle, + displayName: params.displayName, + deps, + parentId: params.parent, + createdByTerminalHandle: params.callerTerminalHandle, + ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation + ? { + createdByPaneKey: creatorAuthority.paneKey, + createdByProcessIncarnation: creatorAuthority.processIncarnation, + createdByRunGeneration: run.consumer_generation + } + : {}), + runId: run.id + }) + return { task } + } + }), + + defineMethod({ + name: 'orchestration.taskList', + params: TaskListParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const explicitRun = params.run ? db.getRun(params.run) : undefined + const run = + explicitRun?.legacy === 1 + ? explicitRun + : resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: params.run === undefined, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. + const joined = db.listTasksWithDispatch({ + status: params.status as TaskStatus, + ready: params.ready, + runId: run.id + }) + const tasks = joined.map((row) => { + const { assignee_handle, dispatch_id, ...base } = row + if (base.status === 'dispatched') { + return { ...base, assignee_handle, dispatch_id } + } + return base + }) + return { + runId: run.id, + legacyReadOnly: run.legacy === 1, + tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, + count: tasks.length + } + } + }), + + defineMethod({ + name: 'orchestration.taskUpdate', + params: TaskUpdateParams, + handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + const db = runtime.getOrchestrationDb() + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true, + legacyCoordinatorRunId, + callerEvidence: orchestrationCompatibilityEvidence + }) + const existing = db.getTask(params.id) + if (!existing || existing.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.id} was not found in Run ${run.id}.` + ) + } + const task = db.updateTaskStatus(params.id, params.status, params.result) + if (!task) { + throw new Error(`Task not found: ${params.id}`) + } + return { task } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-reset-methods.ts b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts new file mode 100644 index 00000000000..d98fc4719b9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-reset-methods.ts @@ -0,0 +1,27 @@ +import { defineMethod, type RpcMethod } from '../core' +import { ResetParams } from './orchestration-schemas' + +export const ORCHESTRATION_RESET_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.reset', + params: ResetParams, + handler: (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + if (params.all) { + runtime.stopOrchestrationFederationRelay() + db.resetAll() + return { reset: 'all' } + } + if (params.tasks) { + runtime.stopOrchestrationFederationRelay() + db.resetTasks() + return { reset: 'tasks' } + } + if (params.messages) { + db.resetMessages() + return { reset: 'messages' } + } + throw new Error('Invalid reset scope') + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-routing.ts b/src/main/runtime/rpc/methods/orchestration-routing.ts new file mode 100644 index 00000000000..0722f19b44e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-routing.ts @@ -0,0 +1,135 @@ +import type { MessageType } from '../../orchestration/db' +import type { RunRow } from '../../orchestration/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { MESSAGE_TYPES } from '../../orchestration/types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { LEGACY_CONTRACT_VERSION } from '../../orchestration/db' + +export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { + const types = rawTypes + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] | undefined + const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes && invalidTypes.length > 0) { + throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) + } + return types && types.length > 0 ? types : undefined +} + +export function resolveMessageRun( + runtime: OrcaRuntimeService, + params: { + from?: string + senderPaneKey?: string + to?: string + runId?: string + payload?: string + } +): { run: RunRow | undefined; dispatchId: string | undefined } { + const db = runtime.getOrchestrationDb() + let dispatchId: string | undefined + if (params.payload) { + try { + const payload: unknown = JSON.parse(params.payload) + if ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' + ) { + dispatchId = (payload as { dispatchId: string }).dispatchId + } + } catch { + // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. + } + } + if (!dispatchId && params.to?.startsWith('dispatch:')) { + dispatchId = params.to.slice('dispatch:'.length) + } + + const dispatch = dispatchId + ? db.getDispatchContextById(dispatchId) + : params.from + ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) + : undefined + if (params.to?.startsWith('dispatch:') && !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${dispatchId ?? ''} was not found.` + ) + } + const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined + const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id + let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined + + if (!run && params.from) { + const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) + run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + } + if (resolvedRunId && (!run || run.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) + } + if (run && targetRunId && targetRunId !== run.id) { + throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) + } + if (run && dispatch && dispatch.run_id !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` + ) + } + return { run, dispatchId: dispatch?.id ?? dispatchId } +} + +export function legacyWorkerDeliveryContract( + runtime: OrcaRuntimeService, + runId: string | undefined, + recipient: string +): 'legacy_direct' | undefined { + if (!runId) { + return undefined + } + if (!recipient.startsWith('dispatch:')) { + return runtime + .getOrchestrationDb() + .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) + ? 'legacy_direct' + : undefined + } + const dispatch = runtime + .getOrchestrationDb() + .getDispatchContextById(recipient.slice('dispatch:'.length)) + return dispatch?.run_id === runId && + dispatch.contract_version === LEGACY_CONTRACT_VERSION && + (dispatch.status === 'pending' || dispatch.status === 'dispatched') + ? 'legacy_direct' + : undefined +} + +export function interruptedAcknowledgedCheck( + runId: string, + acknowledged: string, + reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' +): Record { + return { + runId, + deliveryId: null, + messages: [], + count: 0, + acknowledged, + timedOut: false, + cancelled: false, + connectionLost: false, + waitInterrupted: reason + } +} + +export function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { + if (params.to || params.run) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated Dispatch messages route to their Run home; omit --to and --run.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-schemas.ts b/src/main/runtime/rpc/methods/orchestration-schemas.ts new file mode 100644 index 00000000000..f0772882a7c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-schemas.ts @@ -0,0 +1,274 @@ +import { z } from 'zod' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' +import type { TaskStatus } from '../../orchestration/db' +import { isGroupAddress } from '../../orchestration/groups' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export const TASK_STATUSES: TaskStatus[] = [ + 'pending', + 'ready', + 'dispatched', + 'completed', + 'failed', + 'blocked' +] + +export async function routeAllMailboxPages( + routePage: () => { routedCount: number; hasMore: boolean }, + signal?: AbortSignal +): Promise { + while (true) { + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + const page = routePage() + if (!page.hasMore) { + return + } + await yieldToEventLoop() + if (signal?.aborted) { + throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') + } + } +} + +export type DispatchMutationMessageType = + | 'worker_done' + | 'heartbeat' + | 'escalation' + | 'decision_gate' + +export function isDispatchMutationMessageType( + type: string | undefined +): type is DispatchMutationMessageType { + return ( + type === 'worker_done' || + type === 'heartbeat' || + type === 'escalation' || + type === 'decision_gate' + ) +} + +export function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +export function parseRemoteWorkerPayload(payload: string | undefined): Record { + if (!payload) { + return {} + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') + } +} + +export function parseMessageTaskId(payload: string | undefined): string | undefined { + if (!payload) { + return undefined + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? typeof (parsed as { taskId?: unknown }).taskId === 'string' + ? (parsed as { taskId: string }).taskId + : undefined + : undefined + } catch { + return undefined + } +} + +export function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { + return value === 'succeeded' || value === 'failed' +} + +export const SendParams = z + .object({ + to: OptionalString, + subject: requiredString('Missing --subject'), + from: OptionalString, + body: OptionalString, + type: z + .enum([ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate', + 'question', + 'heartbeat' + ]) + .optional(), + priority: z.enum(['normal', 'high', 'urgent']).optional(), + threadId: OptionalString, + payload: OptionalString, + // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. + senderPaneKey: OptionalString, + run: OptionalString, + waitForLifecycleSettlement: OptionalBoolean, + devMode: OptionalBoolean + }) + .superRefine((params, ctx) => { + if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { + return + } + // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: getLifecycleGroupRecipientError(params.type), + path: ['to'] + }) + }) + +export const CheckParams = z + .object({ + terminal: OptionalString, + terminalPaneKey: OptionalString, + unread: OptionalBoolean, + peek: OptionalBoolean, + // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). + all: OptionalBoolean, + types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. + inject: OptionalBoolean, + ack: OptionalString, + compatibilityAck: OptionalString, + compatibilityQuestionAck: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + run: OptionalString, + wait: OptionalBoolean, + timeoutMs: OptionalFiniteNumber + }) + .superRefine((params, ctx) => { + // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. + const modes = [ + params.unread === true, + params.peek === true, + params.all === true || (params.unread === false && params.peek !== true) + ].filter(Boolean) + if (modes.length > 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose at most one message read mode: --unread, --peek, or --all.' + }) + } + }) + +export const ReplyParams = z.object({ + id: requiredString('Missing --id'), + body: requiredString('Missing --body'), + from: OptionalString, + run: OptionalString +}) + +export const InboxParams = z.object({ + limit: OptionalFiniteNumber, + // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). + terminal: OptionalString +}) + +export const TaskCreateParams = z.object({ + spec: requiredString('Missing --spec'), + taskTitle: OptionalString, + displayName: OptionalString, + deps: OptionalString, + parent: OptionalString, + callerTerminalHandle: OptionalString, + run: OptionalString +}) + +export const TaskListParams = z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), + ready: OptionalBoolean, + // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const TaskUpdateParams = z.object({ + id: requiredString('Missing --id'), + status: z + .unknown() + .transform((v) => { + if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { + return v as TaskStatus + } + return '' + }) + .pipe( + z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { + message: 'Missing --status' + }) + ), + result: OptionalString, + run: OptionalString, + callerTerminalHandle: OptionalString +}) + +export const DispatchParams = z.object({ + task: requiredString('Missing --task'), + // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. + to: OptionalString, + from: OptionalString, + inject: OptionalBoolean, + dryRun: OptionalBoolean, + returnPreamble: OptionalBoolean, + devMode: OptionalBoolean, + run: OptionalString +}) + +export const DispatchShowParams = z.object({ + task: OptionalString, + preamble: OptionalBoolean, + from: OptionalString, + devMode: OptionalBoolean +}) + +export const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString, + compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), + compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) + +export const ResetParams = z + .object({ + all: OptionalBoolean, + tasks: OptionalBoolean, + messages: OptionalBoolean + }) + .superRefine((params, ctx) => { + const selectedScopeCount = [params.all, params.tasks, params.messages].filter( + (scope) => scope === true + ).length + if (selectedScopeCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + } + }) diff --git a/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts new file mode 100644 index 00000000000..40f8abf08ee --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-control-mail.ts @@ -0,0 +1,83 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { SendParams } from './orchestration-schemas' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +/** Delivers coordinator control mail to a federated worker when `to` names its exact Dispatch. */ +export function sendFederatedControlMail(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + messageRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + } = args + const dispatchId = to.startsWith('dispatch:') ? to.slice('dispatch:'.length) : undefined + const federatedTarget = + dispatchId && to === `dispatch:${dispatchId}` ? db.getFederatedDispatch(dispatchId) : undefined + if (!federatedTarget || !dispatchId) { + return undefined + } + if (federatedTarget.protocol_version < ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION) { + throw new OrchestrationError( + 'capability_unsupported', + `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` + ) + } + if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} is not active.` + ) + } + if (params.type === 'worker_done' || params.type === 'heartbeat') { + throw new OrchestrationError( + 'invalid_argument', + 'Coordinator-to-worker control mail cannot report worker lifecycle.' + ) + } + revalidateLegacyCoordinator?.() + const relay = db.enqueueFederationRelay({ + dispatchId, + direction: 'to_worker', + kind: 'control_message', + payload: encodeFederatedControlMessage({ + from, + subject: params.subject, + body: params.body ?? '', + type: (params.type ?? 'status') as MessageType, + priority: (params.priority ?? 'normal') as MessagePriority, + threadId: params.threadId ?? null, + payload: params.payload ?? null + }) + }) + runtime.ensureOrchestrationFederationRelay(messageRunId) + return withSendWarnings({ + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'worker', + accepted: true + } + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-group.ts b/src/main/runtime/rpc/methods/orchestration-send-group.ts new file mode 100644 index 00000000000..aa3c8d47788 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-group.ts @@ -0,0 +1,131 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { resolveGroupAddress } from '../../orchestration/groups' +import { resolveBareOrchestrationRecipient } from './orchestration-recipient-routing' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { SendParams } from './orchestration-schemas' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export async function sendGroupMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + groupAddress: string + senderPaneKey: string | undefined + senderRunId: string | undefined + explicitRunId: string | undefined + legacyCoordinatorRunId: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + recordMutationReceipt: ((receipt: unknown) => void) | undefined + withSendWarnings: SendReceipt +}): Promise { + const { + params, + runtime, + db, + from, + groupAddress, + senderPaneKey, + senderRunId, + explicitRunId, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt + } = args + // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). + const { terminals } = await runtime.listTerminals(undefined, undefined, { + includeVisualLayouts: false + }) + const handles = resolveGroupAddress(groupAddress, from, terminals, (handle: string) => + runtime.getAgentStatusForHandle(handle) + ) + if (handles.length === 0) { + throw new Error(`No recipients resolved for group address: ${groupAddress}`) + } + + const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() + const resolvedRecipients = handles.map((handle) => ({ + handle, + resolution: resolveBareOrchestrationRecipient({ + runtime, + db, + handle, + senderRunId, + explicitRunId, + legacyAdoptedMailboxOwner + }) + })) + const deliverableRecipients = resolvedRecipients.filter( + ( + recipient + ): recipient is typeof recipient & { + resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } + } => recipient.resolution.ok + ) + const senderRecipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: from, + senderRunId, + legacyAdoptedMailboxOwner + }) + const senderMailboxKey = senderRecipient.ok + ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` + : undefined + const seenMailboxes = new Set() + const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { + const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` + if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { + return false + } + seenMailboxes.add(mailboxKey) + return true + }) + if (uniqueRecipients.length === 0) { + throw new OrchestrationError( + 'terminal_not_found', + `No recipient of ${groupAddress} resolved to a live terminal or durable Run/Dispatch mailbox.` + ) + } + + revalidateLegacyCoordinator?.() + const threadId = params.threadId ?? `thread_${Date.now()}` + const messages = db.insertMessages( + uniqueRecipients.map(({ resolution }) => ({ + from, + to: resolution.to, + subject: params.subject, + body: params.body, + type: params.type as MessageType, + priority: params.priority as MessagePriority, + threadId, + payload: params.payload, + senderPaneKey, + runId: resolution.runId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + resolution.runId ?? legacyCoordinatorRunId, + resolution.to + ) + })) + ) + const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => + resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] + ) + const receipt = { + messages, + recipients: messages.length, + ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) + } + recordMutationReceipt?.(receipt) + for (const message of messages) { + runtime.notifyMessageArrived(message.to_handle, message.type) + } + return receipt +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-methods.ts b/src/main/runtime/rpc/methods/orchestration-send-methods.ts new file mode 100644 index 00000000000..d780d6a5a5e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-methods.ts @@ -0,0 +1,188 @@ +import { defineMethod, type RpcMethod } from '../core' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { isGroupAddress } from '../../orchestration/groups' +import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' +import { + SendParams, + isWorkerReportOutcome, + parseRemoteWorkerPayload +} from './orchestration-schemas' +import { resolveMessageRun } from './orchestration-routing' +import { + assertDispatchMailboxDeliverable, + resolveBareOrchestrationRecipient, + type SendRecipientWarning +} from './orchestration-recipient-routing' +import { sendRemoteMessage } from './orchestration-send-remote' +import { sendPointToPointMessage } from './orchestration-send-point-to-point' +import { sendGroupMessage } from './orchestration-send-group' +import { sendFederatedControlMail } from './orchestration-send-control-mail' + +export const ORCHESTRATION_SEND_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.send', + params: SendParams, + handler: async ( + params, + { + runtime, + orchestrationCapability, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + orchestrationCompatibilityCallerAuthority, + recordMutationReceipt, + signal + } + ) => { + const db = runtime.getOrchestrationDb() + const from = params.from ?? 'unknown' + const attestedCaller = + orchestrationCompatibilityCallerAuthority?.terminalHandle === from + ? orchestrationCompatibilityCallerAuthority + : undefined + // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. + const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = senderPaneKey + ? db.findActiveRemoteAttachmentForPane(senderPaneKey) + : undefined + if (remoteAttachment && senderPaneKey) { + return sendRemoteMessage({ + params, + runtime, + db, + from, + senderPaneKey, + remoteAttachment, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + orchestrationCapability, + signal + }) + } + + const routing = resolveMessageRun(runtime, { + from, + senderPaneKey, + to: params.to, + runId: params.run, + payload: params.payload + }) + if ( + params.type === 'worker_done' && + !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'worker_done requires outcome=succeeded|failed for a current Dispatch.' + ) + } + if (params.to?.startsWith('task:')) { + throw new OrchestrationError( + 'invalid_argument', + 'Task recipients are intentionally unsupported; use run: or dispatch:.' + ) + } + + let to = params.to + if ( + routing.run && + (!to || + ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) + ) { + to = `run:${routing.run.id}` + } + if (!to) { + throw new OrchestrationError( + 'run_required', + 'No recipient or active Dispatch Run could be resolved. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + + const sendWarnings: SendRecipientWarning[] = [] + let messageRunId = routing.run?.id + if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + const recipient = resolveBareOrchestrationRecipient({ + runtime, + db, + handle: to, + senderRunId: routing.run?.id, + explicitRunId: params.run + }) + if (!recipient.ok) { + throw new OrchestrationError(recipient.code, recipient.message) + } + to = recipient.to + messageRunId = recipient.runId + if (recipient.warning) { + sendWarnings.push(recipient.warning) + } + } + const withSendWarnings = ( + receipt: T + ): T & { warnings?: SendRecipientWarning[] } => + sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt + + if (!isGroupAddress(to)) { + const addressedDispatchId = to.startsWith('dispatch:') + ? to.slice('dispatch:'.length) + : undefined + const federatedTarget = + addressedDispatchId && to === `dispatch:${addressedDispatchId}` + ? db.getFederatedDispatch(addressedDispatchId) + : undefined + // Federated targets perform their own liveness check before relaying. + if (addressedDispatchId && !federatedTarget) { + assertDispatchMailboxDeliverable(db, addressedDispatchId) + } + const federatedControl = sendFederatedControlMail({ + params, + runtime, + db, + from, + to, + messageRunId, + revalidateLegacyCoordinator, + withSendWarnings + }) + if (federatedControl !== undefined) { + return federatedControl + } + return sendPointToPointMessage({ + params, + runtime, + db, + from, + to, + dispatchId: routing.dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation: + attestedCaller?.processIncarnation ?? + runtime.getTerminalProcessIncarnation(from) ?? + undefined, + revalidateLegacyCoordinator, + withSendWarnings + }) + } + return sendGroupMessage({ + params, + runtime, + db, + from, + groupAddress: to, + senderPaneKey, + senderRunId: routing.run?.id, + explicitRunId: params.run, + legacyCoordinatorRunId, + revalidateLegacyCoordinator, + recordMutationReceipt, + withSendWarnings + }) + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts new file mode 100644 index 00000000000..3988dc90af8 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-point-to-point.ts @@ -0,0 +1,187 @@ +import type { MessagePriority, MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { isDispatchMutationMessageType, parseMessageTaskId } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { legacyWorkerDeliveryContract } from './orchestration-routing' +import type { SendRecipientWarning } from './orchestration-recipient-routing' +import type { z } from 'zod' + +type SendParamsInput = z.infer +type SendReceipt = (receipt: T) => T & { warnings?: SendRecipientWarning[] } + +export function sendPointToPointMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + to: string + dispatchId: string | undefined + messageRunId: string | undefined + senderPaneKey: string | undefined + legacyCoordinatorRunId: string | undefined + orchestrationCapability: string | undefined + processIncarnation: string | undefined + revalidateLegacyCoordinator: (() => string) | undefined + withSendWarnings: SendReceipt +}): unknown { + const { + params, + runtime, + db, + from, + to, + dispatchId, + messageRunId, + senderPaneKey, + legacyCoordinatorRunId, + orchestrationCapability, + processIncarnation, + revalidateLegacyCoordinator, + withSendWarnings + } = args + // Point-to-point — existing single-recipient behavior + revalidateLegacyCoordinator?.() + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + const messageType = (params.type ?? 'status') as MessageType + const msg = db.insertMessage({ + from, + to, + subject: params.subject, + body: params.body, + type: messageType, + priority: params.priority as MessagePriority, + threadId: params.threadId, + payload: dispatch + ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) + : params.payload, + senderPaneKey, + runId: messageRunId, + deliveryContract: legacyWorkerDeliveryContract( + runtime, + messageRunId ?? legacyCoordinatorRunId, + to + ) + }) + if (isDispatchMutationMessageType(msg.type)) { + const taskId = parseMessageTaskId(params.payload) + const capabilityBacked = Boolean(dispatch?.capability_hash) + const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' + const authority = resolveLifecycleAuthority({ + db, + dispatch, + from, + paneKey: senderPaneKey, + processIncarnation, + capability: orchestrationCapability, + taskId, + capabilityBacked, + coordinatorMutation + }) + if (!authority.valid) { + const rejection = + db.convertLifecycleMessageToRejection(msg.id, authority.code, authority.reason) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ + message: rejection, + lifecycle: { action: 'rejected', code: authority.code, reason: authority.reason } + }) + } + } + + // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. + if (msg.type === 'worker_done' || msg.type === 'heartbeat') { + const reconciled = reconcileLifecycleMessage(db, msg) + // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. + if (reconciled.action === 'suppressed') { + return withSendWarnings({ message: msg }) + } + if (reconciled.action === 'rejected') { + const rejection = db.getMessageById(msg.id) ?? msg + runtime.notifyMessageArrived(rejection.to_handle, rejection.type) + return withSendWarnings({ message: rejection, lifecycle: reconciled }) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings( + msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } + ) + } + runtime.notifyMessageArrived(msg.to_handle, msg.type) + return withSendWarnings({ message: msg }) +} + +type LifecycleAuthority = { + valid: boolean + code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' + reason: string +} + +function resolveLifecycleAuthority(args: { + db: OrchestrationDb + dispatch: ReturnType + from: string + paneKey: string | undefined + processIncarnation: string | undefined + capability: string | undefined + taskId: string | undefined + capabilityBacked: boolean + coordinatorMutation: boolean +}): LifecycleAuthority { + const { + db, + dispatch, + from, + paneKey, + processIncarnation, + capability, + taskId, + capabilityBacked, + coordinatorMutation + } = args + if (!dispatch) { + return { + valid: !coordinatorMutation, + code: 'sender_not_assignee', + reason: 'No active Dispatch belongs to this message sender.' + } + } + if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { + return { + valid: false, + code: 'task_dispatch_mismatch', + reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` + } + } + if (capabilityBacked) { + const authority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability, + paneKey, + processIncarnation + }) + return { + valid: authority.valid, + code: 'dispatch_capability_invalid', + reason: authority.valid ? '' : authority.reason + } + } + if (dispatch.process_incarnation) { + return { + valid: db.isDispatchProcessCurrent({ + dispatchId: dispatch.id, + paneKey: paneKey ?? null, + processIncarnation: processIncarnation ?? null + }), + code: 'sender_not_assignee', + reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` + } + } + return { + valid: + !coordinatorMutation || + db.isDispatchMessageSender({ dispatchId: dispatch.id, handle: from, paneKey }), + code: 'sender_not_assignee', + reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-send-remote.ts b/src/main/runtime/rpc/methods/orchestration-send-remote.ts new file mode 100644 index 00000000000..9243b977d2e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-send-remote.ts @@ -0,0 +1,114 @@ +import type { MessageType, OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' +import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' +import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import type { z } from 'zod' +import { parseRemoteWorkerPayload } from './orchestration-schemas' +import type { SendParams } from './orchestration-schemas' +import { rejectFederatedExplicitTarget } from './orchestration-routing' + +type SendParamsInput = z.infer + +type RemoteAttachment = { + dispatch_id: string + protocol_version: number +} + +export async function sendRemoteMessage(args: { + params: SendParamsInput + runtime: OrcaRuntimeService + db: OrchestrationDb + from: string + senderPaneKey: string + remoteAttachment: RemoteAttachment + processIncarnation?: string | null + orchestrationCapability?: string + signal?: AbortSignal +}): Promise { + const { params, runtime, db, from, senderPaneKey, remoteAttachment } = args + rejectFederatedExplicitTarget(params) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: remoteAttachment.dispatch_id, + capability: args.orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation: args.processIncarnation ?? null + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + + const type = (params.type ?? 'status') as MessageType + const payload = parseRemoteWorkerPayload(params.payload) + if ( + typeof payload.dispatchId === 'string' && + payload.dispatchId !== remoteAttachment.dispatch_id + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` + ) + } + const outcome = + type === 'worker_done' && (payload.outcome === 'succeeded' || payload.outcome === 'failed') + ? payload.outcome + : undefined + if (type === 'worker_done' && !outcome) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker_done requires outcome=succeeded|failed.' + ) + } + + const supportsLifecycleSettlement = + remoteAttachment.protocol_version >= + ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION + const relay = db.enqueueFederationRelay({ + dispatchId: remoteAttachment.dispatch_id, + direction: 'to_home', + kind: type, + payload: JSON.stringify({ + from, + subject: params.subject, + body: params.body ?? '', + type, + priority: params.priority ?? 'normal', + threadId: params.threadId ?? null, + payload: bindCoordinatorMutationPayload(type, params.payload, remoteAttachment.dispatch_id) + }), + ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) + }) + const lifecycle = + outcome && supportsLifecycleSettlement + ? await waitForFederatedLifecycleSettlement(runtime, relay.dispatch_id, relay.sequence, { + timeoutMs: 30_000, + signal: args.signal + }) + : outcome + ? { + action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), + authority: 'worker_server_legacy' as const + } + : undefined + if (outcome && supportsLifecycleSettlement && !lifecycle) { + throw new OrchestrationError( + 'operation_unknown', + 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' + ) + } + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'run_home', + accepted: true + }, + ...(lifecycle ? { lifecycle } : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 876021e79e1..fab5feba812 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,2060 +1,26 @@ -/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */ -import { z } from 'zod' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import { defineMethod, type RpcMethod } from '../core' -import { resolveDispatchCreator } from './orchestration-dispatch-creator' -import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' -import { - LEGACY_CONTRACT_VERSION, - type MessageRow, - type MessageType, - type MessagePriority, - type TaskStatus -} from '../../orchestration/db' -import { MESSAGE_TYPES } from '../../orchestration/types' -import { buildDispatchPreamble } from '../../orchestration/preamble' -import { formatMessageBanner } from '../../orchestration/formatter' -import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' -import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' -import { waitForFederatedLifecycleSettlement } from '../../orchestration/federation-lifecycle-settlement' -import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' -import { - ORCHESTRATION_LEGACY_RUN_ID, - orchestrationSkillRecoveryData -} from '../../../../shared/orchestration-rpc-contract' -import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' -import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' -import { - assertDispatchMailboxDeliverable, - resolveBareOrchestrationRecipient, - type SendRecipientWarning -} from './orchestration-recipient-routing' -import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message' -import { parseOrchestrationTaskDepsFlag } from '../../orchestration/task-deps-flag' -import { resolveRunScope } from './orchestration-run-scope' +import type { RpcMethod } from '../core' import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods' import { ORCHESTRATION_MUTATION_REQUEST_METHODS } from './orchestration-mutation-request-show' -import { OrchestrationError } from '../../orchestration/orchestration-error' -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RunRow } from '../../orchestration/types' -import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' -import { bindCoordinatorMutationPayload } from '../../orchestration/dispatch-message-binding' -import { - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION -} from '../../../../shared/protocol-version' - -const TASK_STATUSES: TaskStatus[] = [ - 'pending', - 'ready', - 'dispatched', - 'completed', - 'failed', - 'blocked' -] - -async function routeAllMailboxPages( - routePage: () => { routedCount: number; hasMore: boolean }, - signal?: AbortSignal -): Promise { - while (true) { - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - const page = routePage() - if (!page.hasMore) { - return - } - await yieldToEventLoop() - if (signal?.aborted) { - throw new OrchestrationError('request_aborted', 'Mailbox routing was cancelled.') - } - } -} - -type DispatchMutationMessageType = 'worker_done' | 'heartbeat' | 'escalation' | 'decision_gate' - -function isDispatchMutationMessageType( - type: string | undefined -): type is DispatchMutationMessageType { - return ( - type === 'worker_done' || - type === 'heartbeat' || - type === 'escalation' || - type === 'decision_gate' - ) -} - -function getLifecycleGroupRecipientError(type: DispatchMutationMessageType): string { - return `${type} messages belong to one exact Dispatch and cannot target a group address.` -} - -function parseRemoteWorkerPayload(payload: string | undefined): Record { - if (!payload) { - return {} - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {} - } catch { - throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') - } -} - -function parseMessageTaskId(payload: string | undefined): string | undefined { - if (!payload) { - return undefined - } - try { - const parsed: unknown = JSON.parse(payload) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? typeof (parsed as { taskId?: unknown }).taskId === 'string' - ? (parsed as { taskId: string }).taskId - : undefined - : undefined - } catch { - return undefined - } -} - -function isWorkerReportOutcome(value: unknown): value is 'succeeded' | 'failed' { - return value === 'succeeded' || value === 'failed' -} - -const SendParams = z - .object({ - to: OptionalString, - subject: requiredString('Missing --subject'), - from: OptionalString, - body: OptionalString, - type: z - .enum([ - 'status', - 'dispatch', - 'worker_done', - 'merge_ready', - 'escalation', - 'handoff', - 'decision_gate', - 'question', - 'heartbeat' - ]) - .optional(), - priority: z.enum(['normal', 'high', 'urgent']).optional(), - threadId: OptionalString, - payload: OptionalString, - // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. - senderPaneKey: OptionalString, - run: OptionalString, - waitForLifecycleSettlement: OptionalBoolean, - devMode: OptionalBoolean - }) - .superRefine((params, ctx) => { - if (!isDispatchMutationMessageType(params.type) || !params.to || !isGroupAddress(params.to)) { - return - } - // Why: dispatch lifecycle messages are authority/liveness signals for one coordinator; fanout would create lifecycle mail in unrelated terminals. - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: getLifecycleGroupRecipientError(params.type), - path: ['to'] - }) - }) - -const CheckParams = z - .object({ - terminal: OptionalString, - terminalPaneKey: OptionalString, - unread: OptionalBoolean, - peek: OptionalBoolean, - // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). - all: OptionalBoolean, - types: OptionalString, - format: OptionalBoolean, - // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. - inject: OptionalBoolean, - ack: OptionalString, - compatibilityAck: OptionalString, - compatibilityQuestionAck: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - run: OptionalString, - wait: OptionalBoolean, - timeoutMs: OptionalFiniteNumber - }) - .superRefine((params, ctx) => { - // Why: CLI encodes --peek as {peek:true, unread:false} for pre-peek runtimes, so that pair is one mode, not a conflict. - const modes = [ - params.unread === true, - params.peek === true, - params.all === true || (params.unread === false && params.peek !== true) - ].filter(Boolean) - if (modes.length > 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose at most one message read mode: --unread, --peek, or --all.' - }) - } - }) - -const ReplyParams = z.object({ - id: requiredString('Missing --id'), - body: requiredString('Missing --body'), - from: OptionalString, - run: OptionalString -}) - -const InboxParams = z.object({ - limit: OptionalFiniteNumber, - // Why: filters the inbox to a handle so inbox and check --all give agreeing results (design doc §3.3). - terminal: OptionalString -}) - -const TaskCreateParams = z.object({ - spec: requiredString('Missing --spec'), - taskTitle: OptionalString, - displayName: OptionalString, - deps: OptionalString, - parent: OptionalString, - callerTerminalHandle: OptionalString, - run: OptionalString -}) - -const TaskListParams = z.object({ - status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), - ready: OptionalBoolean, - // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const TaskUpdateParams = z.object({ - id: requiredString('Missing --id'), - status: z - .unknown() - .transform((v) => { - if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) { - return v as TaskStatus - } - return '' - }) - .pipe( - z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], { - message: 'Missing --status' - }) - ), - result: OptionalString, - run: OptionalString, - callerTerminalHandle: OptionalString -}) - -const DispatchParams = z.object({ - task: requiredString('Missing --task'), - // Why: --to is optional so --dry-run can preview without a target; the handler enforces presence before any side-effecting work. - to: OptionalString, - from: OptionalString, - inject: OptionalBoolean, - dryRun: OptionalBoolean, - returnPreamble: OptionalBoolean, - devMode: OptionalBoolean, - run: OptionalString -}) - -const DispatchShowParams = z.object({ - task: OptionalString, - preamble: OptionalBoolean, - from: OptionalString, - devMode: OptionalBoolean -}) - -const AskParams = z - .object({ - to: OptionalString, - question: OptionalString, - resume: OptionalString, - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString, - run: OptionalString, - compatibilityCliCommand: z.enum(['orca', 'orca-ide', 'orca-dev']).optional(), - compatibilityWindowsCommand: z.enum(['orca', 'orca-ide']).optional() - }) - .superRefine((params, ctx) => { - if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one of --question or --resume.' - }) - } - }) - -const ResetParams = z - .object({ - all: OptionalBoolean, - tasks: OptionalBoolean, - messages: OptionalBoolean - }) - .superRefine((params, ctx) => { - const selectedScopeCount = [params.all, params.tasks, params.messages].filter( - (scope) => scope === true - ).length - if (selectedScopeCount !== 1) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' - }) - } - }) - -function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { - const types = rawTypes - ?.split(',') - .map((type) => type.trim()) - .filter(Boolean) as MessageType[] | undefined - const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) - if (invalidTypes && invalidTypes.length > 0) { - throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) - } - return types && types.length > 0 ? types : undefined -} - -function resolveMessageRun( - runtime: OrcaRuntimeService, - params: { - from?: string - senderPaneKey?: string - to?: string - runId?: string - payload?: string - } -): { run: RunRow | undefined; dispatchId: string | undefined } { - const db = runtime.getOrchestrationDb() - let dispatchId: string | undefined - if (params.payload) { - try { - const payload: unknown = JSON.parse(params.payload) - if ( - payload && - typeof payload === 'object' && - !Array.isArray(payload) && - typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' - ) { - dispatchId = (payload as { dispatchId: string }).dispatchId - } - } catch { - // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. - } - } - if (!dispatchId && params.to?.startsWith('dispatch:')) { - dispatchId = params.to.slice('dispatch:'.length) - } - - const dispatch = dispatchId - ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined - if (params.to?.startsWith('dispatch:') && !dispatch) { - throw new OrchestrationError( - 'dispatch_not_found', - `Dispatch ${dispatchId ?? ''} was not found.` - ) - } - const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined - const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id - let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - } - if (resolvedRunId && (!run || run.legacy === 1)) { - throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) - } - if (run && targetRunId && targetRunId !== run.id) { - throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) - } - if (run && dispatch && dispatch.run_id !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` - ) - } - return { run, dispatchId: dispatch?.id ?? dispatchId } -} - -function legacyWorkerDeliveryContract( - runtime: OrcaRuntimeService, - runId: string | undefined, - recipient: string -): 'legacy_direct' | undefined { - if (!runId) { - return undefined - } - if (!recipient.startsWith('dispatch:')) { - return runtime - .getOrchestrationDb() - .resolveLegacyWorkerCandidate({ runId, terminalHandle: recipient }) - ? 'legacy_direct' - : undefined - } - const dispatch = runtime - .getOrchestrationDb() - .getDispatchContextById(recipient.slice('dispatch:'.length)) - return dispatch?.run_id === runId && - dispatch.contract_version === LEGACY_CONTRACT_VERSION && - (dispatch.status === 'pending' || dispatch.status === 'dispatched') - ? 'legacy_direct' - : undefined -} - -function interruptedAcknowledgedCheck( - runId: string, - acknowledged: string, - reason: 'consumer_fenced' | 'outcome_unknown' | 'waiter_exists' -): Record { - return { - runId, - deliveryId: null, - messages: [], - count: 0, - acknowledged, - timedOut: false, - cancelled: false, - connectionLost: false, - waitInterrupted: reason - } -} - -function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { - if (params.to || params.run) { - throw new OrchestrationError( - 'invalid_argument', - 'Federated Dispatch messages route to their Run home; omit --to and --run.' - ) - } -} +import { ORCHESTRATION_SEND_METHODS } from './orchestration-send-methods' +import { ORCHESTRATION_CHECK_METHODS } from './orchestration-check-methods' +import { ORCHESTRATION_MESSAGE_METHODS } from './orchestration-message-methods' +import { ORCHESTRATION_DISPATCH_METHODS } from './orchestration-dispatch-methods' +import { ORCHESTRATION_ASK_METHODS } from './orchestration-ask-methods' +import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' +import { ORCHESTRATION_RESET_METHODS } from './orchestration-reset-methods' export const ORCHESTRATION_METHODS: RpcMethod[] = [ ...ORCHESTRATION_RUN_METHODS, ...ORCHESTRATION_WORKER_METHODS, ...ORCHESTRATION_FEDERATION_METHODS, ...ORCHESTRATION_MUTATION_REQUEST_METHODS, - defineMethod({ - name: 'orchestration.send', - params: SendParams, - handler: async ( - params, - { - runtime, - orchestrationCapability, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - orchestrationCompatibilityCallerAuthority, - recordMutationReceipt, - signal - } - ) => { - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - const attestedCaller = - orchestrationCompatibilityCallerAuthority?.terminalHandle === from - ? orchestrationCompatibilityCallerAuthority - : undefined - // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = senderPaneKey - ? db.findActiveRemoteAttachmentForPane(senderPaneKey) - : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - const processIncarnation = - attestedCaller?.processIncarnation ?? runtime.getTerminalProcessIncarnation(from) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: remoteAttachment.dispatch_id, - capability: orchestrationCapability, - paneKey: senderPaneKey ?? null, - processIncarnation - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const type = (params.type ?? 'status') as MessageType - const payload = parseRemoteWorkerPayload(params.payload) - if ( - typeof payload.dispatchId === 'string' && - payload.dispatchId !== remoteAttachment.dispatch_id - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` - ) - } - const outcome = - type === 'worker_done' && - (payload.outcome === 'succeeded' || payload.outcome === 'failed') - ? payload.outcome - : undefined - if (type === 'worker_done' && !outcome) { - throw new OrchestrationError( - 'invalid_argument', - 'Remote worker_done requires outcome=succeeded|failed.' - ) - } - const supportsLifecycleSettlement = - remoteAttachment.protocol_version >= - ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION - const relay = db.enqueueFederationRelay({ - dispatchId: remoteAttachment.dispatch_id, - direction: 'to_home', - kind: type, - payload: JSON.stringify({ - from, - subject: params.subject, - body: params.body ?? '', - type, - priority: params.priority ?? 'normal', - threadId: params.threadId ?? null, - payload: bindCoordinatorMutationPayload( - type, - params.payload, - remoteAttachment.dispatch_id - ) - }), - ...(!supportsLifecycleSettlement && outcome ? { settleRemoteOutcome: outcome } : {}) - }) - const lifecycle = - outcome && supportsLifecycleSettlement - ? await waitForFederatedLifecycleSettlement( - runtime, - relay.dispatch_id, - relay.sequence, - { - timeoutMs: 30_000, - signal - } - ) - : outcome - ? { - action: outcome === 'succeeded' ? ('completed' as const) : ('failed' as const), - authority: 'worker_server_legacy' as const - } - : undefined - if (outcome && supportsLifecycleSettlement && !lifecycle) { - throw new OrchestrationError( - 'operation_unknown', - 'worker_done was queued, but the Run-home runtime did not confirm settlement. Verify the Task and Dispatch before retrying.' - ) - } - return { - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'run_home', - accepted: true - }, - ...(lifecycle ? { lifecycle } : {}) - } - } - const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, - to: params.to, - runId: params.run, - payload: params.payload - }) - if ( - params.type === 'worker_done' && - !isWorkerReportOutcome(parseRemoteWorkerPayload(params.payload).outcome) - ) { - throw new OrchestrationError( - 'invalid_argument', - 'worker_done requires outcome=succeeded|failed for a current Dispatch.' - ) - } - if (params.to?.startsWith('task:')) { - throw new OrchestrationError( - 'invalid_argument', - 'Task recipients are intentionally unsupported; use run: or dispatch:.' - ) - } - let to = params.to - if ( - routing.run && - (!to || - ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) - ) { - to = `run:${routing.run.id}` - } - if (!to) { - throw new OrchestrationError( - 'run_required', - 'No recipient or active Dispatch Run could be resolved. No effects were applied.', - orchestrationSkillRecoveryData() - ) - } - - const sendWarnings: SendRecipientWarning[] = [] - let messageRunId = routing.run?.id - if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { - const recipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: to, - senderRunId: routing.run?.id, - explicitRunId: params.run - }) - if (!recipient.ok) { - throw new OrchestrationError(recipient.code, recipient.message) - } - to = recipient.to - messageRunId = recipient.runId - if (recipient.warning) { - sendWarnings.push(recipient.warning) - } - } - const withSendWarnings = ( - receipt: T - ): T & { - warnings?: SendRecipientWarning[] - } => (sendWarnings.length > 0 ? { ...receipt, warnings: sendWarnings } : receipt) - - if (!isGroupAddress(to)) { - const addressedDispatchId = to.startsWith('dispatch:') - ? to.slice('dispatch:'.length) - : undefined - const federatedTarget = - addressedDispatchId && to === `dispatch:${addressedDispatchId}` - ? db.getFederatedDispatch(addressedDispatchId) - : undefined - if (addressedDispatchId && !federatedTarget) { - assertDispatchMailboxDeliverable(db, addressedDispatchId) - } - if (federatedTarget && addressedDispatchId) { - const dispatchId = addressedDispatchId - if ( - federatedTarget.protocol_version < - ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION - ) { - throw new OrchestrationError( - 'capability_unsupported', - `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` - ) - } - if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { - throw new OrchestrationError( - 'dispatch_inactive', - `Federated Dispatch ${dispatchId} is not active.` - ) - } - if (params.type === 'worker_done' || params.type === 'heartbeat') { - throw new OrchestrationError( - 'invalid_argument', - 'Coordinator-to-worker control mail cannot report worker lifecycle.' - ) - } - revalidateLegacyCoordinator?.() - const relay = db.enqueueFederationRelay({ - dispatchId, - direction: 'to_worker', - kind: 'control_message', - payload: encodeFederatedControlMessage({ - from, - subject: params.subject, - body: params.body ?? '', - type: (params.type ?? 'status') as MessageType, - priority: (params.priority ?? 'normal') as MessagePriority, - threadId: params.threadId ?? null, - payload: params.payload ?? null - }) - }) - runtime.ensureOrchestrationFederationRelay(messageRunId) - return withSendWarnings({ - relay: { - messageId: relay.message_id, - sequence: relay.sequence, - dispatchId: relay.dispatch_id, - destination: 'worker', - accepted: true - } - }) - } - // Point-to-point — existing single-recipient behavior - revalidateLegacyCoordinator?.() - const dispatch = routing.dispatchId - ? db.getDispatchContextById(routing.dispatchId) - : undefined - const messageType = (params.type ?? 'status') as MessageType - const msg = db.insertMessage({ - from, - to, - subject: params.subject, - body: params.body, - type: messageType, - priority: params.priority as MessagePriority, - threadId: params.threadId, - payload: dispatch - ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) - : params.payload, - senderPaneKey, - runId: messageRunId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - messageRunId ?? legacyCoordinatorRunId, - to - ) - }) - const dispatchMutationMessage = isDispatchMutationMessageType(msg.type) - if (dispatchMutationMessage) { - const processIncarnation = - attestedCaller?.processIncarnation ?? - runtime.getTerminalProcessIncarnation(from) ?? - undefined - const taskId = parseMessageTaskId(params.payload) - const capabilityBacked = Boolean(dispatch?.capability_hash) - const coordinatorMutation = msg.type === 'escalation' || msg.type === 'decision_gate' - let authority: { - valid: boolean - code: 'sender_not_assignee' | 'task_dispatch_mismatch' | 'dispatch_capability_invalid' - reason: string - } - if (!dispatch) { - authority = { - valid: !coordinatorMutation, - code: 'sender_not_assignee', - reason: 'No active Dispatch belongs to this message sender.' - } - } else if (coordinatorMutation && taskId && taskId !== dispatch.task_id) { - authority = { - valid: false, - code: 'task_dispatch_mismatch', - reason: `Task ${taskId} does not belong to Dispatch ${dispatch.id}.` - } - } else if (capabilityBacked) { - const capabilityAuthority = db.verifyDispatchCapability({ - dispatchId: dispatch.id, - capability: orchestrationCapability, - paneKey: senderPaneKey, - processIncarnation - }) - authority = { - valid: capabilityAuthority.valid, - code: 'dispatch_capability_invalid', - reason: capabilityAuthority.valid ? '' : capabilityAuthority.reason - } - } else if (dispatch.process_incarnation) { - authority = { - valid: db.isDispatchProcessCurrent({ - dispatchId: dispatch.id, - paneKey: senderPaneKey ?? null, - processIncarnation: processIncarnation ?? null - }), - code: 'sender_not_assignee', - reason: `Dispatch ${dispatch.id} process incarnation is no longer current for its pane.` - } - } else { - authority = { - valid: - !coordinatorMutation || - db.isDispatchMessageSender({ - dispatchId: dispatch.id, - handle: from, - paneKey: senderPaneKey - }), - code: 'sender_not_assignee', - reason: `Terminal ${from} does not own Dispatch ${dispatch.id}.` - } - } - if (!authority.valid) { - const code = authority.code - const rejection = - db.convertLifecycleMessageToRejection(msg.id, code, authority.reason) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ - message: rejection, - lifecycle: { - action: 'rejected', - code, - reason: authority.reason - } - }) - } - } - // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. - if (msg.type === 'worker_done' || msg.type === 'heartbeat') { - const reconciled = reconcileLifecycleMessage(db, msg) - // Why: a suppressed message is already read, so skip the notify that would wake a check --wait waiter to an empty result. - if (reconciled.action === 'suppressed') { - return withSendWarnings({ message: msg }) - } - if (reconciled.action === 'rejected') { - const rejection = db.getMessageById(msg.id) ?? msg - runtime.notifyMessageArrived(rejection.to_handle, rejection.type) - return withSendWarnings({ message: rejection, lifecycle: reconciled }) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings( - msg.type === 'worker_done' ? { message: msg, lifecycle: reconciled } : { message: msg } - ) - } - runtime.notifyMessageArrived(msg.to_handle, msg.type) - return withSendWarnings({ message: msg }) - } - - // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). - const { terminals } = await runtime.listTerminals(undefined, undefined, { - includeVisualLayouts: false - }) - const handles = resolveGroupAddress(to, from, terminals, (handle: string) => - runtime.getAgentStatusForHandle(handle) - ) - - if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${to}`) - } - - const legacyAdoptedMailboxOwner = db.getLegacyAdoptedRunMailboxOwner() - const resolvedRecipients = handles.map((handle) => ({ - handle, - resolution: resolveBareOrchestrationRecipient({ - runtime, - db, - handle, - senderRunId: routing.run?.id, - explicitRunId: params.run, - legacyAdoptedMailboxOwner - }) - })) - const deliverableRecipients = resolvedRecipients.filter( - ( - recipient - ): recipient is typeof recipient & { - resolution: { ok: true; to: string; runId?: string; warning?: SendRecipientWarning } - } => recipient.resolution.ok - ) - const senderRecipient = resolveBareOrchestrationRecipient({ - runtime, - db, - handle: from, - senderRunId: routing.run?.id, - legacyAdoptedMailboxOwner - }) - const senderMailboxKey = senderRecipient.ok - ? `${senderRecipient.runId ?? ''}\u0000${senderRecipient.to}` - : undefined - const seenMailboxes = new Set() - const uniqueRecipients = deliverableRecipients.filter(({ resolution }) => { - const mailboxKey = `${resolution.runId ?? ''}\u0000${resolution.to}` - if (mailboxKey === senderMailboxKey || seenMailboxes.has(mailboxKey)) { - return false - } - seenMailboxes.add(mailboxKey) - return true - }) - if (uniqueRecipients.length === 0) { - throw new OrchestrationError( - 'terminal_not_found', - `No recipient of ${to} resolved to a live terminal or durable Run/Dispatch mailbox.` - ) - } - - revalidateLegacyCoordinator?.() - const threadId = params.threadId ?? `thread_${Date.now()}` - const messages = db.insertMessages( - uniqueRecipients.map(({ resolution }) => ({ - from, - to: resolution.to, - subject: params.subject, - body: params.body, - type: params.type as MessageType, - priority: params.priority as MessagePriority, - threadId, - payload: params.payload, - senderPaneKey, - runId: resolution.runId, - deliveryContract: legacyWorkerDeliveryContract( - runtime, - resolution.runId ?? legacyCoordinatorRunId, - resolution.to - ) - })) - ) - const groupWarnings = resolvedRecipients.flatMap(({ resolution }) => - resolution.ok ? (resolution.warning ? [resolution.warning] : []) : [resolution.warning] - ) - const receipt = { - messages, - recipients: messages.length, - ...(groupWarnings.length > 0 ? { warnings: groupWarnings } : {}) - } - recordMutationReceipt?.(receipt) - for (const message of messages) { - runtime.notifyMessageArrived(message.to_handle, message.type) - } - return receipt - } - }), - - defineMethod({ - name: 'orchestration.check', - params: CheckParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - signal, - legacyCoordinatorRunId, - revalidateLegacyCoordinator, - recordMutationReceipt - } - ) => { - const db = runtime.getOrchestrationDb() - const handle = params.terminal ?? 'unknown' - const typeFilter = parseMessageTypes(params.types) - const routeDirectSnapshot = async ( - runId: string, - directHandle: string, - routePage: (throughSequence: number) => { routedCount: number; hasMore: boolean } - ): Promise => { - const throughSequence = db.getLatestUnreadDirectMessageSequenceForRun(runId, directHandle) - if (throughSequence !== undefined) { - await routeAllMailboxPages(() => routePage(throughSequence), signal) - } - } - - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined - if (params.run || boundRun) { - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const generation = run.consumer_generation - const address = `run:${run.id}` - runtime.ensureOrchestrationFederationRelay(run.id) - await routeDirectSnapshot(run.id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, handle, throughSequence) - ) - const coordinatorHandle = run.coordinator_handle - if (coordinatorHandle && coordinatorHandle !== handle) { - await routeDirectSnapshot(run.id, coordinatorHandle, (throughSequence) => - db.routeUnreadDirectMessagesToRunMailbox(run.id, coordinatorHandle, throughSequence) - ) - } - revalidateLegacyCoordinator?.() - const currentRun = resolveRunScope(runtime, { - runId: run.id, - callerTerminalHandle: handle, - callerPaneKey: paneKey ?? undefined, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (currentRun.consumer_generation !== generation) { - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while routing pending mail.' - ) - } - - const acknowledged = params.ack - ? db.acknowledgeRunDelivery({ - runId: run.id, - consumerGeneration: generation, - deliveryId: params.ack - }) - : undefined - if (acknowledged) { - recordMutationReceipt?.( - interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'outcome_unknown') - ) - } - if (params.all || (params.unread === false && !params.peek)) { - const history = db.getRunMailboxHistory(run.id, 100, typeFilter) - const messages = history - const result = { - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null - } - if (params.format || params.inject) { - return { - ...result, - formatted: messages.map(formatMessageBanner).join('\n\n'), - runId: run.id - } - } - return { ...result, runId: run.id } - } - - const peekResult = (messages: MessageRow[]) => ({ - runId: run.id, - messages, - count: messages.length, - acknowledged: acknowledged?.delivery.id ?? null, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - }) - const readPeek = () => db.getUnreadRunMailbox(run.id, 100, typeFilter) - const readDelivery = (wakeTypes?: MessageType[]) => - db.getOrCreateRunDelivery({ - runId: run.id, - consumerGeneration: generation, - wakeTypes - }) - let peeked = params.peek ? readPeek() : [] - if (params.peek && peeked.length > 0) { - return peekResult(peeked) - } - let current = params.peek ? undefined : readDelivery(params.wait ? typeFilter : undefined) - if (current) { - return { - runId: run.id, - deliveryId: current.delivery.id, - messages: current.messages, - count: current.messages.length, - replayed: current.replayed, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format || params.inject - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - if (!params.wait) { - if (params.peek) { - return peekResult([]) - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false - } - } - - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal, - exclusive: true - }) - try { - revalidateLegacyCoordinator?.() - } catch (error) { - if (!acknowledged) { - throw error - } - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - const latestRun = db.getRun(run.id) - if (!latestRun || latestRun.consumer_generation !== generation) { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'consumer_fenced') - } - throw new OrchestrationError( - 'consumer_fenced', - 'This mailbox consumer was replaced while waiting.' - ) - } - if (waitResult === 'waiter_exists') { - if (acknowledged) { - return interruptedAcknowledgedCheck(run.id, acknowledged.delivery.id, 'waiter_exists') - } - throw new OrchestrationError( - 'waiter_exists', - `Run ${run.id} already has an active actionable waiter.` - ) - } - if (waitResult === 'timed_out') { - if (params.peek) { - return { ...peekResult([]), timedOut: true, cancelled: false, connectionLost: false } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: true, - cancelled: false, - connectionLost: false - } - } - if (waitResult === 'cancelled') { - if (params.peek) { - return { - ...peekResult([]), - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - return { - runId: run.id, - deliveryId: null, - messages: [], - count: 0, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: true, - connectionLost: signal?.aborted === true - } - } - - if (params.peek) { - peeked = readPeek() - return { - ...peekResult(peeked), - timedOut: false, - cancelled: false, - connectionLost: false - } - } - current = readDelivery(typeFilter) - return { - runId: run.id, - deliveryId: current?.delivery.id ?? null, - messages: current?.messages ?? [], - count: current?.messages.length ?? 0, - replayed: current?.replayed ?? false, - acknowledged: acknowledged?.delivery.id ?? null, - timedOut: false, - cancelled: false, - connectionLost: false, - ...(params.format && current - ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - const remoteAttachment = - !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if ( - remoteAttachment && - !db.isRemoteAttachmentProcessCurrent({ - dispatchId: remoteAttachment.dispatch_id, - paneKey: paneKey ?? null, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` - ) - } - const workerMailbox = activeDispatch - ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } - : remoteAttachment - ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } - : undefined - if (workerMailbox) { - const address = `dispatch:${workerMailbox.dispatchId}` - const revalidateWorkerMailbox = async (): Promise => { - if (activeDispatch) { - const current = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) - if (current?.id === activeDispatch.id) { - return - } - } else if (remoteAttachment && paneKey) { - const current = db.findActiveRemoteAttachmentForPane(paneKey) - if ( - current?.dispatch_id === remoteAttachment.dispatch_id && - db.isRemoteAttachmentProcessCurrent({ - dispatchId: current.dispatch_id, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(handle) - }) - ) { - return - } - } - const latestDispatch = db.getDispatchContextById(workerMailbox.dispatchId) - const owningRunId = - latestDispatch?.run_id ?? activeDispatch?.run_id ?? workerMailbox.runId - if ( - owningRunId && - (!latestDispatch || - (latestDispatch.status !== 'pending' && latestDispatch.status !== 'dispatched')) - ) { - const throughSequence = db.getLatestUnreadMessageSequence(address) - if (throughSequence !== undefined) { - const routedTypes = new Set() - const routePage = (): { routedCount: number; hasMore: boolean } => { - const routed = db.routeUnreadDispatchMailboxToRunMailbox( - workerMailbox.dispatchId, - owningRunId, - throughSequence - ) - for (const routedType of routed.types) { - routedTypes.add(routedType) - } - return routed - } - const notifyRoutedTypes = (): void => { - for (const routedType of routedTypes) { - runtime.notifyMessageArrived(`run:${owningRunId}`, routedType) - } - routedTypes.clear() - } - try { - await routeAllMailboxPages(routePage, signal) - } catch (error) { - notifyRoutedTypes() - if (error instanceof OrchestrationError && error.code === 'request_aborted') { - setImmediate(() => { - void routeAllMailboxPages(routePage) - .catch(() => undefined) - .finally(notifyRoutedTypes) - }) - } - throw error - } - notifyRoutedTypes() - } - } - throw new OrchestrationError( - 'dispatch_inactive', - `Dispatch ${workerMailbox.dispatchId} is no longer assigned to this worker.` - ) - } - if (activeDispatch) { - await routeDirectSnapshot(activeDispatch.run_id, handle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - handle, - throughSequence - ) - ) - const assigneeHandle = activeDispatch.assignee_handle - if (assigneeHandle && assigneeHandle !== handle) { - await routeDirectSnapshot(activeDispatch.run_id, assigneeHandle, (throughSequence) => - db.routeUnreadDirectMessagesToDispatchMailbox( - activeDispatch.id, - activeDispatch.run_id, - assigneeHandle, - throughSequence - ) - ) - } - } - await revalidateWorkerMailbox() - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const messages = showAll - ? db.getAllMessagesForHandle(address, 100, typeFilter) - : db.getUnreadMessages(address, typeFilter) - if (!showAll && params.peek !== true && messages.length > 0) { - db.markAsRead(messages.map((message) => message.id)) - } - if (messages.length > 0 || !params.wait) { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages, - count: messages.length, - ...(params.format || params.inject - ? { formatted: messages.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - const waitResult = await runtime.waitForMessage(address, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - await revalidateWorkerMailbox() - if (waitResult === 'timed_out' || waitResult === 'cancelled') { - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: [], - count: 0, - timedOut: waitResult === 'timed_out', - cancelled: waitResult === 'cancelled', - connectionLost: waitResult === 'cancelled' && signal?.aborted === true - } - } - const arrived = db.getUnreadMessages(address, typeFilter) - db.markAsRead(arrived.map((message) => message.id)) - return { - ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), - dispatchId: workerMailbox.dispatchId, - messages: arrived, - count: arrived.length, - ...(params.format || params.inject - ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } - : {}) - } - } - - // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). - const showAll = params.all === true || (params.unread === false && params.peek !== true) - const consumeUnread = !showAll && params.peek !== true - - const readAndReturn = () => { - const messages = showAll - ? db.getAllMessagesForHandle(handle, undefined, typeFilter) - : db.getUnreadMessages(handle, typeFilter) - - if ( - consumeUnread && - messages.some((message) => message.run_id === ORCHESTRATION_LEGACY_RUN_ID) - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; use --peek or --all. No acknowledgment was applied.', - { effectsApplied: false } - ) - } - - let visibleMessages = messages - if (consumeUnread && messages.length > 0) { - // Why: unread check is an authoritative read path for worker_done/heartbeat, so reconcile lifecycle messages here too. - visibleMessages = messages.map((message) => { - const reconciled = reconcileLifecycleMessage(db, message) - return reconciled.action === 'rejected' - ? (db.getMessageById(message.id) ?? message) - : message - }) - db.markAsRead(messages.map((m) => m.id)) - } - - if (params.format || params.inject) { - const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') - return { messages: visibleMessages, formatted, count: visibleMessages.length } - } - - return { messages: visibleMessages, count: visibleMessages.length } - } - - if (signal?.aborted) { - return { messages: [], count: 0 } - } - const result = readAndReturn() - if (result.count > 0 || !params.wait) { - return result - } - - // Why: signal aborts this waiter when the client socket closes, freeing the long-poll slot immediately rather than after timeoutMs (design doc §3.1). - const waitResult = await runtime.waitForMessage(handle, { - typeFilter: typeFilter as string[] | undefined, - timeoutMs: params.timeoutMs ?? undefined, - signal - }) - if (signal?.aborted) { - return { messages: [], count: 0 } - } - if (waitResult === 'cancelled') { - throw new OrchestrationError( - 'consumer_fenced', - 'This direct mailbox became owned by a Run while the check was waiting.' - ) - } - return readAndReturn() - } - }), - - defineMethod({ - name: 'orchestration.reply', - params: ReplyParams, - handler: async ( - params, - { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId } - ) => { - const db = runtime.getOrchestrationDb() - const original = db.getMessageById(params.id) - if (!original) { - throw new Error(`Message not found: ${params.id}`) - } - if ( - legacyCoordinatorRunId && - (original.run_id !== legacyCoordinatorRunId || - (params.run !== undefined && params.run !== legacyCoordinatorRunId)) - ) { - throw new OrchestrationError( - 'request_mismatch', - `Message ${params.id} does not belong to this adopted Run.`, - { effectsApplied: false } - ) - } - if ( - original.run_id === ORCHESTRATION_LEGACY_RUN_ID || - original.delivery_contract === 'legacy_direct' || - original.delivery_contract === 'audit_only' - ) { - throw new OrchestrationError( - 'legacy_read_only', - 'Legacy orchestration messages are inspect-only; no reply was applied.', - { effectsApplied: false } - ) - } - - const question = db.getQuestion(params.id) - if (question) { - const run = resolveRunScope(runtime, { - runId: params.run ?? question.run_id, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const answered = db.answerQuestion({ - messageId: question.message_id, - runId: run.id, - consumerGeneration: run.consumer_generation, - body: params.body - }) - const federated = db.getFederatedDispatch(question.dispatch_id) - if (federated) { - db.enqueueFederationRelay({ - dispatchId: question.dispatch_id, - direction: 'to_worker', - kind: 'reply', - payload: JSON.stringify({ - questionId: question.message_id, - answerMessageId: answered.message.id, - body: params.body - }) - }) - runtime.ensureOrchestrationFederationRelay(run.id) - } else { - runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') - } - return { - message: answered.message, - question: answered.question, - duplicate: answered.duplicate - } - } - - db.markAsRead([original.id]) - - const reply = db.insertMessage({ - from: params.from ?? original.to_handle, - to: original.from_handle, - subject: `Re: ${original.subject}`, - body: params.body, - threadId: original.thread_id ?? original.id, - runId: original.run_id - }) - - runtime.notifyMessageArrived(reply.to_handle, reply.type) - return { message: reply } - } - }), - - defineMethod({ - name: 'orchestration.inbox', - params: InboxParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - // Why: stale/unknown handles return empty rather than error — historical rows survive handle deletion (design doc §3.3). - const messages = params.terminal - ? db.getAllMessagesForHandle(params.terminal, params.limit) - : db.getInbox(params.limit) - return { messages, count: messages.length } - } - }), - - defineMethod({ - name: 'orchestration.taskCreate', - params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) - : null - const task = db.createTask({ - spec: params.spec, - taskTitle: params.taskTitle, - displayName: params.displayName, - deps, - parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, - ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation - ? { - createdByPaneKey: creatorAuthority.paneKey, - createdByProcessIncarnation: creatorAuthority.processIncarnation, - createdByRunGeneration: run.consumer_generation - } - : {}), - runId: run.id - }) - return { task } - } - }), - - defineMethod({ - name: 'orchestration.taskList', - params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const explicitRun = params.run ? db.getRun(params.run) : undefined - const run = - explicitRun?.legacy === 1 - ? explicitRun - : resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: params.run === undefined, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. - const joined = db.listTasksWithDispatch({ - status: params.status as TaskStatus, - ready: params.ready, - runId: run.id - }) - const tasks = joined.map((row) => { - const { assignee_handle, dispatch_id, ...base } = row - if (base.status === 'dispatched') { - return { ...base, assignee_handle, dispatch_id } - } - return base - }) - return { - runId: run.id, - legacyReadOnly: run.legacy === 1, - tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, - count: tasks.length - } - } - }), - - defineMethod({ - name: 'orchestration.taskUpdate', - params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { - const db = runtime.getOrchestrationDb() - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.callerTerminalHandle, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - const existing = db.getTask(params.id) - if (!existing || existing.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${params.id} was not found in Run ${run.id}.` - ) - } - const task = db.updateTaskStatus(params.id, params.status, params.result) - if (!task) { - throw new Error(`Task not found: ${params.id}`) - } - return { task } - } - }), - - defineMethod({ - name: 'orchestration.dispatch', - params: DispatchParams, - handler: async ( - params, - { - orchestrationCompatibilityEvidence, - runtime, - legacyCoordinatorRunId, - revalidateLegacyCoordinator - } - ) => { - const db = runtime.getOrchestrationDb() - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const run = resolveRunScope(runtime, { - runId: params.run, - callerTerminalHandle: params.from, - requireCurrentConsumer: true, - legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence - }) - if (task.run_id !== run.id) { - throw new OrchestrationError( - 'task_not_found', - `Task ${task.id} was not found in Run ${run.id}.` - ) - } - - // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. - if (params.dryRun) { - const maxDepth = runtime.getNestedWorkerMaxDepth() - const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), - maxDepth - ) - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: 'ctx_dryrun', - canDispatchSubWorkers: previewDepth < maxDepth, - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: params.to ?? 'worker', - devMode: params.devMode, - ...(params.to - ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(params.to) } - : {}) - }) - return { dispatch: null, injected: false, dryRun: true, preamble } - } - - if (!params.to) { - throw new Error('Missing --to') - } - const to = params.to - - if (task.status !== 'ready') { - throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) - } - - // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(to) - if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) - } - } - - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) - const assigneePaneKey = - dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined - const processIncarnation = - dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation - ? dispatchAuthority.processIncarnation - : undefined - if (params.inject && (!assigneePaneKey || !processIncarnation)) { - throw new OrchestrationError( - 'stable_pane_required', - `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` - ) - } - - revalidateLegacyCoordinator?.() - const ctx = db.createDispatchContext({ - taskId: params.task, - assigneeHandle: to, - assigneePaneKey, - launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, - processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), - maxDepth: runtime.getNestedWorkerMaxDepth() - }) - const dispatchCapability = params.inject - ? db.mintDispatchCapability({ - dispatchId: ctx.id, - paneKey: assigneePaneKey as string, - processIncarnation: processIncarnation as string - }) - : undefined - - // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. - const preamble = buildDispatchPreamble({ - taskId: task.id, - dispatchId: ctx.id, - canDispatchSubWorkers: ctx.depth < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle: to, - dispatchCapability, - devMode: params.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(to) - }) - - let injected = false - if (params.inject) { - try { - await runtime.sendTerminalAgentPrompt(to, preamble) - injected = true - } catch (err) { - db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err)) - throw err - } - } - - // Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response. - if (params.returnPreamble) { - return { dispatch: ctx, injected, preamble } - } - return { dispatch: ctx, injected } - } - }), - - defineMethod({ - name: 'orchestration.dispatchShow', - params: DispatchShowParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (!params.task) { - throw new Error('Missing --task') - } - const ctx = db.getDispatchContext(params.task) - - // Why: the preamble is derived from the current task spec, so it can be regenerated deterministically even after dispatch completes. - if (params.preamble) { - const task = db.getTask(params.task) - if (!task) { - throw new Error(`Task not found: ${params.task}`) - } - const workerHandle = ctx?.assignee_handle ?? 'worker' - const preamble = buildDispatchPreamble({ - taskId: task.id, - // Why: use the real ctx.id when present so the preview matches what was injected; placeholder when no dispatch has occurred yet. - dispatchId: ctx?.id ?? 'ctx_preview', - canDispatchSubWorkers: (ctx?.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), - taskSpec: task.spec, - coordinatorHandle: params.from ?? 'coordinator', - workerHandle, - devMode: params.devMode, - ...(ctx ? { cliCommand: runtime.getTerminalOrchestrationCliCommand(workerHandle) } : {}) - }) - return { dispatch: ctx ?? null, preamble } - } - - return { dispatch: ctx ?? null } - } - }), - - defineMethod({ - name: 'orchestration.ask', - params: AskParams, - handler: async ( - params, - { runtime, signal, orchestrationCapability, recordMutationReceipt } - ) => { - // Why: group addresses have no unambiguous first-answer authority. - if (params.to && isGroupAddress(params.to)) { - throw new Error( - 'ask does not support group addresses; use send for non-blocking fan-out questions' - ) - } - - const db = runtime.getOrchestrationDb() - const from = params.from ?? 'unknown' - // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. - const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) - const paneKey = runtime.getTerminalPaneKey(from) ?? undefined - const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined - if (remoteAttachment) { - rejectFederatedExplicitTarget(params) - return askRemoteRunHome({ - params: { ...params, timeoutMs }, - runtime, - signal, - orchestrationCapability, - recordMutationReceipt, - from, - paneKey: paneKey as string, - dispatchId: remoteAttachment.dispatch_id, - taskId: remoteAttachment.task_id - }) - } - const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) - if (!activeDispatch) { - throw new OrchestrationError( - 'dispatch_inactive', - 'ask requires an active supervised Dispatch.' - ) - } - if (activeDispatch.capability_hash) { - const authority = db.verifyDispatchCapability({ - dispatchId: activeDispatch.id, - capability: orchestrationCapability, - paneKey, - processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined - }) - if (!authority.valid) { - throw new OrchestrationError('dispatch_capability_invalid', authority.reason) - } - } - const options = - params.options - ?.split(',') - .map((s) => s.trim()) - .filter(Boolean) ?? [] - let question = params.resume ? db.getQuestion(params.resume) : undefined - if (params.resume) { - if (!question || question.dispatch_id !== activeDispatch.id) { - throw new OrchestrationError( - 'question_not_found', - `Question ${params.resume} does not belong to this active Dispatch.` - ) - } - } else { - const run = db.getRun(activeDispatch.run_id) - if (!run || run.legacy === 1) { - throw new OrchestrationError( - 'run_not_found', - `Run ${activeDispatch.run_id} was not found.` - ) - } - if (params.run && params.run !== run.id) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` - ) - } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { - throw new OrchestrationError( - 'dispatch_run_mismatch', - `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` - ) - } - const created = db.createQuestion({ - runId: run.id, - dispatchId: activeDispatch.id, - askerHandle: from, - question: params.question as string, - options - }) - question = created.question - runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) - } - - const questionId = question.message_id - recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const current = db.getQuestion(questionId) - if (!current || current.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its Dispatch is inactive.` - ) - } - if (current.status === 'answered') { - return { - answer: current.answer_body, - messageId: questionId, - answerMessageId: current.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { - timeoutMs: remainingMs, - signal - }) - } - } - }), - + ...ORCHESTRATION_SEND_METHODS, + ...ORCHESTRATION_CHECK_METHODS, + ...ORCHESTRATION_MESSAGE_METHODS, + ...ORCHESTRATION_DISPATCH_METHODS, + ...ORCHESTRATION_ASK_METHODS, ...ORCHESTRATION_GATE_METHODS, - - defineMethod({ - name: 'orchestration.reset', - params: ResetParams, - handler: (params, { runtime }) => { - const db = runtime.getOrchestrationDb() - if (params.all) { - runtime.stopOrchestrationFederationRelay() - db.resetAll() - return { reset: 'all' } - } - if (params.tasks) { - runtime.stopOrchestrationFederationRelay() - db.resetTasks() - return { reset: 'tasks' } - } - if (params.messages) { - db.resetMessages() - return { reset: 'messages' } - } - throw new Error('Invalid reset scope') - } - }) + ...ORCHESTRATION_RESET_METHODS ] - -async function askRemoteRunHome(args: { - params: z.infer - runtime: OrcaRuntimeService - signal?: AbortSignal - orchestrationCapability?: string - recordMutationReceipt?: (receipt: unknown) => void - from: string - paneKey: string - dispatchId: string - taskId: string -}): Promise { - const db = args.runtime.getOrchestrationDb() - const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) - if ( - !db.verifyRemoteAttachmentAuthority({ - dispatchId: args.dispatchId, - capability: args.orchestrationCapability, - paneKey: args.paneKey, - processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) - }) - ) { - throw new OrchestrationError( - 'dispatch_capability_invalid', - 'The remote Dispatch capability or exact worker process is invalid.' - ) - } - const options = - args.params.options - ?.split(',') - .map((option) => option.trim()) - .filter(Boolean) ?? [] - let questionId = args.params.resume - if (questionId) { - const existing = db.getRemoteQuestion(questionId) - if (!existing || existing.dispatch_id !== args.dispatchId) { - throw new OrchestrationError( - 'question_not_found', - `Question ${questionId} does not belong to this remote Dispatch.` - ) - } - } else { - const relay = db.enqueueFederationRelay({ - dispatchId: args.dispatchId, - direction: 'to_home', - kind: 'question', - payload: JSON.stringify({ - from: args.from, - subject: 'Question', - body: args.params.question as string, - type: 'question', - priority: 'normal', - threadId: null, - payload: JSON.stringify({ - taskId: args.taskId, - dispatchId: args.dispatchId, - question: args.params.question, - options - }) - }), - remoteQuestion: true - }) - questionId = relay.message_id - } - args.recordMutationReceipt?.({ - accepted: true, - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - }) - const deadline = Date.now() + timeoutMs - while (true) { - const question = db.getRemoteQuestion(questionId) - if (!question || question.status === 'closed') { - throw new OrchestrationError( - 'dispatch_inactive', - `Question ${questionId} closed because its remote Dispatch is inactive.` - ) - } - if (question.status === 'answered') { - return { - answer: question.answer_body, - messageId: questionId, - answerMessageId: question.answer_message_id, - threadId: questionId, - timedOut: false, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - if (args.signal?.aborted) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: false, - cancelled: true, - connectionLost: true, - timeoutMs - } - } - const remainingMs = deadline - Date.now() - if (remainingMs <= 0) { - return { - answer: null, - messageId: questionId, - threadId: questionId, - timedOut: true, - cancelled: false, - connectionLost: false, - timeoutMs - } - } - await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { - timeoutMs: remainingMs, - signal: args.signal - }) - } -} diff --git a/src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts b/src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts new file mode 100644 index 00000000000..11264ea3035 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-active-screencasts-by-page-id.ts @@ -0,0 +1,209 @@ +// @ts-nocheck -- mechanically split class members. +import type { + BrowserCommandTargetParams, + ResolvedBrowserCommandTarget, + ResolvedBrowserPageWebContents, + RuntimeBrowserCommandHost +} from './runtime-browser-commands-browser-command-target-params' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { BrowserError } from '../browser/browser-error' +import { webContents } from 'electron' +import { + waitForTabRegistration, + waitForWorktreeTabRegistration +} from '../ipc/browser-tab-registration-wait' +import type { BrowserSnapshotResult } from '../../shared/runtime-types' +import { RuntimeBrowserCommandsState } from './runtime-browser-commands-state' + +export class RuntimeBrowserCommandsWithActiveScreencastsByPageId extends RuntimeBrowserCommandsState { + constructor(private readonly host: RuntimeBrowserCommandHost) { + super() + } + + protected requireAgentBrowserBridge(): AgentBrowserBridge { + const bridge = this.host.getAgentBrowserBridge() + if (!bridge) { + throw new BrowserError('browser_no_tab', 'No browser session is active') + } + return bridge + } + + /** + * Retires a session row for a page nothing here can close any more. + * + * A client-hosted page whose runtime record is gone -- released as unrecoverable, or created by a + * build that kept its records in memory only -- still leaves a row every paired device can see, + * and every close path below is keyed on a live guest this runtime does not have. Without this + * the row's X fails closed and the ghost outlives the browser it named. + */ + private retireGhostBrowserSessionRow( + worktreeId: string | undefined, + browserPageId: string + ): boolean { + return ( + worktreeId !== undefined && + this.host.retireRuntimeOwnedBrowserSessionTab?.(worktreeId, browserPageId) === true + ) + } + + protected hasLiveRegisteredBrowserTab( + bridge: AgentBrowserBridge, + worktreeId: string | undefined + ): boolean { + for (const [, webContentsId] of bridge.getRegisteredTabs(worktreeId)) { + const guest = webContents.fromId(webContentsId) + if (guest && !guest.isDestroyed()) { + return true + } + } + return false + } + + protected hasLiveRegisteredBrowserPage( + bridge: AgentBrowserBridge, + worktreeId: string | undefined, + browserPageId: string + ): boolean { + const webContentsId = bridge.getRegisteredTabs(worktreeId).get(browserPageId) + if (webContentsId == null) { + return false + } + const guest = webContents.fromId(webContentsId) + return Boolean(guest && !guest.isDestroyed()) + } + + // Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id. + private async resolveBrowserWorktreeId(selector?: string): Promise { + if (!selector) { + // Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, undefined)) { + try { + await this.ensureBrowserWorktreeActive(undefined) + } catch { + // Window may not exist yet (e.g. during startup or in tests) + } + } + return undefined + } + + const worktreeId = (await this.host.resolveWorktreeSelector(selector)).id + // Why: explicit selectors are user intent, so resolution errors surface (not silently widen scope); only activation stays best-effort. + const bridge = this.host.getAgentBrowserBridge() + if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) { + try { + await this.ensureBrowserWorktreeActive(worktreeId) + } catch { + // Fall through with the validated worktree id so routing stays scoped to the caller's explicit selector. + } + } + return worktreeId + } + + protected async resolveBrowserCommandTarget( + params: BrowserCommandTargetParams + ): Promise { + const browserPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined + if (!browserPageId) { + return { + worktreeId: await this.resolveBrowserWorktreeId(params.worktree) + } + } + + const worktreeId = params.worktree + ? (await this.host.resolveWorktreeSelector(params.worktree)).id + : undefined + const bridge = this.host.getAgentBrowserBridge() + if (bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { + try { + await this.ensureBrowserPageActive(worktreeId, browserPageId) + } catch { + // Fall through with the explicit page target; downstream routing surfaces a clear "tab not found" error if wake fails. + } + } + return { + // Why: an explicit browserPageId is already a stable tab identity, so don't auto-resolve cwd worktree scoping on top of it. + worktreeId, + browserPageId + } + } + + protected resolveBrowserPageWebContents( + worktreeId: string | undefined, + browserPageId: string | undefined + ): ResolvedBrowserPageWebContents { + const bridge = this.requireAgentBrowserBridge() + const resolvedPageId = browserPageId ?? bridge.getActivePageId(worktreeId) + if (!resolvedPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const webContentsId = bridge.getRegisteredTabs(worktreeId).get(resolvedPageId) + if (webContentsId == null) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${resolvedPageId} was not found${scope}` + ) + } + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed()) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${resolvedPageId} is no longer available` + ) + } + return { browserPageId: resolvedPageId, webContents: guest } + } + + // Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane. + private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {}) + // Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep. + await waitForWorktreeTabRegistration(worktreeId) + } + + protected async ensureBrowserPageActive( + worktreeId: string | undefined, + browserPageId: string + ): Promise { + const win = this.host.getAuthoritativeWindow() + win.webContents.send( + 'browser:activateView', + worktreeId ? { worktreeId, browserPageId } : { browserPageId } + ) + await waitForTabRegistration(browserPageId) + } + + // Why: helper-driven clicks can bypass Electron navigation events; push authoritative URL/title updates after automation. + private notifyRendererNavigation(browserPageId: string, url: string, title: string): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:navigation-update', { browserPageId, url, title }) + } catch { + // Window may not exist during shutdown + } + } + + // Why: carry worktreeId (not a global setActiveWorktree) so one agent's --focus can't steal the screen from another agent's parallel worktree. + private notifyRendererBrowserPaneFocus( + worktreeId: string | undefined, + browserPageId: string + ): void { + try { + const win = this.host.getAuthoritativeWindow() + win.webContents.send('browser:pane-focus', { + worktreeId: worktreeId ?? null, + browserPageId + }) + } catch { + // Window may not exist during shutdown + } + } + + async browserSnapshot(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().snapshot(target.worktreeId, target.browserPageId) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-clear.ts b/src/main/runtime/runtime-browser-commands-browser-clear.ts new file mode 100644 index 00000000000..e7d2c36b543 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-clear.ts @@ -0,0 +1,218 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserTabList } from './runtime-browser-commands-browser-tab-list' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import type { + BrowserCaptureStartResult, + BrowserCaptureStopResult, + BrowserClearResult, + BrowserConsoleResult, + BrowserCookieDeleteResult, + BrowserCookieGetResult, + BrowserCookieSetResult, + BrowserGeolocationResult, + BrowserInterceptDisableResult, + BrowserInterceptEnableResult, + BrowserKeypressResult, + BrowserPdfResult, + BrowserScreenshotResult, + BrowserSelectAllResult, + BrowserViewportResult +} from '../../shared/runtime-types' + +export class RuntimeBrowserCommandsWithBrowserClear extends RuntimeBrowserCommandsWithBrowserTabList { + async browserClear( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clear( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelectAll( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().selectAll( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserKeypress( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keypress( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserPdf(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().pdf(target.worktreeId, target.browserPageId) + } + + async browserFullScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fullPageScreenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + // ── Cookie management ── + + async browserCookieGet( + params: { url?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieGet( + params.url, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieSet( + params: { + name: string + value: string + domain?: string + path?: string + secure?: boolean + httpOnly?: boolean + sameSite?: string + expires?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieSet( + params, + target.worktreeId, + target.browserPageId + ) + } + + async browserCookieDelete( + params: { + name: string + domain?: string + url?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().cookieDelete( + params.name, + params.domain, + params.url, + target.worktreeId, + target.browserPageId + ) + } + + // ── Viewport ── + + async browserSetViewport( + params: { + width: number + height: number + deviceScaleFactor?: number + mobile?: boolean + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setViewport( + params.width, + params.height, + params.deviceScaleFactor, + params.mobile, + target.worktreeId, + target.browserPageId + ) + } + + // ── Geolocation ── + + async browserSetGeolocation( + params: { + latitude: number + longitude: number + accuracy?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setGeolocation( + params.latitude, + params.longitude, + params.accuracy, + target.worktreeId, + target.browserPageId + ) + } + + // ── Request interception ── + + async browserInterceptEnable( + params: { + patterns?: string[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptEnable( + params.patterns, + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptDisable( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptDisable( + target.worktreeId, + target.browserPageId + ) + } + + async browserInterceptList(params: BrowserCommandTargetParams): Promise<{ requests: unknown[] }> { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().interceptList(target.worktreeId, target.browserPageId) + } + + // ── Console/network capture ── + + async browserCaptureStart( + params: BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStart(target.worktreeId, target.browserPageId) + } + + async browserCaptureStop(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().captureStop(target.worktreeId, target.browserPageId) + } + + async browserConsoleLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().consoleLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-click.ts b/src/main/runtime/runtime-browser-commands-browser-click.ts new file mode 100644 index 00000000000..49fe9b7900c --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-click.ts @@ -0,0 +1,174 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithActiveScreencastsByPageId } from './runtime-browser-commands-active-screencasts-by-page-id' +import type { + ActiveBrowserScreencastPage, + BrowserCommandTargetParams +} from './runtime-browser-commands-browser-command-target-params' +import { + applySharedScreencastFrameBudget, + hasScreencastViewportSize +} from './runtime-browser-commands-browser-command-target-params' +import type { + BrowserBackResult, + BrowserClickResult, + BrowserFillResult, + BrowserGotoResult, + BrowserReloadResult, + BrowserScreenshotResult, + BrowserScrollResult, + BrowserSelectResult, + BrowserTypeResult +} from '../../shared/runtime-types' +import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' + +export class RuntimeBrowserCommandsWithBrowserClick extends RuntimeBrowserCommandsWithActiveScreencastsByPageId { + async browserClick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.click(params.element, target.worktreeId, target.browserPageId) + // Why: clicks can trigger navigation, so push the tab's live URL/title to the renderer even when automation targeted a non-active page. + const page = bridge.getPageInfo(target.worktreeId, target.browserPageId) + if (page) { + this.notifyRendererNavigation(page.browserPageId, page.url, page.title) + } + return result + } + + async browserGoto( + params: { url: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.goto(params.url, target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + if (!this.host.getAvailableAuthoritativeWindow() && target.worktreeId) { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(target.worktreeId) + } + return result + } + + async browserFill( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().fill( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserType( + params: { input: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().type( + params.input, + target.worktreeId, + target.browserPageId + ) + } + + async browserSelect( + params: { + element: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().select( + params.element, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserScroll( + params: { direction: 'up' | 'down'; amount?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scroll( + params.direction, + params.amount, + target.worktreeId, + target.browserPageId + ) + } + + async browserBack(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.back(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserReload(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const bridge = this.requireAgentBrowserBridge() + const result = await bridge.reload(target.worktreeId, target.browserPageId) + const pageId = bridge.getActivePageId(target.worktreeId, target.browserPageId) + if (pageId) { + this.notifyRendererNavigation(pageId, result.url, result.title) + } + return result + } + + async browserScreenshot( + params: { + format?: 'png' | 'jpeg' + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().screenshot( + params.format, + target.worktreeId, + target.browserPageId + ) + } + + // The single leave path: an explicit stop, a ghost eviction and a same-device replacement all + // unwind through here, so viewport hand-off, budget release and stream teardown cannot drift. + private leaveScreencastSubscriber( + active: ActiveBrowserScreencastPage, + subscriptionId: string, + session: BrowserScreencastSession + ): void { + const subscriber = active.subscribers.get(subscriptionId) + if (!subscriber) { + return + } + active.subscribers.delete(subscriptionId) + subscriber.resolveDone() + if (active.viewportOwnerSubscriptionId === subscriptionId) { + const fallback = Array.from(active.subscribers.entries()).findLast(([, candidate]) => + hasScreencastViewportSize(candidate.viewport) + ) + active.viewportOwnerSubscriptionId = fallback?.[0] ?? null + if (fallback) { + void session.updateViewport(fallback[1].viewport).catch(() => {}) + } + } + if (active.subscribers.size === 0) { + active.stopping = true + session.stop() + return + } + // Why: a departed subscriber's caps would otherwise pin the shared stream for + // the rest of its life, long after the client that asked for them is gone. + void applySharedScreencastFrameBudget(active, session).catch(() => {}) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts new file mode 100644 index 00000000000..c7766703926 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-command-target-params.ts @@ -0,0 +1,191 @@ +// @ts-nocheck -- mechanically split declarations. +import type { BrowserScreencastResult } from '../../shared/runtime-types' +import type { + BrowserScreencastFrameBudget, + BrowserScreencastSession, + BrowserScreencastViewport +} from '../browser/browser-screencast-stream-types' +import type { ScreencastSubscriberDeliveryState } from './browser-screencast-ghost-subscriber-eviction' +import { + browserScreencastFrameBudgetsEqual, + mergeBrowserScreencastFrameBudgets +} from '../browser/browser-screencast-frame-budget' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' +import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' +import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import type { BrowserWindow } from 'electron' +import type { BrowserBackend } from '../browser/browser-backend' +import type { BrowserSessionTabSelectionOptions } from './browser-tab-create-publication' + +export type BrowserCommandTargetParams = { + worktree?: string + page?: string +} + +export type ResolvedBrowserCommandTarget = { + worktreeId?: string + browserPageId?: string +} + +export type ResolvedBrowserPageWebContents = { + browserPageId: string + webContents: Electron.WebContents +} + +export type BrowserScreencastParams = { + format: 'jpeg' | 'png' + quality?: number + maxWidth?: number + maxHeight?: number + viewportWidth?: number + viewportHeight?: number + deviceScaleFactor?: number + mobile?: boolean + everyNthFrame?: number + minFrameIntervalMs?: number +} & BrowserCommandTargetParams + +export type BrowserScreencastStartResult = { + subscriptionId: string + ready: Extract + // The frame budget belongs to the shared page, not to one subscriber's handle. + session: Omit + // Why: callers gate frames until they have emitted `ready`, and the snapshot captured + // for a joining subscriber lands inside that window. This replays it once the gate opens. + flushPendingFrame: () => void +} + +export type ActiveBrowserScreencastSubscriber = { + sendBinary: (bytes: Uint8Array) => boolean | void + emit?: (event: BrowserScreencastResult) => void + done: Promise + resolveDone: () => void + viewport: BrowserScreencastViewport + budget: BrowserScreencastFrameBudget + pendingFrame: Uint8Array | null + // Why: identifies the viewer across reconnects, which the RPC connectionId cannot — a new + // socket never reuses the old id, so a reconnecting device would stack a second subscription. + pairedDeviceId?: string + delivery: ScreencastSubscriberDeliveryState +} + +export type ActiveBrowserScreencastPage = { + format: 'jpeg' | 'png' + session: BrowserScreencastSession | null + started: Promise + stopping: boolean + subscribers: Map + viewportOwnerSubscriptionId: string | null + appliedBudget: BrowserScreencastFrameBudget +} + +export async function applySharedScreencastFrameBudget( + active: ActiveBrowserScreencastPage, + session: BrowserScreencastSession +): Promise { + const merged = mergeBrowserScreencastFrameBudgets( + Array.from(active.subscribers.values(), (subscriber) => subscriber.budget) + ) + if (!merged || browserScreencastFrameBudgetsEqual(merged, active.appliedBudget)) { + return + } + active.appliedBudget = merged + await session.updateFrameBudget(merged) +} + +export function normalizeScreencastViewport( + params: BrowserScreencastParams +): BrowserScreencastViewport { + return { + viewportWidth: clampOptionalInteger(params.viewportWidth, 320, 3840), + viewportHeight: clampOptionalInteger(params.viewportHeight, 240, 2160), + deviceScaleFactor: clampOptionalNumber(params.deviceScaleFactor, 1, 4), + mobile: params.mobile === true + } +} + +export function normalizeScreencastFrameBudget( + params: BrowserScreencastParams +): BrowserScreencastFrameBudget { + return { + quality: clampInteger(params.quality, 10, 100, 70), + maxWidth: clampInteger(params.maxWidth, 320, 3840, 1440), + maxHeight: clampInteger(params.maxHeight, 240, 2160, 1200), + everyNthFrame: clampInteger(params.everyNthFrame, 1, 10, 2), + minFrameIntervalMs: clampInteger(params.minFrameIntervalMs, 0, 1000, 0) + } +} + +export function hasScreencastViewportSize(viewport: BrowserScreencastViewport): boolean { + return viewport.viewportWidth !== undefined && viewport.viewportHeight !== undefined +} + +export function clampInteger( + value: number | undefined, + min: number, + max: number, + fallback: number +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback + } + return Math.min(max, Math.max(min, Math.round(value))) +} + +export function clampOptionalInteger( + value: number | undefined, + min: number, + max: number +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.min(max, Math.max(min, Math.round(value))) +} + +export function clampOptionalNumber( + value: number | undefined, + min: number, + max: number +): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.min(max, Math.max(min, value)) +} + +export type RuntimeBrowserCommandHost = { + getAgentBrowserBridge(): AgentBrowserBridge | null + resolveWorktreeSelector(selector: string): Promise<{ + id: string + repoId?: string + hostId?: ExecutionHostId + }> + resolveBrowserWorkspace(selector: string): Promise<{ + id: string + repoId?: string + hostId?: ExecutionHostId + }> + resolveBrowserNetworkExecutionHost(worktree?: { + id: string + repoId?: string + hostId?: ExecutionHostId + }): BrowserNetworkExecutionHost | Promise + getBrowserHostLeaseRegistry(): BrowserHostLeaseRegistry + getRuntimeBrowserPageRegistry(): RuntimeBrowserPageRegistry + getAuthoritativeWindow(): BrowserWindow + getAvailableAuthoritativeWindow(): BrowserWindow | null + // Why: headless serve backs pages with a main-process offscreen backend; null when the environment can't support offscreen browsing. + getOffscreenBrowserBackend(): BrowserBackend | null + // Why: the session-tab snapshot owns focus, so a headless create must mark itself active or paired clients snap back to a terminal. + markHeadlessBrowserSessionTabActive?( + worktreeId: string | undefined, + browserPageId: string, + options?: BrowserSessionTabSelectionOptions + ): void + notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void + /** True when a runtime-owned session row for that page existed and was retired. */ + retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void +} diff --git a/src/main/runtime/runtime-browser-commands-browser-network-log.ts b/src/main/runtime/runtime-browser-commands-browser-network-log.ts new file mode 100644 index 00000000000..b89dfc6b99c --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-network-log.ts @@ -0,0 +1,200 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserClear } from './runtime-browser-commands-browser-clear' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import { clampOptionalNumber } from './runtime-browser-commands-browser-command-target-params' +import type { BrowserNetworkLogResult } from '../../shared/runtime-types' + +export class RuntimeBrowserCommandsWithBrowserNetworkLog extends RuntimeBrowserCommandsWithBrowserClear { + async browserNetworkLog( + params: { limit?: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().networkLog( + params.limit, + target.worktreeId, + target.browserPageId + ) + } + + // ── Additional core commands ── + + async browserDblclick( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dblclick( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserForward(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().forward(target.worktreeId, target.browserPageId) + } + + async browserScrollIntoView( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().scrollIntoView( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserGet( + params: { + what: string + selector?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().get( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + async browserIs( + params: { what: string; selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().is( + params.what, + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── Keyboard insert text ── + + async browserKeyboardInsertText( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().keyboardInsertText( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Mouse commands ── + + async browserMouseMove( + params: { x: number; y: number } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseMove( + params.x, + params.y, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseDown( + params: { button?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseDown( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseClick( + params: { + x: number + y: number + button?: string + radius?: number + modifiers?: ('cmd' | 'ctrl' | 'alt' | 'shift')[] + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseClick( + params.x, + params.y, + params.button, + target.worktreeId, + target.browserPageId, + clampOptionalNumber(params.radius, 0, 64), + params.modifiers + ) + } + + async browserMouseUp(params: { button?: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseUp( + params.button, + target.worktreeId, + target.browserPageId + ) + } + + async browserMouseWheel( + params: { + dy: number + dx?: number + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().mouseWheel( + params.dy, + params.dx, + target.worktreeId, + target.browserPageId + ) + } + + // ── Find (semantic locators) ── + + async browserFind( + params: { + locator: string + value: string + action: string + text?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().find( + params.locator, + params.value, + params.action, + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Set commands ── + + async browserSetDevice(params: { name: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setDevice( + params.name, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetOffline( + params: { state?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setOffline( + params.state, + target.worktreeId, + target.browserPageId + ) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts b/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts new file mode 100644 index 00000000000..585af8cd756 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-profile-import-from-browser.ts @@ -0,0 +1,70 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserTabSetProfile } from './runtime-browser-commands-browser-tab-set-profile' +import type { + BrowserProfileClearDefaultCookiesResult, + BrowserProfileImportFromBrowserResult +} from '../../shared/runtime-types' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { + detectInstalledBrowsers, + importCookiesFromBrowser, + selectBrowserProfile +} from '../browser/browser-cookie-import' + +export class RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser extends RuntimeBrowserCommandsWithBrowserTabSetProfile { + async browserProfileImportFromBrowser(params: { + profileId: string + browserFamily: string + browserProfile?: string + supportsPartitionSkippedCookies?: true + }): Promise { + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + return { ok: false, reason: 'Session profile not found.' } + } + if ( + params.browserProfile && + (/[/\\]/.test(params.browserProfile) || params.browserProfile.includes('..')) + ) { + return { ok: false, reason: 'Invalid browser profile name.' } + } + + const browsers = detectInstalledBrowsers() + let browser = browsers.find((candidate) => candidate.family === params.browserFamily) + if (!browser) { + return { ok: false, reason: 'Browser not found on this system.' } + } + + if (params.browserProfile && params.browserProfile !== browser.selectedProfile) { + const reselected = selectBrowserProfile(browser, params.browserProfile) + if (!reselected) { + return { + ok: false, + reason: `No cookies database found for profile "${params.browserProfile}".` + } + } + browser = reselected + } + + const result = await importCookiesFromBrowser(browser, profile.partition, { + canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true + }) + if (!result.ok) { + return result + } + + const profileName = + browser.profiles.find((candidate) => candidate.directory === browser.selectedProfile)?.name ?? + browser.selectedProfile + browserSessionRegistry.updateProfileSource(params.profileId, { + browserFamily: browser.family, + profileName, + importedAt: Date.now() + }) + return { ...result, profileId: params.profileId } + } + + async browserProfileClearDefaultCookies(): Promise { + return { cleared: await browserSessionRegistry.clearDefaultSessionCookies() } + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-screencast.ts b/src/main/runtime/runtime-browser-commands-browser-screencast.ts new file mode 100644 index 00000000000..d77f2c13410 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-screencast.ts @@ -0,0 +1,206 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserClick } from './runtime-browser-commands-browser-click' +import type { + ActiveBrowserScreencastPage, + ActiveBrowserScreencastSubscriber, + BrowserCommandTargetParams, + BrowserScreencastParams, + BrowserScreencastStartResult +} from './runtime-browser-commands-browser-command-target-params' +import { + applySharedScreencastFrameBudget, + hasScreencastViewportSize, + normalizeScreencastFrameBudget, + normalizeScreencastViewport +} from './runtime-browser-commands-browser-command-target-params' +import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/runtime-types' +import { BrowserError } from '../browser/browser-error' +import { randomUUID } from 'node:crypto' +import { startBrowserScreencast } from '../browser/browser-screencast-stream' +import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission' +import { + INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY, + recordScreencastSubscriberSend, + screencastSubscriberIsGhost +} from './browser-screencast-ghost-subscriber-eviction' +import type { BrowserScreencastSession } from '../browser/browser-screencast-stream-types' + +export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserCommandsWithBrowserClick { + async browserScreencast( + params: BrowserScreencastParams, + stream: { + sendBinary: (bytes: Uint8Array) => boolean | void + emit?: (event: BrowserScreencastResult) => void + pairedDeviceId?: string + } + ): Promise { + if (await this.resolveClientHostedBrowserPage(params)) { + throw new BrowserError( + 'browser_error', + 'Client-hosted browser pages do not support server screencast.' + ) + } + const target = await this.resolveBrowserCommandTarget(params) + const { browserPageId, webContents: guest } = this.resolveBrowserPageWebContents( + target.worktreeId, + target.browserPageId + ) + const subscriptionId = `browser-screencast:${browserPageId}:${randomUUID()}` + const viewport = normalizeScreencastViewport(params) + const budget = normalizeScreencastFrameBudget(params) + let resolveSubscriberDone!: () => void + const subscriberDone = new Promise((resolve) => { + resolveSubscriberDone = resolve + }) + let createdPageStream = false + let active = this.activeScreencastsByPageId.get(browserPageId) + while (active?.stopping) { + await active.session?.done + active = this.activeScreencastsByPageId.get(browserPageId) + } + if (!active) { + createdPageStream = true + const subscribers = new Map() + const record = { + format: params.format, + session: null, + stopping: false, + subscribers, + viewportOwnerSubscriptionId: null, + appliedBudget: budget + } as ActiveBrowserScreencastPage + record.started = startBrowserScreencast(guest, { + format: params.format, + ...budget, + ...viewport, + onFrame: (bytes) => { + const ghosts: string[] = [] + for (const [subscriptionId, subscriber] of record.subscribers) { + // A slow viewer drops this frame without stalling every other viewer, but the + // newest refusal is retained so a gate that opens later can still be filled. + const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) + subscriber.pendingFrame = delivered ? null : bytes + subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) + if (screencastSubscriberIsGhost(subscriber.delivery)) { + ghosts.push(subscriptionId) + } + } + // Evicting after the fan-out keeps a teardown that stops the session from cutting the + // remaining viewers out of this frame. + for (const subscriptionId of ghosts) { + if (record.session) { + this.leaveScreencastSubscriber(record, subscriptionId, record.session) + } + } + return true + }, + onEvent: (event) => { + for (const subscriber of record.subscribers.values()) { + subscriber.emit?.(event) + } + }, + onError: (message) => { + for (const subscriber of record.subscribers.values()) { + subscriber.emit?.({ type: 'error', message }) + } + } + }) + active = record + this.activeScreencastsByPageId.set(browserPageId, record) + void record.started + .then((session) => { + record.session = session + return session.done + }) + .finally(() => { + if (this.activeScreencastsByPageId.get(browserPageId) === record) { + this.activeScreencastsByPageId.delete(browserPageId) + } + for (const subscriber of record.subscribers.values()) { + subscriber.resolveDone() + } + record.subscribers.clear() + }) + .catch(() => {}) + } + active.subscribers.set(subscriptionId, { + sendBinary: stream.sendBinary, + emit: stream.emit, + done: subscriberDone, + resolveDone: resolveSubscriberDone, + viewport, + budget, + pendingFrame: null, + pairedDeviceId: stream.pairedDeviceId, + delivery: INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY + }) + // Why: normalizeScreencastViewport keeps undefined dimensions, so a sizeless + // subscriber taking ownership would clear the emulation for every viewer. + if (hasScreencastViewportSize(viewport)) { + active.viewportOwnerSubscriptionId = subscriptionId + } + let session: BrowserScreencastSession + try { + session = await active.started + } catch (error) { + active.subscribers.delete(subscriptionId) + resolveSubscriberDone() + throw error + } + // Why: a device that force-quit and reconnected arrives on a fresh socket, so the + // connection-keyed replacement upstream cannot see its old subscription. Run this after the + // joiner is registered — the page then never empties mid-replacement and stops the stream. + if (stream.pairedDeviceId !== undefined) { + // Deleting the entry being visited is well defined for a Map, and no other entry is touched. + for (const [candidateId, candidate] of active.subscribers) { + if (candidateId !== subscriptionId && candidate.pairedDeviceId === stream.pairedDeviceId) { + this.leaveScreencastSubscriber(active, candidateId, session) + } + } + } + if (!createdPageStream) { + if (active.viewportOwnerSubscriptionId === subscriptionId) { + await session.updateViewport(viewport) + } + await applySharedScreencastFrameBudget(active, session) + } + return { + subscriptionId, + flushPendingFrame: () => { + const subscriber = active.subscribers.get(subscriptionId) + const bytes = subscriber?.pendingFrame + if (!subscriber || !bytes) { + return + } + const delivered = sendRemoteBrowserScreencastFrame(subscriber.sendBinary, bytes) + subscriber.pendingFrame = delivered ? null : bytes + // The replay is this subscriber's first chance to reach its socket, so it is also where + // an eviction-eligible delivery history starts. + subscriber.delivery = recordScreencastSubscriberSend(subscriber.delivery, delivered) + }, + session: { + done: subscriberDone, + stop: () => this.leaveScreencastSubscriber(active, subscriptionId, session), + updateViewport: session.updateViewport + }, + ready: { + type: 'ready', + subscriptionId, + browserPageId, + format: active.format, + tab: this.describeBrowserTab(browserPageId, target.worktreeId) + } + } + } + + async browserEval( + params: { expression: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().evaluate( + params.expression, + target.worktreeId, + target.browserPageId + ) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-set-headers.ts b/src/main/runtime/runtime-browser-commands-browser-set-headers.ts new file mode 100644 index 00000000000..f5bee7bd21f --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-set-headers.ts @@ -0,0 +1,193 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserNetworkLog } from './runtime-browser-commands-browser-network-log' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' + +export class RuntimeBrowserCommandsWithBrowserSetHeaders extends RuntimeBrowserCommandsWithBrowserNetworkLog { + async browserSetHeaders( + params: { headers: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setHeaders( + params.headers, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetCredentials( + params: { + user: string + pass: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setCredentials( + params.user, + params.pass, + target.worktreeId, + target.browserPageId + ) + } + + async browserSetMedia( + params: { + colorScheme?: string + reducedMotion?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().setMedia( + params.colorScheme, + params.reducedMotion, + target.worktreeId, + target.browserPageId + ) + } + + // ── Clipboard commands ── + + async browserClipboardRead(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardRead(target.worktreeId, target.browserPageId) + } + + async browserClipboardWrite( + params: { text: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().clipboardWrite( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + // ── Dialog commands ── + + async browserDialogAccept( + params: { text?: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogAccept( + params.text, + target.worktreeId, + target.browserPageId + ) + } + + async browserDialogDismiss(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().dialogDismiss(target.worktreeId, target.browserPageId) + } + + // ── Storage commands ── + + async browserStorageLocalGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageLocalClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageLocalClear( + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionGet( + params: { key: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionGet( + params.key, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionSet( + params: { + key: string + value: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionSet( + params.key, + params.value, + target.worktreeId, + target.browserPageId + ) + } + + async browserStorageSessionClear(params: BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().storageSessionClear( + target.worktreeId, + target.browserPageId + ) + } + + // ── Download command ── + + async browserDownload( + params: { + selector: string + path: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().download( + params.selector, + params.path, + target.worktreeId, + target.browserPageId + ) + } + + // ── Highlight command ── + + async browserHighlight( + params: { selector: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().highlight( + params.selector, + target.worktreeId, + target.browserPageId + ) + } + + // ── New: exec passthrough + tab lifecycle ── + + async browserExec(params: { command: string } & BrowserCommandTargetParams): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().exec( + params.command, + target.worktreeId, + target.browserPageId + ) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-close.ts b/src/main/runtime/runtime-browser-commands-browser-tab-close.ts new file mode 100644 index 00000000000..7b6845234f1 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-tab-close.ts @@ -0,0 +1,190 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser } from './runtime-browser-commands-browser-profile-import-from-browser' +import { closeRuntimeBrowserClientPage } from './runtime-browser-client-page-creation' +import { BrowserError } from '../browser/browser-error' +import { randomUUID } from 'node:crypto' +import { ipcMain } from 'electron' +import type { BrowserTabListResult } from '../../shared/runtime-types' +import { browserManager } from '../browser/browser-manager' +import { browserSessionRegistry } from '../browser/browser-session-registry' + +export class RuntimeBrowserCommandsWithBrowserTabClose extends RuntimeBrowserCommandsWithBrowserProfileImportFromBrowser { + async browserTabClose(params: { + index?: number + page?: string + worktree?: string + }): Promise<{ closed: boolean }> { + const pages = this.host.getRuntimeBrowserPageRegistry() + let clientPage = params.page ? pages.getPage(params.page) : undefined + if (clientPage) { + await this.assertClientPageWorkspace(clientPage, params.worktree) + } else if (!params.page) { + const workspaceId = params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + if (pages.listPages(workspaceId).length > 0) { + const tab = + params.index !== undefined + ? (await this.browserTabList({ worktree: params.worktree })).tabs[params.index] + : (await this.browserTabCurrent({ worktree: params.worktree })).tab + clientPage = tab ? pages.getPage(tab.browserPageId) : undefined + } + } + if (clientPage) { + const authority = this.host.getBrowserHostLeaseRegistry() + // Why: a retained page whose host quit has no placement left to command, and asking the + // absent host first would refuse the close and strand the tab with no way to dismiss it. + if (authority.getPlacement(clientPage.browserPageId)) { + await closeRuntimeBrowserClientPage(authority, { + browserPageId: clientPage.browserPageId, + placement: clientPage.placement + }) + } + if (!pages.retirePage(clientPage.browserPageId, clientPage.placement)) { + throw new Error('browser_page_placement_stale') + } + if (this.host.retireRuntimeOwnedBrowserSessionTab) { + this.host.retireRuntimeOwnedBrowserSessionTab( + clientPage.workspaceId, + clientPage.browserPageId + ) + } else { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(clientPage.workspaceId) + } + return { closed: true } + } + const namedPageId = + typeof params.page === 'string' && params.page.length > 0 ? params.page : null + const explicitPage = namedPageId !== null + const bridge = this.host.getAgentBrowserBridge() + if (!bridge) { + // Why before the refusal: a runtime with no browser session cannot be holding this page + // either, but it can still be carrying the session row that names it. + if ( + namedPageId && + params.worktree && + this.retireGhostBrowserSessionRow( + (await this.host.resolveWorktreeSelector(params.worktree)).id, + namedPageId + ) + ) { + return { closed: true } + } + throw new BrowserError('browser_no_tab', 'No browser session is active') + } + const worktreeId = explicitPage + ? params.worktree + ? (await this.host.resolveWorktreeSelector(params.worktree)).id + : undefined + : await this.resolveBrowserWorktreeId(params.worktree) + + let tabId: string | null = null + if (namedPageId !== null) { + tabId = namedPageId + } else if (params.index !== undefined) { + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + if (params.index < 0 || params.index >= entries.length) { + throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) + } + tabId = entries[params.index][0] + } else { + // Why: try the bridge first; fall back to the renderer for tabs whose webview hasn't mounted yet (e.g. just created). + const tabs = bridge.getRegisteredTabs(worktreeId) + const entries = [...tabs.entries()] + const activeEntry = entries.find(([, wcId]) => wcId === bridge.getActiveWebContentsId()) + if (activeEntry) { + tabId = activeEntry[0] + } + } + + // Why: headless serve has no renderer to ask, so destroy the offscreen page directly. + const authoritativeWindow = this.host.getAvailableAuthoritativeWindow() + const offscreen = authoritativeWindow ? null : this.host.getOffscreenBrowserBackend() + if (offscreen) { + // Why: resolve the active page for implicit close so we don't report success while closing nothing. + const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) + if (!resolvedTabId) { + return { closed: false } + } + if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { + if (this.retireGhostBrowserSessionRow(worktreeId, resolvedTabId)) { + return { closed: true } + } + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${resolvedTabId} was not found${scope}` + ) + } + await offscreen.closeTab(resolvedTabId) + // Why: closeTab only destroys the guest; without retirement, paired clients keep a + // dead session tab until an unrelated republish (closeMobileSessionTab already retires). + if (worktreeId) { + if (this.host.retireRuntimeOwnedBrowserSessionTab) { + this.host.retireRuntimeOwnedBrowserSessionTab(worktreeId, resolvedTabId) + } else { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) + } + } + return { closed: true } + } + + if (!authoritativeWindow && tabId && !bridge.getRegisteredTabs(worktreeId).has(tabId)) { + if (this.retireGhostBrowserSessionRow(worktreeId, tabId)) { + return { closed: true } + } + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError('browser_tab_not_found', `Browser page ${tabId} was not found${scope}`) + } + + const win = authoritativeWindow ?? this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCloseReply', handler) + reject(new Error('Tab close timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string; code?: 'browser_tab_not_found' } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCloseReply', handler) + if (reply.error) { + reject( + reply.code === 'browser_tab_not_found' + ? new BrowserError('browser_tab_not_found', reply.error) + : new Error(reply.error) + ) + } else { + resolve() + } + } + ipcMain.on('browser:tabCloseReply', handler) + // Why: pass worktreeId so the renderer scopes the close correctly instead of falling back to the globally active tab in the wrong worktree. + win.webContents.send('browser:requestTabClose', { requestId, tabId, worktreeId }) + }) + + return { closed: true } + } + + protected enrichBrowserTabInfo( + tab: BrowserTabListResult['tabs'][number] + ): BrowserTabListResult['tabs'][number] { + const rawProfileId = browserManager.getSessionProfileIdForTab(tab.browserPageId) + const profile = + browserSessionRegistry.getProfile(rawProfileId ?? 'default') ?? + browserSessionRegistry.getDefaultProfile() + return { + ...tab, + worktreeId: browserManager.getWorktreeIdForTab(tab.browserPageId) ?? null, + profileId: profile.id, + profileLabel: profile.label + } + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-create.ts b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts new file mode 100644 index 00000000000..f511995d18d --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-tab-create.ts @@ -0,0 +1,214 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserSetHeaders } from './runtime-browser-commands-browser-set-headers' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' +import { + publishCreatedBrowserSessionTab, + resolveBrowserTabCreateFocus +} from './browser-tab-create-publication' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { BrowserError } from '../browser/browser-error' +import { randomUUID } from 'node:crypto' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol' +import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection' +import { + createRuntimeBrowserClientPage, + navigateRuntimeBrowserClientPage +} from './runtime-browser-client-page-creation' +import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' +import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait' + +export class RuntimeBrowserCommandsWithBrowserTabCreate extends RuntimeBrowserCommandsWithBrowserSetHeaders { + async browserOpenUrlOnClient(params: { + url: string + worktree: string + }): Promise<{ browserPageId: string }> { + const protocol = new URL(params.url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') + } + const lease = this.host + .getBrowserHostLeaseRegistry() + .select(undefined, [ + BROWSER_HOST_WEBVIEW_CAPABILITY, + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY + ]) + return this.browserTabCreate( + { + url: params.url, + worktree: params.worktree, + activate: true, + navigation: 'caller', + placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } + }, + { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } + ) + } + + async browserTabCreate( + params: { + url?: string + worktree?: string + page?: string + profileId?: string + waitForRegistration?: boolean + activate?: boolean + navigation?: RuntimeNavigationTarget + targetGroupId?: string + placement?: BrowserPageCreationPlacement + }, + caller?: { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } + ): Promise<{ browserPageId: string }> { + const url = params.url ?? 'about:blank' + const focus = resolveBrowserTabCreateFocus({ + activate: params.activate, + navigation: params.navigation, + clientKind: caller?.clientKind + }) + const worktree = params.worktree + ? params.placement?.kind === 'client' + ? await this.host.resolveBrowserWorkspace(params.worktree) + : await this.host.resolveWorktreeSelector(params.worktree) + : undefined + const worktreeId = worktree?.id + const sessionPartition = browserSessionRegistry.resolveKnownPartition(params.profileId) + if (!sessionPartition) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + if (params.placement?.kind === 'client') { + if (!caller?.pairedDeviceId) { + throw new BrowserError( + 'forbidden', + 'Client-hosted browser pages require an authenticated paired runtime.' + ) + } + if (!worktree) { + throw new BrowserError( + 'invalid_argument', + 'Client-hosted browser pages require an explicit workspace.' + ) + } + const browserPageId = params.page ?? randomUUID() + const executionHost = await this.host.resolveBrowserNetworkExecutionHost(worktree) + const authority = this.host.getBrowserHostLeaseRegistry() + const browserProfileId = params.profileId ?? browserSessionRegistry.getDefaultProfile().id + const created = await createRuntimeBrowserClientPage(authority, { + browserPageId, + browserHostClientId: params.placement.browserHostClientId, + pairedDeviceId: caller.pairedDeviceId, + browserProfileId, + executionHost, + workspaceId: worktree.id + }) + const pages = this.host.getRuntimeBrowserPageRegistry() + pages.publishClientPage({ + browserPageId, + workspaceId: worktree.id, + browserProfileId, + executionHostKey: browserNetworkExecutionHostKey(executionHost), + placement: created.placement, + pairedDeviceId: caller.pairedDeviceId, + url: 'about:blank', + loading: url !== 'about:blank', + active: focus.startsActive + }) + publishCreatedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId, + worktreeId: worktree.id, + focus, + clientNavigationId: caller.pairedDeviceId, + targetGroupId: params.targetGroupId + }) + if (url !== 'about:blank') { + try { + await navigateRuntimeBrowserClientPage(authority, { + browserPageId, + placement: created.placement, + url + }) + pages.updatePage(browserPageId, created.placement, { url, loading: false }) + } catch { + pages.updatePage(browserPageId, created.placement, { loading: false }) + } + this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) + } + return { browserPageId } + } + // Why: headless serve has no renderer , so back the page with a main-process offscreen WebContents instead. + if (!this.host.getAvailableAuthoritativeWindow()) { + const offscreen = this.host.getOffscreenBrowserBackend() + if (!offscreen) { + throw new BrowserError('browser_error', 'This host does not support browser panes.') + } + // Why: the offscreen backend registers synchronously, so there is no webview-mount wait. + const created = await offscreen.createTab({ + url, + worktreeId, + profileId: params.profileId, + ...(params.page ? { browserPageId: params.page } : {}) + }) + publishCreatedBrowserSessionTab(this.host, { + placementKind: 'offscreen', + browserPageId: created.browserPageId, + worktreeId, + focus, + ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), + targetGroupId: params.targetGroupId + }) + return { browserPageId: created.browserPageId } + } + const { browserPageId } = await this.createBrowserTabInRenderer( + url, + worktreeId, + params.profileId, + params.profileId ? sessionPartition : undefined, + focus.focusesHost, + params.page + ) + + // Why: the webview must mount and register before the tab is operable, so wait here (returning the ID anyway on timeout). + if (params.waitForRegistration !== false) { + try { + await waitForTabRegistration(browserPageId) + } catch { + // Tab exists in the renderer even if the webview hasn't mounted; subsequent commands surface a clear error if it never loads. + } + } + + const bridge = this.requireAgentBrowserBridge() + publishCreatedBrowserSessionTab(this.host, { + placementKind: 'renderer', + browserPageId, + worktreeId, + focus, + ...(caller?.pairedDeviceId ? { clientNavigationId: caller.pairedDeviceId } : {}), + targetGroupId: params.targetGroupId + }) + + // Why: the webview loads about:blank first; route navigation through the bridge so its registered owner remains authoritative. + if (url && url !== 'about:blank') { + const navigate = async (): Promise => { + const result = await bridge.goto(url, worktreeId, browserPageId) + this.notifyRendererNavigation(browserPageId, result.url, result.title) + if (!this.host.getAvailableAuthoritativeWindow() && worktreeId) { + this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktreeId) + } + } + if (params.waitForRegistration === true) { + void navigate().catch(() => {}) + return { browserPageId } + } + try { + await navigate() + } catch { + // Tab exists but navigation failed — caller can retry with explicit goto + } + } + + return { browserPageId } + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-list.ts b/src/main/runtime/runtime-browser-commands-browser-tab-list.ts new file mode 100644 index 00000000000..7dfa007abf6 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-tab-list.ts @@ -0,0 +1,209 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserScreencast } from './runtime-browser-commands-browser-screencast' +import type { + BrowserCheckResult, + BrowserDragResult, + BrowserFocusResult, + BrowserHoverResult, + BrowserTabCurrentResult, + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult, + BrowserUploadResult, + BrowserWaitResult +} from '../../shared/runtime-types' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import type { BrowserCertificateProceedResult } from '../../shared/browser-workspace-types' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' +import { BrowserError } from '../browser/browser-error' +import { publishSwitchedBrowserSessionTab } from './browser-tab-create-publication' + +export class RuntimeBrowserCommandsWithBrowserTabList extends RuntimeBrowserCommandsWithBrowserScreencast { + async browserTabList(params: { worktree?: string }): Promise { + const workspaceId = params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + const clientPages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) + let bridgeWorktreeId = workspaceId + if (this.host.getAgentBrowserBridge()) { + try { + bridgeWorktreeId = await this.resolveBrowserWorktreeId(params.worktree) + } catch (error) { + if (clientPages.length === 0) { + throw error + } + } + } + return { tabs: this.listLogicalBrowserTabs(bridgeWorktreeId, clientPages) } + } + + async browserProceedCertificate( + params: { challengeId: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + if (!target.browserPageId) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) + if (clientPage) { + await this.assertClientPageWorkspace(clientPage, params.worktree) + const tab = this.listLogicalBrowserTabs( + clientPage.workspaceId, + this.host.getRuntimeBrowserPageRegistry().listPages(clientPage.workspaceId) + ).find((candidate) => candidate.browserPageId === clientPage.browserPageId) + if (!tab) { + throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) + } + return { tab } + } + const target = await this.resolveBrowserCommandTarget(params) + return { tab: this.describeBrowserTab(params.page, target.worktreeId) } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const tab = (await this.browserTabList(params)).tabs.find((candidate) => candidate.active) + if (!tab) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab } + } + + async browserTabSwitch( + params: { + index?: number + focus?: boolean + } & BrowserCommandTargetParams + ): Promise { + const listed = await this.browserTabList({ worktree: params.worktree }) + const switchedIndex = params.page + ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) + : (params.index ?? -1) + const selected = listed.tabs[switchedIndex] + if (!selected) { + const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` + throw new BrowserError( + 'browser_tab_not_found', + `${label} out of range (0-${listed.tabs.length - 1})` + ) + } + const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) + if (clientPage) { + this.host + .getRuntimeBrowserPageRegistry() + .activatePage(clientPage.browserPageId, clientPage.placement) + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId: clientPage.browserPageId, + worktreeId: clientPage.workspaceId, + focus: params.focus + }) + return { switched: switchedIndex, browserPageId: clientPage.browserPageId } + } + const bridge = this.requireAgentBrowserBridge() + const worktreeId = + typeof selected.worktreeId === 'string' + ? selected.worktreeId + : params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) + this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() + if (worktreeId) { + this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) + } + // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). + const focusWorktreeId = + worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'bridge', + browserPageId: result.browserPageId, + worktreeId: focusWorktreeId, + focus: params.focus + }) + if (params.focus) { + this.notifyRendererBrowserPaneFocus(focusWorktreeId, result.browserPageId) + } + return { ...result, switched: switchedIndex } + } + + async browserHover( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().hover( + params.element, + target.worktreeId, + target.browserPageId + ) + } + + async browserDrag( + params: { + from: string + to: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().drag( + params.from, + params.to, + target.worktreeId, + target.browserPageId + ) + } + + async browserUpload( + params: { element: string; files: string[] } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().upload( + params.element, + params.files, + target.worktreeId, + target.browserPageId + ) + } + + async browserWait( + params: { + selector?: string + timeout?: number + text?: string + url?: string + load?: string + fn?: string + state?: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const { worktree: _, page: __, ...options } = params + return this.requireAgentBrowserBridge().wait(options, target.worktreeId, target.browserPageId) + } + + async browserCheck( + params: { element: string; checked: boolean } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().check( + params.element, + params.checked, + target.worktreeId, + target.browserPageId + ) + } + + async browserFocus( + params: { element: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + return this.requireAgentBrowserBridge().focus( + params.element, + target.worktreeId, + target.browserPageId + ) + } +} diff --git a/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts b/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts new file mode 100644 index 00000000000..3f701aea347 --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-browser-tab-set-profile.ts @@ -0,0 +1,188 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserTabCreate } from './runtime-browser-commands-browser-tab-create' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import type { + BrowserDetectProfilesResult, + BrowserProfileCreateResult, + BrowserProfileDeleteResult, + BrowserProfileListResult, + BrowserTabProfileCloneResult, + BrowserTabProfileShowResult, + BrowserTabSetProfileResult +} from '../../shared/runtime-types' +import { BrowserError } from '../browser/browser-error' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { browserManager } from '../browser/browser-manager' +import { randomUUID } from 'node:crypto' +import { ipcMain } from 'electron' +import { waitForTabRegistration } from '../ipc/browser-tab-registration-wait' +import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' +import { detectInstalledBrowsers } from '../browser/browser-cookie-import' + +export class RuntimeBrowserCommandsWithBrowserTabSetProfile extends RuntimeBrowserCommandsWithBrowserTabCreate { + async browserTabSetProfile( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const browserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!browserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + // Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered. + const profile = + browserSessionRegistry.getProfile(params.profileId) ?? + (params.profileId === 'default' ? browserSessionRegistry.getDefaultProfile() : null) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + + // Why: short-circuit no-op switches so the renderer doesn't needlessly tear down and remount the webview. + const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default' + if (currentProfileId === profile.id) { + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabSetProfileReply', handler) + reject(new Error('Tab profile update timed out')) + }, 10_000) + + const handler = ( + _event: Electron.IpcMainEvent, + reply: { requestId: string; error?: string } + ): void => { + if (reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabSetProfileReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve() + } + } + ipcMain.on('browser:tabSetProfileReply', handler) + win.webContents.send('browser:requestTabSetProfile', { + requestId, + browserPageId, + profileId: profile.id, + sessionPartition: profile.partition + }) + }) + + // Why: profile change remounts the webview; wait for re-register so follow-up commands see the new profile and an attached guest. + try { + await waitForTabRegistration(browserPageId) + } catch { + // Best-effort: re-register won't fire while the worktree is hidden; downstream commands retry once the pane re-mounts. + } + + return { + browserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserTabProfileShow(params: { + page: string + worktree?: string + }): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const tab = this.describeBrowserTab(params.page, target.worktreeId) + return { + browserPageId: tab.browserPageId, + worktreeId: tab.worktreeId ?? null, + profileId: tab.profileId ?? null, + profileLabel: tab.profileLabel ?? null + } + } + + async browserTabProfileClone( + params: { + profileId: string + } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + const sourceBrowserPageId = + target.browserPageId ?? this.requireAgentBrowserBridge().getActivePageId(target.worktreeId) + if (!sourceBrowserPageId) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + const sourceTab = this.describeBrowserTab(sourceBrowserPageId, target.worktreeId) + const profile = browserSessionRegistry.getProfile(params.profileId) + if (!profile) { + throw new BrowserError( + 'invalid_argument', + `Browser profile ${params.profileId} was not found` + ) + } + const created = await this.createBrowserTabInRenderer( + sourceTab.url, + sourceTab.worktreeId ?? target.worktreeId, + profile.id, + profile.partition + ) + // Why: wait for the cloned tab's webview to register so the returned browserPageId is operable by the next CLI call. + try { + await waitForTabRegistration(created.browserPageId) + } catch { + // Best-effort: registration may not fire if the worktree is hidden. + } + return { + browserPageId: created.browserPageId, + sourceBrowserPageId, + profileId: profile.id, + profileLabel: profile.label + } + } + + async browserProfileList(): Promise { + return { profiles: browserSessionRegistry.listProfiles() } + } + + async browserProfileCreate(params: { + label: string + scope: 'isolated' | 'imported' + userAgentMode?: BrowserSessionUserAgentMode + }): Promise { + return { + profile: await browserSessionRegistry.createProfile(params.scope, params.label, { + userAgentMode: params.userAgentMode + }) + } + } + + async browserProfileDelete(params: { profileId: string }): Promise { + return { + deleted: await browserSessionRegistry.deleteProfile(params.profileId), + profileId: params.profileId + } + } + + async browserProfileDetectBrowsers(): Promise { + return { + // Why: expose only display metadata; filesystem paths and keychain identifiers stay on the runtime server. + browsers: detectInstalledBrowsers().map((browser) => ({ + family: browser.family, + label: browser.label, + profiles: browser.profiles, + selectedProfile: browser.selectedProfile + })) + } + } +} diff --git a/src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts b/src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts new file mode 100644 index 00000000000..8f86fbfa44d --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-list-logical-browser-tabs.ts @@ -0,0 +1,148 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeBrowserCommandsWithBrowserTabClose } from './runtime-browser-commands-browser-tab-close' +import type { RuntimeBrowserClientPage } from './runtime-browser-page-registry' +import type { BrowserTabListResult } from '../../shared/runtime-types' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { BrowserError } from '../browser/browser-error' +import type { BrowserCommandTargetParams } from './runtime-browser-commands-browser-command-target-params' +import { browserManager } from '../browser/browser-manager' +import { randomUUID } from 'node:crypto' +import { ipcMain } from 'electron' + +export class RuntimeBrowserCommandsWithListLogicalBrowserTabs extends RuntimeBrowserCommandsWithBrowserTabClose { + protected listLogicalBrowserTabs( + worktreeId: string | undefined, + clientPages: readonly RuntimeBrowserClientPage[] + ): BrowserTabListResult['tabs'] { + const clientPageActive = clientPages.some((page) => page.active) + const bridge = this.host.getAgentBrowserBridge() + const serverTabs = + bridge && typeof bridge.tabList === 'function' + ? bridge.tabList(worktreeId).tabs.map((tab) => ({ + ...this.enrichBrowserTabInfo(tab), + active: clientPageActive ? false : tab.active + })) + : [] + const clientTabs = clientPages.map((page, offset) => { + const profile = + browserSessionRegistry.getProfile(page.browserProfileId) ?? + browserSessionRegistry.getDefaultProfile() + return { + browserPageId: page.browserPageId, + index: serverTabs.length + offset, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } + }) + const tabs = [...serverTabs, ...clientTabs] + if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { + tabs[0] = { ...tabs[0]!, active: true } + } + return tabs.map((tab, index) => ({ ...tab, index })) + } + + protected async assertClientPageWorkspace( + page: RuntimeBrowserClientPage, + selector: string | undefined + ): Promise { + if (!selector) { + return + } + const workspace = await this.host.resolveBrowserWorkspace(selector) + if (workspace.id !== page.workspaceId) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${page.browserPageId} was not found in this worktree` + ) + } + } + + protected async resolveClientHostedBrowserPage( + params: BrowserCommandTargetParams + ): Promise { + const pages = this.host.getRuntimeBrowserPageRegistry() + if (params.page) { + const page = pages.getPage(params.page) + if (page) { + await this.assertClientPageWorkspace(page, params.worktree) + } + return page + } + const workspaceId = params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + return pages.listPages(workspaceId).find((page) => page.active) + } + + protected describeBrowserTab( + browserPageId: string, + explicitWorktreeId?: string + ): BrowserTabListResult['tabs'][number] { + const worktreeId = explicitWorktreeId ?? browserManager.getWorktreeIdForTab(browserPageId) + const tab = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.find((entry) => entry.browserPageId === browserPageId) + if (!tab) { + const scope = worktreeId ? ' in this worktree' : '' + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${browserPageId} was not found${scope}` + ) + } + return this.enrichBrowserTabInfo(tab) + } + + protected async createBrowserTabInRenderer( + url: string, + worktreeId: string | undefined, + profileId: string | undefined, + sessionPartition: string | undefined, + activate?: boolean, + requestedPageId?: string + ): Promise<{ browserPageId: string }> { + const win = this.host.getAuthoritativeWindow() + const requestId = randomUUID() + + const browserPageId = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ipcMain.removeListener('browser:tabCreateReply', handler) + reject(new Error('Tab creation timed out')) + }, 10_000) + + const handler = ( + event: Electron.IpcMainEvent, + reply: { requestId: string; browserPageId?: string; error?: string } + ): void => { + if (event.sender !== win.webContents || reply.requestId !== requestId) { + return + } + clearTimeout(timer) + ipcMain.removeListener('browser:tabCreateReply', handler) + if (reply.error) { + reject(new Error(reply.error)) + } else { + resolve(reply.browserPageId!) + } + } + ipcMain.on('browser:tabCreateReply', handler) + win.webContents.send('browser:requestTabCreate', { + requestId, + url, + worktreeId, + ...(requestedPageId ? { browserPageId: requestedPageId } : {}), + // Why: keep these undefined (not null) when no profile is chosen so the renderer still applies default-profile inheritance. + sessionProfileId: profileId, + sessionPartition, + activate + }) + }) + + return { browserPageId } + } +} diff --git a/src/main/runtime/runtime-browser-commands-state.ts b/src/main/runtime/runtime-browser-commands-state.ts new file mode 100644 index 00000000000..dde716c6f5e --- /dev/null +++ b/src/main/runtime/runtime-browser-commands-state.ts @@ -0,0 +1,6 @@ +// @ts-nocheck -- mechanically split class members. +import type { ActiveBrowserScreencastPage } from './runtime-browser-commands-browser-command-target-params' + +export class RuntimeBrowserCommandsState { + protected readonly activeScreencastsByPageId = new Map() +} diff --git a/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts new file mode 100644 index 00000000000..5805039615d --- /dev/null +++ b/src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts @@ -0,0 +1,25 @@ +// @ts-nocheck -- mechanically split class members. +import type { ChildProcess } from 'node:child_process' +import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' +import { + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS +} from './runtime-file-commands-mobile-file-list-limit' +import { RuntimeMobileFilePathSearchCache } from './runtime-mobile-file-path-search' + +export class RuntimeFileCommandsWithActiveRuntimeTextSearches { + protected activeRuntimeTextSearches = new Map() + + protected terminalFileGrants = new Map() + + protected mobileFilePathSearchCache = new RuntimeMobileFilePathSearchCache( + MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES, + MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS + ) + + // The mux drops ErrnoException.code, so match not-found by message shape (vs transport/permission/provider errors). + protected static isRemoteNotFoundErrorMessage(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /\bENOENT\b|no such file|not found|does not exist/i.test(message) + } +} diff --git a/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts new file mode 100644 index 00000000000..7b8948fb784 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical.ts @@ -0,0 +1,179 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithWriteTerminalArtifactFile } from './runtime-file-commands-write-terminal-artifact-file' +import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' +import { + runtimeFileWatcherLeasesByOwnerAndRoot, + runtimeWatcherReleaseKey +} from './runtime-file-commands-mobile-file-list-limit' +import type { IFilesystemProvider } from '../providers/types' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { readdir, stat } from 'node:fs/promises' +import type { DirEntry, FsChangeEvent } from '../../shared/filesystem-entry-types' +import { sortDirEntries } from '../../shared/file-name-sort' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { + isRuntimeDirectoryEntry, + watchWindowsRuntimeFileExplorer +} from './runtime-file-commands-host' +import { beginWatcherInstall } from '../ipc/watcher-removal-gate' +import { + armSshFileExplorerWatchRearm, + stopSshFileExplorerWatchRearms +} from './runtime-file-commands-ssh-watch-rearm' +import { + closeFileExplorerWatcherInWatcherProcess, + watchFileExplorerInWatcherProcess +} from './file-watcher-host' +import { registerRuntimeFileWatcherRelease } from './runtime-file-commands-watcher-lifecycle' + +export class RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical extends RuntimeFileCommandsWithWriteTerminalArtifactFile { + protected async assertRemoteTerminalFileGrantPathStillCanonical( + grant: TerminalFileGrant + ): Promise { + if (!grant.connectionId) { + throw new Error('terminal_file_grant_mismatch') + } + const provider = getSshFilesystemProvider(grant.connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const canonicalPath = + grant.provenance === 'native-chat' + ? await provider.realpath(grant.absolutePath) + : await this.resolveAllowedRemoteTerminalArtifactPath( + grant.absolutePath, + grant.connectionId + ) + // Why: relay I/O follows symlinks, so re-canonicalize after the remote process can mutate the path. + if (canonicalPath !== grant.absolutePath) { + throw new Error('terminal_file_grant_stale') + } + return provider + } + + async readFileExplorerDir(worktreeSelector: string, relativePath: string): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: re-sort locally — the remote relay may be an older build with + // lexicographic ordering. + return sortDirEntries(await provider.readDir(target.path)) + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const entries = await readdir(dirPath, { withFileTypes: true }) + const mapped = entries.map((entry) => ({ + name: entry.name, + isDirectory: isRuntimeDirectoryEntry(entry), + isSymlink: entry.isSymbolicLink() + })) + return sortDirEntries(mapped) + } + + async watchFileExplorer( + worktreeSelector: string, + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void = () => undefined, + signal?: AbortSignal + ): Promise<() => Promise> { + const target = await this.resolveFileExplorerPath(worktreeSelector, '') + const open = async (): Promise<{ + unsubscribe: () => Promise + rootPaths: string[] + }> => { + const finishInstall = beginWatcherInstall(target.path, target.connectionId) + try { + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + // Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out. + const close = await provider.watch(target.path, callback, { signal, onTerminalError }) + const rearm = armSshFileExplorerWatchRearm({ + runtimeId: this.host.getRuntimeId(), + connectionId: target.connectionId, + rootPath: target.path, + callback, + onTerminalError, + signal, + initialUnwatch: close + }) + return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } + } + + const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const rootStats = await stat(rootPath) + if (!rootStats.isDirectory()) { + throw new Error('not_a_directory') + } + if (process.platform === 'win32') { + const close = watchWindowsRuntimeFileExplorer(rootPath, callback, onTerminalError) + return { unsubscribe: close, rootPaths: [target.path, rootPath] } + } + // Why: the forked watcher keeps the blocking crawl and native faults out of the main/`serve` process (issues #5308, #8212). + const dispose = await watchFileExplorerInWatcherProcess( + rootPath, + callback, + onTerminalError, + signal + ) + return { unsubscribe: dispose, rootPaths: [target.path, rootPath] } + } finally { + finishInstall() + } + } + const initial = await open() + return registerRuntimeFileWatcherRelease( + this.host.getRuntimeId(), + target.connectionId, + initial.rootPaths, + initial.unsubscribe, + async () => (await open()).unsubscribe, + onTerminalError + ) + } + + async closeFileExplorerWatchersForPath(rootPath: string, connectionId?: string): Promise { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + await Promise.all(Array.from(leases, (lease) => lease.suspend())) + } + if (!connectionId) { + // Why: setup can fail before registerRuntimeFileWatcherRelease publishes its callback while the child owner still lives. + const resolvedRootPath = await resolveAuthorizedPath(rootPath, this.host.requireStore()) + await closeFileExplorerWatcherInWatcherProcess(resolvedRootPath) + } + } + + async restoreFileExplorerWatchersAfterFailedRemoval( + rootPath: string, + connectionId?: string + ): Promise { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + await Promise.all(Array.from(leases, (lease) => lease.resume())) + } + } + + forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { + const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted + // worktree and re-watch it on the next reconnect. + stopSshFileExplorerWatchRearms(key) + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + if (leases) { + for (const lease of Array.from(leases)) { + lease.forget() + } + } + } +} diff --git a/src/main/runtime/runtime-file-commands-constructor.ts b/src/main/runtime/runtime-file-commands-constructor.ts new file mode 100644 index 00000000000..a2f313db138 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-constructor.ts @@ -0,0 +1,213 @@ +// @ts-nocheck -- mechanically split class members. +import { + RuntimeFileCommandsWithActiveRuntimeTextSearches, + RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands +} from './runtime-file-commands-active-runtime-text-searches' +import type { RuntimeFileCommandHost } from './runtime-file-commands-host' +import { + isMobileBinaryPath, + isMobileMarkdownPath, + isSafeMobileRelativePath +} from './runtime-file-commands-host' +import { basenameFromRelativePath } from './runtime-file-path-names' +import type { RuntimeFileListResult, RuntimeFileOpenResult } from '../../shared/runtime-types' +import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import { + MOBILE_FILE_LIST_LIMIT, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT, + isMobilePreviewableImagePath +} from './runtime-file-commands-mobile-file-list-limit' +import { rankRuntimeMobileFilePaths } from './runtime-mobile-file-path-search' +import { isQuickOpenQueryTooLarge } from '../../shared/quick-open-path-search' +import { searchQuickOpenFilePaths as searchHostQuickOpenFilePaths } from '../ipc/filesystem-search-file-paths' +import { stat } from 'node:fs/promises' +import { joinWorktreeRelativePath } from './runtime-relative-paths' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { isENOENT } from '../ipc/filesystem-path-containment' + +export class RuntimeFileCommandsWithConstructor extends RuntimeFileCommandsWithActiveRuntimeTextSearches { + constructor(private readonly host: RuntimeFileCommandHost) { + super() + } + + async listMobileFiles( + worktreeSelector: string, + options: { signal?: AbortSignal } = {} + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const files = connectionId + ? await this.listRemoteMobileFiles(worktree.path, connectionId, undefined, options.signal) + : await listQuickOpenFiles(worktree.path, store, undefined, options.signal) + const entries = files + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + .slice(0, MOBILE_FILE_LIST_LIMIT) + .map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })) + + return { + worktree: worktree.id, + rootPath: worktree.path, + files: entries, + totalCount: files.length, + truncated: files.length > MOBILE_FILE_LIST_LIMIT + } + } + + async searchMobileFilePaths( + worktreeSelector: string, + query: string, + limit: number + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const cacheKey = `${connectionId ?? 'local'}:${worktree.id}:${worktree.path}` + const inventory = await this.mobileFilePathSearchCache.get(cacheKey, async () => { + const listed = connectionId + ? await this.listRemoteMobileFiles( + worktree.path, + connectionId, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + : await listQuickOpenFiles( + worktree.path, + store, + undefined, + undefined, + MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + 1 + ) + const safePaths = listed + .filter((relativePath) => isSafeMobileRelativePath(relativePath)) + .sort((a, b) => a.localeCompare(b)) + return { + paths: safePaths.slice(0, MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT), + totalCount: safePaths.length, + truncated: safePaths.length > MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT + } + }) + const matches = rankRuntimeMobileFilePaths(inventory.paths, query, limit) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: matches.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: matches.totalCount, + truncated: inventory.truncated || matches.totalCount > limit + } + } + + async searchQuickOpenFilePaths( + worktreeSelector: string, + query: string, + limit: number, + excludePaths?: string[], + signal?: AbortSignal + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + const result = + !query.trim() || isQuickOpenQueryTooLarge(query) + ? { paths: [], totalCount: 0, truncated: false } + : connectionId + ? await this.searchRemoteQuickOpenFilePaths( + worktree.path, + connectionId, + query, + limit, + excludePaths, + signal + ) + : await searchHostQuickOpenFilePaths(worktree.path, this.host.requireStore(), { + query, + limit, + excludePaths, + signal + }) + return { + worktree: worktree.id, + rootPath: worktree.path, + files: result.paths.map((relativePath) => ({ + relativePath, + basename: basenameFromRelativePath(relativePath), + kind: isMobileBinaryPath(relativePath) ? ('binary' as const) : ('text' as const) + })), + totalCount: result.totalCount, + truncated: result.truncated + } + } + + async openMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + // Previewable images open like text (mobile renders via files.readPreview); other binaries stay unavailable on mobile. + const kind = isMobilePreviewableImagePath(relativePath) + ? 'image' + : isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + if (kind === 'binary') { + return { worktree: worktree.id, relativePath, kind, opened: false } + } + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: CLI/agents treat opened:true as success; stat first so missing paths fail the RPC instead of opening a ghost tab. + await this.assertMobileOpenTargetExists(filePath, connectionId) + // Why: the internal runtimeId isn't a valid env selector; pass undefined so openFile falls back to activeRuntimeEnvironmentId. + this.host.openFile(worktree.id, filePath, relativePath, undefined) + return { worktree: worktree.id, relativePath, kind, opened: true } + } + + protected async assertMobileOpenTargetExists( + filePath: string, + connectionId?: string + ): Promise { + try { + await (connectionId + ? this.statRemoteTerminalPath(filePath, connectionId) + : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) + } catch (error) { + if ( + isENOENT(error) || + (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + } + throw error + } + } + + async openMobileDiff( + worktreeSelector: string, + relativePath: string, + staged: boolean + ): Promise { + const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + const kind = isMobileBinaryPath(relativePath) + ? 'binary' + : isMobileMarkdownPath(relativePath) + ? 'markdown' + : 'text' + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: see openMobileFile; avoid stamping internal runtimeId as runtimeEnvironmentId. + this.host.openDiff(worktree.id, filePath, relativePath, staged, undefined) + return { worktree: worktree.id, relativePath, kind, opened: true } + } +} diff --git a/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts new file mode 100644 index 00000000000..8702d0370ed --- /dev/null +++ b/src/main/runtime/runtime-file-commands-create-file-explorer-dir-no-clobber.ts @@ -0,0 +1,190 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithWriteFileExplorerFile } from './runtime-file-commands-write-file-explorer-file' +import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { constants, copyFile, mkdir, rm } from 'node:fs/promises' +import { dirname } from 'node:path' +import { renameLocalPathSerializedByDestination } from '../destination-serialized-local-rename' + +export class RuntimeFileCommandsWithCreateFileExplorerDirNoClobber extends RuntimeFileCommandsWithWriteFileExplorerFile { + async createFileExplorerDirNoClobber( + worktreeSelector: string, + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.createDirNoClobber(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } + + async commitFileExplorerUpload( + worktreeSelector: string, + tempRelativePath: string, + finalRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const [tempTarget, finalTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ + tempRelativePath, + finalRelativePath + ]) + assertRuntimeFileMutationExpectation( + tempTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = tempTarget.connectionId + ? getSshFilesystemProvider(tempTarget.connectionId) + : null + if (tempTarget.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.copy(tempTarget.path, finalTarget.path) + await provider.deletePath(tempTarget.path, false).catch(() => {}) + return { ok: true } + } + + const store = this.host.requireStore() + const tempPath = await resolveAuthorizedPath(tempTarget.path, store) + const finalPath = await resolveAuthorizedPath(finalTarget.path, store) + await mkdir(dirname(finalPath), { recursive: true }) + await copyFile(tempPath, finalPath, constants.COPYFILE_EXCL) + await rm(tempPath, { force: true }) + return { ok: true } + } + + async renameFileExplorerPath( + worktreeSelector: string, + oldRelativePath: string, + newRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const [oldTarget, newTarget] = await this.resolveFileExplorerPaths(worktreeSelector, [ + oldRelativePath, + newRelativePath + ]) + assertRuntimeFileMutationExpectation( + oldTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = oldTarget.connectionId + ? getSshFilesystemProvider(oldTarget.connectionId) + : null + if (oldTarget.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.renameNoClobber(oldTarget.path, newTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const oldPath = await resolveAuthorizedPath(oldTarget.path, store, { preserveSymlink: true }) + const newPath = await resolveAuthorizedPath(newTarget.path, store, { preserveSymlink: true }) + await renameLocalPathSerializedByDestination(oldPath, newPath) + return { ok: true } + } + + async copyFileExplorerPath( + worktreeSelector: string, + sourceRelativePath: string, + destinationRelativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const [sourceTarget, destinationTarget] = await this.resolveFileExplorerPaths( + worktreeSelector, + [sourceRelativePath, destinationRelativePath] + ) + assertRuntimeFileMutationExpectation( + sourceTarget.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = sourceTarget.connectionId + ? getSshFilesystemProvider(sourceTarget.connectionId) + : null + if (sourceTarget.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.copy(sourceTarget.path, destinationTarget.path) + return { ok: true } + } + + const store = this.host.requireStore() + const sourcePath = await resolveAuthorizedPath(sourceTarget.path, store, { + preserveSymlink: true + }) + const destinationPath = await resolveAuthorizedPath(destinationTarget.path, store, { + preserveSymlink: true + }) + await mkdir(dirname(destinationPath), { recursive: true }) + // Why: COPYFILE_EXCL preserves the no-clobber invariant of the local shell copy IPC (caller already deconflicts names). + await copyFile(sourcePath, destinationPath, constants.COPYFILE_EXCL) + return { ok: true } + } + + async deleteFileExplorerPath( + worktreeSelector: string, + relativePath: string, + recursive?: boolean, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.deletePath(target.path, recursive) + return { ok: true } + } + + const targetPath = await resolveAuthorizedPath(target.path, this.host.requireStore(), { + preserveSymlink: true + }) + // Why: a non-local runtime has no client Trash; this delete is permanent, so the renderer confirms before calling. + await rm(targetPath, { recursive: recursive === true, force: true }) + return { ok: true } + } +} diff --git a/src/main/runtime/runtime-file-commands-host.ts b/src/main/runtime/runtime-file-commands-host.ts new file mode 100644 index 00000000000..93a16f7d20f --- /dev/null +++ b/src/main/runtime/runtime-file-commands-host.ts @@ -0,0 +1,193 @@ +// @ts-nocheck -- mechanically split declarations. +import type { Store } from '../persistence' +import type { + ResolvedRuntimeFileTarget, + ResolvedRuntimeFileWorktree +} from './runtime-file-commands-watcher-lifecycle' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { RuntimeNativeChatFileContext } from '../../shared/runtime-types' +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' +import { PhysicalExitTracker } from '../../shared/physical-exit-tracker' +import { + MOBILE_BINARY_EXTENSIONS, + WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS, + WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS +} from './runtime-file-commands-mobile-file-list-limit' +import { watch as watchFs } from 'node:fs' +import { WatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { basenameFromRelativePath } from './runtime-file-path-names' + +export type RuntimeFileCommandHost = { + getRuntimeId(): string + requireStore(): Store + resolveWorktreeSelector(selector: string): Promise + resolveRuntimeFileTarget(selector: string): Promise + resolveKnownWorkspaceFileTarget?( + absolutePath: string, + executionHostId: ExecutionHostId + ): Promise<(ResolvedRuntimeFileTarget & { relativePath: string }) | null> + resolveTerminalCwd?(terminalHandle: string): string | null | Promise + resolveTerminalContext?( + terminalHandle: string + ): { worktreeId: string; connectionId: string | null } | null + resolveTerminalFileUriHostname?(terminalHandle: string): string | null | Promise + hasRecentTerminalOutputPath?( + terminalHandle: string, + pathText: string, + absolutePath: string + ): boolean | Promise + hasRecentNativeChatOutputPath?( + worktreeId: string, + context: RuntimeNativeChatFileContext, + pathText: string, + absolutePath: string + ): boolean | Promise + resolveRuntimeGitTarget( + selector: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; connectionId?: string }> + openFile( + worktreeId: string, + filePath: string, + relativePath: string, + runtimeEnvironmentId?: string | null + ): void + openDiff( + worktreeId: string, + filePath: string, + relativePath: string, + staged: boolean, + runtimeEnvironmentId?: string | null + ): void +} + +export function watchWindowsRuntimeFileExplorer( + rootPath: string, + callback: (events: FsChangeEvent[]) => void, + onTerminalError: (error: Error) => void +): () => Promise { + let disposed = false + let timer: ReturnType | null = null + let closeStarted = false + const physicalClose = new PhysicalExitTracker() + + const emitOverflow = (): void => { + timer = null + if (disposed) { + return + } + callback([{ kind: 'overflow', absolutePath: rootPath }]) + } + + const scheduleOverflow = (): void => { + if (disposed) { + return + } + if (timer) { + clearTimeout(timer) + } + timer = setTimeout(emitOverflow, WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS) + } + + // Why: Parcel's Watchman probe can crash the headless server on Windows; use a conservative overflow refresh instead. + const watcher = watchFs(rootPath, { recursive: true }, scheduleOverflow) + const onClose = (): void => { + watcher.removeListener('error', onError) + physicalClose.markExited() + } + const onError = (err: Error): void => { + console.error('[runtime-files.watch] Windows watcher error', { rootPath, err }) + if (timer) { + clearTimeout(timer) + timer = null + } + watcher.removeListener('close', onClose) + watcher.removeListener('error', onError) + // Why: Node nulls FSWatcher's native handle on error without a close event; treat the error as physical-exit proof. + physicalClose.markExited() + if (!disposed) { + try { + callback([{ kind: 'overflow', absolutePath: rootPath }]) + } finally { + onTerminalError(err) + } + } + } + watcher.once('close', onClose) + watcher.on('error', onError) + + return async () => { + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + if (!closeStarted) { + try { + watcher.close() + } catch (err) { + console.error('[runtime-files.watch] Windows watcher close error', { rootPath, err }) + throw err + } + closeStarted = true + } + try { + await physicalClose.waitForExit( + WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS, + () => new Error('Windows watcher did not close before deletion deadline') + ) + } catch (error) { + // Why: late Windows close still owns native dir handles; expose its completion so cleanup retains then clears the root. + throw new WatcherProcessFailure( + error instanceof Error ? error.message : String(error), + 'supervisor', + 'process_unavailable', + physicalClose.exitedPromise + ) + } + } +} + +export function isSafeMobileRelativePath(relativePath: string): boolean { + if (!relativePath || relativePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(relativePath)) { + return false + } + const parts = relativePath.replace(/\\/g, '/').split('/') + return parts.every((part) => part !== '' && part !== '.' && part !== '..') +} + +export function isMobileMarkdownPath(relativePath: string): boolean { + return /\.(md|mdx|markdown)$/i.test(relativePath) +} + +export function isMobileBinaryPath(relativePath: string): boolean { + const basename = basenameFromRelativePath(relativePath) + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return false + } + return MOBILE_BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) +} + +export function isRuntimeDirectoryEntry(entry: { + isDirectory(): boolean + isSymbolicLink(): boolean +}): boolean { + // Why: listings are passive UI reads; don't stat symlink targets here (explicit open/expand resolves them). + if (entry.isSymbolicLink()) { + return false + } + if (entry.isDirectory()) { + return true + } + return false +} + +export function isBinaryBuffer(buffer: Buffer): boolean { + const len = Math.min(buffer.length, 8192) + for (let i = 0; i < len; i += 1) { + if (buffer[i] === 0) { + return true + } + } + return false +} diff --git a/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts new file mode 100644 index 00000000000..66d8ca1bc9f --- /dev/null +++ b/src/main/runtime/runtime-file-commands-mobile-file-list-limit.ts @@ -0,0 +1,219 @@ +// @ts-nocheck -- mechanically split declarations. +import type { FileReadLimits, IFilesystemProvider } from '../providers/types' +import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' +import { FileReadCapExceededError } from '../ssh/ssh-filesystem-stream-reader' +import { + REMOTE_RPC_MAX_CONTENT_BYTES, + remoteRpcResultExceedsContentBudget +} from '../../shared/remote-rpc-content-budget' +import { constants } from 'node:fs/promises' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation' +import { basenameFromRelativePath } from './runtime-file-path-names' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' + +export const MOBILE_FILE_LIST_LIMIT = 5000 + +export const QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT = 32 + +export const MOBILE_FILE_PATH_SEARCH_CACHE_LIMIT = 20_000 + +export const MOBILE_FILE_PATH_SEARCH_CACHE_ENTRIES = 8 + +export const MOBILE_FILE_PATH_SEARCH_CACHE_TTL_MS = 30_000 + +export const MOBILE_FILE_READ_MAX_BYTES = 512 * 1024 + +export const LOCAL_PREVIEWABLE_BINARY_MAX_BYTES = 10 * 1024 * 1024 + +export const PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES = Buffer.byteLength( + JSON.stringify({ + content: '', + isBinary: true, + isImage: true, + mimeType: 'application/octet-stream' + }), + 'utf8' +) + +export const PREVIEW_CONTENT_FIELDS = ['content'] as const + +export function previewableBinaryByteLimit(maxContentBytes: number): number { + const base64Bytes = Math.max(0, maxContentBytes - PREVIEWABLE_BINARY_EMPTY_RESULT_BYTES) + return Math.floor(base64Bytes / 4) * 3 +} + +export async function readPreviewFileWithinCap( + provider: IFilesystemProvider, + filePath: string, + limits: FileReadLimits +): Promise { + try { + return await provider.readFile(filePath, limits) + } catch (error) { + if (error instanceof FileReadCapExceededError) { + throw new Error('file_too_large') + } + throw error + } +} + +export function assertPreviewWithinTransportBudget( + result: RuntimeFilePreviewResult, + maxContentBytes: number | undefined +): RuntimeFilePreviewResult { + if ( + maxContentBytes !== undefined && + remoteRpcResultExceedsContentBudget(result, maxContentBytes, PREVIEW_CONTENT_FIELDS) + ) { + throw new Error('file_too_large') + } + return result +} + +export const RUNTIME_PREVIEWABLE_BINARY_MAX_BYTES = previewableBinaryByteLimit( + REMOTE_RPC_MAX_CONTENT_BYTES +) + +export const WINDOWS_RUNTIME_FILE_WATCH_DEBOUNCE_MS = 150 + +export const WINDOWS_RUNTIME_FILE_WATCH_CLOSE_DEADLINE_MS = 10_000 + +export const TERMINAL_FILE_GRANT_TTL_MS = 10 * 60 * 1000 + +export const OPEN_NOFOLLOW = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0 + +export const RUNTIME_FILE_MUTATION_UPDATE_REQUIRED = + 'Remote file changes require a newer Orca client. Update the paired client and try again.' + +export function assertRuntimeFileMutationExpectation( + connectionId: string | undefined, + expectedExecutionHostId: string | undefined, + expectedSshTargetId: string | undefined, + expectedSshConnectionGeneration: number | undefined +): void { + if (!expectedExecutionHostId) { + throw new Error(RUNTIME_FILE_MUTATION_UPDATE_REQUIRED) + } + const actualExecutionHostId = connectionId ? toSshExecutionHostId(connectionId) : 'local' + if (expectedExecutionHostId !== actualExecutionHostId) { + throw new Error('Workspace host changed; refresh and try again') + } + assertSshMutationExpectation(connectionId, expectedSshTargetId, expectedSshConnectionGeneration) +} + +export const pendingRuntimeFileWatcherUnsubscribes = new Set>() + +export type RuntimeFileWatcherLease = { + suspend(): Promise + resume(): Promise + forget(): void +} + +export const runtimeFileWatcherLeasesByOwnerAndRoot = new Map< + string, + Set +>() + +export const sshFileExplorerWatchRearms = new Map void>>() + +export const MOBILE_BINARY_EXTENSIONS = new Set([ + '.avif', + '.bmp', + '.gif', + '.heic', + '.ico', + '.jpeg', + '.jpg', + '.mov', + '.mp3', + '.mp4', + '.pdf', + '.png', + '.webp', + '.zip' +]) + +export const MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.bmp', + '.ico' +]) + +export type RuntimeFileStatLike = { + size?: number + dev?: number + ino?: number + nlink?: number + mtime?: number | Date + mtimeMs?: number + isDirectory?: () => boolean +} + +export type TerminalFileGrant = { + id: string + worktreeId: string + absolutePath: string + provider: 'local' | 'ssh' + connectionId?: string + clientId?: string + expiresAt: number + statIdentity: string | null + readOnly: boolean + provenance: 'terminal-output' | 'native-chat' + expiryTimer?: ReturnType +} + +export function isMobilePreviewableImagePath(relativePath: string): boolean { + const basename = basenameFromRelativePath(relativePath) + const dotIndex = basename.lastIndexOf('.') + if (dotIndex <= 0) { + return false + } + return MOBILE_PREVIEWABLE_IMAGE_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) +} + +export const RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.bmp': 'image/bmp', + '.ico': 'image/x-icon', + '.pdf': 'application/pdf' +} + +export function trackRuntimeFileWatcherUnsubscribe( + rootPath: string, + unsubscribe: () => Promise +): Promise { + const promise = Promise.resolve() + .then(unsubscribe) + .finally(() => { + pendingRuntimeFileWatcherUnsubscribes.delete(promise) + }) + pendingRuntimeFileWatcherUnsubscribes.add(promise) + void promise.catch((err: unknown) => { + console.error('[runtime-files.watch] unsubscribe error', { rootPath, err }) + }) + return promise +} + +export function normalizeRuntimeWatcherRoot(rootPath: string): string { + return normalizeRuntimePathForComparison(rootPath) +} + +export function runtimeWatcherReleaseKey( + runtimeId: string, + connectionId: string | undefined, + rootPath: string +): string { + // Why: identical absolute paths exist on local and multiple SSH hosts; scope teardown to the host that owns it. + return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) +} diff --git a/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts new file mode 100644 index 00000000000..4bf1a152196 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-read-file-explorer-preview.ts @@ -0,0 +1,194 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical } from './runtime-file-commands-assert-remote-terminal-file-grant-path-still-canonical' +import type { + RuntimeFilePreviewResult, + RuntimeFileReadChunkResult +} from '../../shared/runtime-types' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + MOBILE_FILE_READ_MAX_BYTES, + RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES, + assertPreviewWithinTransportBudget, + previewableBinaryByteLimit, + readPreviewFileWithinCap +} from './runtime-file-commands-mobile-file-list-limit' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { open, stat } from 'node:fs/promises' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { extname } from 'node:path' +import { + NodeFileReadTooLargeError, + readNodeFileWithinLimit +} from '../../shared/node-bounded-file-reader' +import { isBinaryBuffer } from './runtime-file-commands-host' +import type { + DocPreviewFileAccessRequest, + DocPreviewFileAccessResult +} from '../../shared/doc-preview-file-access' +import { readAuthorizedDocPreviewFile } from '../../shared/doc-preview-file-access' +import { readSshFileExplorerChunk } from './ssh-file-explorer-chunk-read' + +export class RuntimeFileCommandsWithReadFileExplorerPreview extends RuntimeFileCommandsWithAssertRemoteTerminalFileGrantPathStillCanonical { + async readFileExplorerPreview( + worktreeSelector: string, + relativePath: string, + maxContentBytes?: number + ): Promise { + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fileStats = await provider.stat(target.path) + if (fileStats.size > binaryMaxBytes) { + throw new Error('file_too_large') + } + const result = await readPreviewFileWithinCap(provider, target.path, { + maxBinaryBytes: binaryMaxBytes, + maxTextBytes: MOBILE_FILE_READ_MAX_BYTES + }) + // Why: the stat gate sizes base64 binaries; text crosses the wire JSON-escaped (up to 6x), so + // hold it to the same decoded limit the local branch enforces before reading. + if ( + !result.isBinary && + Buffer.byteLength(result.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES + ) { + throw new Error('file_too_large') + } + if ( + result.isBinary && + maxContentBytes !== undefined && + Buffer.byteLength(result.content, 'utf8') > maxContentBytes + ) { + throw new Error('file_too_large') + } + return assertPreviewWithinTransportBudget(result, maxContentBytes) + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(filePath).toLowerCase()] + const maxBytes = mimeType ? binaryMaxBytes : MOBILE_FILE_READ_MAX_BYTES + let buffer: Buffer + try { + buffer = (await readNodeFileWithinLimit(filePath, maxBytes)).buffer + } catch (error) { + if (error instanceof NodeFileReadTooLargeError) { + throw new Error('file_too_large') + } + throw error + } + if (mimeType) { + return assertPreviewWithinTransportBudget( + { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + }, + maxContentBytes + ) + } + + if (isBinaryBuffer(buffer)) { + return assertPreviewWithinTransportBudget({ content: '', isBinary: true }, maxContentBytes) + } + return assertPreviewWithinTransportBudget( + { content: buffer.toString('utf-8'), isBinary: false }, + maxContentBytes + ) + } + + async readDocPreviewFile( + worktreeSelector: string, + relativePath: string, + entryRelativePath: string, + implicitRootRelativePath: string | null, + authorizedRootRelativePaths: string[], + maxContentBytes?: number + ): Promise { + const relativePaths = [ + '', + entryRelativePath, + relativePath, + ...(implicitRootRelativePath === null ? [] : [implicitRootRelativePath]), + ...authorizedRootRelativePaths + ] + const [boundary, entry, target, ...authorityRoots] = await this.resolveFileExplorerPaths( + worktreeSelector, + relativePaths + ) + const implicitRoot = implicitRootRelativePath === null ? null : authorityRoots[0] + const authorizedRoots = authorityRoots.slice(implicitRoot === null ? 0 : 1) + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + const request: DocPreviewFileAccessRequest = { + boundaryPath: boundary.path, + entryPath: entry.path, + implicitRootPath: implicitRoot?.path ?? null, + authorizedRootPaths: authorizedRoots.map((root) => root.path), + targetPath: target.path, + maxTextBytes: MOBILE_FILE_READ_MAX_BYTES, + maxBinaryBytes: binaryMaxBytes + } + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId && !provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + if (target.connectionId && !provider?.readDocPreviewFile) { + throw new Error('Secure document previews require a newer SSH relay') + } + const result = provider?.readDocPreviewFile + ? await provider.readDocPreviewFile(request) + : await readAuthorizedDocPreviewFile(request) + return assertPreviewWithinTransportBudget(result, maxContentBytes) + } + + async readFileExplorerChunk( + worktreeSelector: string, + relativePath: string, + offset: number, + length: number + ): Promise { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fileStat = await provider.stat(target.path) + if (fileStat.type === 'directory') { + throw new Error('Cannot download a directory') + } + return readSshFileExplorerChunk(provider, target.path, fileStat.size, offset, length) + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const fileStats = await stat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot download a directory') + } + const handle = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(Math.min(length, Math.max(0, fileStats.size - offset))) + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset) + const chunk = buffer.subarray(0, bytesRead) + return { + contentBase64: chunk.toString('base64'), + bytesRead, + eof: offset + bytesRead >= fileStats.size + } + } finally { + await handle.close() + } + } +} diff --git a/src/main/runtime/runtime-file-commands-read-mobile-file.ts b/src/main/runtime/runtime-file-commands-read-mobile-file.ts new file mode 100644 index 00000000000..db253f78188 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-read-mobile-file.ts @@ -0,0 +1,38 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithConstructor } from './runtime-file-commands-constructor' +import type { RuntimeFileReadResult } from '../../shared/runtime-types' +import { isMobileBinaryPath, isSafeMobileRelativePath } from './runtime-file-commands-host' +import { joinWorktreeRelativePath } from './runtime-relative-paths' +import { readLocalMobileFile } from './runtime-file-commands-terminal-artifact-paths' +import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-security' + +export class RuntimeFileCommandsWithReadMobileFile extends RuntimeFileCommandsWithConstructor { + async readMobileFile( + worktreeSelector: string, + relativePath: string + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + if (!isSafeMobileRelativePath(relativePath)) { + throw new Error('invalid_relative_path') + } + if (isMobileBinaryPath(relativePath)) { + throw new Error('binary_file') + } + + const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + const content = connectionId + ? await this.readRemoteMobileFile(filePath, connectionId) + : await readLocalMobileFile(filePath, store) + const truncated = truncateMobileFilePreview(content) + + return { + worktree: worktree.id, + relativePath, + content: truncated.content, + truncated: truncated.truncated, + byteLength: truncated.byteLength + } + } +} diff --git a/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts new file mode 100644 index 00000000000..e2ddd6ca481 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-resolve-allowed-terminal-artifact-path.ts @@ -0,0 +1,212 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithResolveTerminalPath } from './runtime-file-commands-resolve-terminal-path' +import { + assertLocalTerminalArtifactPathStillCanonical, + resolveAllowedLocalTerminalArtifactPath +} from './runtime-file-commands-terminal-artifact-paths' +import { + assertTerminalArtifactNotHardLinked, + canonicalPathForArtifactComparison, + isTerminalArtifactHardLinked, + terminalFileStatIdentity +} from './runtime-file-commands-terminal-artifact-security' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { open } from 'node:fs/promises' +import type { + RuntimeFileStatLike, + TerminalFileGrant +} from './runtime-file-commands-mobile-file-list-limit' +import { TERMINAL_FILE_GRANT_TTL_MS } from './runtime-file-commands-mobile-file-list-limit' +import type { RuntimeTerminalPathResolution } from '../../shared/runtime-types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { randomUUID } from 'node:crypto' +import type { ResolvedRuntimeFileTarget } from './runtime-file-commands-watcher-lifecycle' + +export class RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath extends RuntimeFileCommandsWithResolveTerminalPath { + protected async resolveAllowedTerminalArtifactPath(args: { + absolutePath: string + connectionId?: string + worktreePath: string + }): Promise { + if (args.connectionId) { + return this.resolveAllowedRemoteTerminalArtifactPath(args.absolutePath, args.connectionId) + } + return resolveAllowedLocalTerminalArtifactPath(args.absolutePath, args.worktreePath) + } + + protected async resolveNativeChatArtifactPath( + absolutePath: string, + connectionId?: string + ): Promise { + if (!connectionId) { + return canonicalPathForArtifactComparison(absolutePath) + } + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.realpath(absolutePath) + } + + protected async resolveAbsoluteFileGrant(args: { + worktreeId: string + artifactPath: string + rejectedAbsolutePath?: string + connectionId?: string + clientId?: string + readOnly?: boolean + provenance?: TerminalFileGrant['provenance'] + }): Promise { + const stats = args.connectionId + ? await this.statRemoteTerminalPath(args.artifactPath, args.connectionId) + : await this.statLocalTerminalPath(args.artifactPath) + const isDirectory = stats.isDirectory() + if (!isDirectory && isTerminalArtifactHardLinked(stats)) { + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.rejectedAbsolutePath ?? args.artifactPath, + exists: false, + isDirectory: false + } + } + const grant = isDirectory + ? null + : this.createTerminalFileGrant({ + worktreeId: args.worktreeId, + absolutePath: args.artifactPath, + provider: args.connectionId ? 'ssh' : 'local', + connectionId: args.connectionId, + clientId: args.clientId, + readOnly: args.readOnly === true, + provenance: args.provenance ?? 'terminal-output', + stats + }) + return { + worktree: args.worktreeId, + relativePath: null, + absolutePath: args.artifactPath, + exists: true, + isDirectory, + openTarget: grant + ? { + kind: 'absolute-file', + provider: grant.provider, + absolutePath: args.artifactPath, + grantId: grant.id, + ...(grant.readOnly ? { readOnly: true } : {}) + } + : undefined + } + } + + protected async resolveAllowedRemoteTerminalArtifactPath( + absolutePath: string, + connectionId: string + ): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const roots = ['/tmp', '/private/tmp'] + const providerTempDir = await provider.getTempDir?.().catch(() => null) + if (providerTempDir) { + roots.push(providerTempDir) + } + if (!roots.some((root) => isPathInsideOrEqual(root, absolutePath))) { + return null + } + const [realArtifactPath, ...realRoots] = await Promise.all([ + provider.realpath(absolutePath), + ...roots.map((root) => provider.realpath(root).catch(() => root)) + ]) + // Why: SSH I/O follows symlinks on the relay; grant the canonical target so a /tmp link can't escape the temp boundary. + return realRoots.some((root) => isPathInsideOrEqual(root, realArtifactPath)) + ? realArtifactPath + : null + } + + protected async statLocalTerminalPath( + absolutePath: string + ): Promise boolean }> { + await assertLocalTerminalArtifactPathStillCanonical(absolutePath) + const handle = await open(absolutePath, 'r') + try { + return handle.stat() + } finally { + await handle.close() + } + } + + protected createTerminalFileGrant(args: { + worktreeId: string + absolutePath: string + provider: 'local' | 'ssh' + connectionId?: string + clientId?: string + readOnly?: boolean + provenance: TerminalFileGrant['provenance'] + stats: RuntimeFileStatLike + }): TerminalFileGrant { + assertTerminalArtifactNotHardLinked(args.stats) + const grant: TerminalFileGrant = { + id: randomUUID(), + worktreeId: args.worktreeId, + absolutePath: args.absolutePath, + provider: args.provider, + ...(args.connectionId ? { connectionId: args.connectionId } : {}), + ...(args.clientId ? { clientId: args.clientId } : {}), + expiresAt: Date.now() + TERMINAL_FILE_GRANT_TTL_MS, + statIdentity: terminalFileStatIdentity(args.stats), + readOnly: args.readOnly === true, + provenance: args.provenance + } + this.terminalFileGrants.set(grant.id, grant) + this.scheduleTerminalFileGrantExpiry(grant) + return grant + } + + protected async requireTerminalFileGrant( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string + ): Promise<{ grant: TerminalFileGrant; target: ResolvedRuntimeFileTarget }> { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + this.pruneExpiredTerminalFileGrants() + const grant = this.terminalFileGrants.get(grantId) + if (!grant) { + throw new Error('terminal_file_grant_expired') + } + if (grant.expiresAt <= Date.now()) { + this.releaseTerminalFileGrant(grantId, grant) + throw new Error('terminal_file_grant_expired') + } + if ( + grant.worktreeId !== target.worktree.id || + grant.absolutePath !== absolutePath || + grant.connectionId !== target.connectionId || + grant.clientId !== clientId + ) { + throw new Error('terminal_file_grant_mismatch') + } + return { grant, target } + } + + protected refreshTerminalFileGrant(grant: TerminalFileGrant): void { + grant.expiresAt = Date.now() + TERMINAL_FILE_GRANT_TTL_MS + this.scheduleTerminalFileGrantExpiry(grant) + } + + protected pruneExpiredTerminalFileGrants(): void { + const now = Date.now() + for (const [id, grant] of this.terminalFileGrants) { + if (grant.expiresAt <= now) { + this.releaseTerminalFileGrant(id, grant) + } + } + } +} diff --git a/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts new file mode 100644 index 00000000000..ce1142abef1 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-resolve-terminal-path.ts @@ -0,0 +1,195 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithReadMobileFile } from './runtime-file-commands-read-mobile-file' +import { RuntimeFileCommandsWithActiveRuntimeTextSearches as RuntimeFileCommands } from './runtime-file-commands-active-runtime-text-searches' +import type { + RuntimeNativeChatFileContext, + RuntimeTerminalPathResolution +} from '../../shared/runtime-types' +import { parseWslPath } from '../wsl' +import { relativePathInsideRoot, resolveRuntimePath } from '../../shared/cross-platform-path' +import { homedir } from 'node:os' +import { + provenancePathCandidate, + resolveTerminalAbsolutePath +} from './runtime-file-commands-terminal-artifact-paths' +import { stat } from 'node:fs/promises' +import { getRuntimeFileTargetExecutionHostId } from './runtime-file-commands-watcher-lifecycle' +import { isSafeMobileRelativePath } from './runtime-file-commands-host' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { isENOENT } from '../ipc/filesystem-path-containment' +import type { RuntimeFileStatLike } from './runtime-file-commands-mobile-file-list-limit' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' + +export class RuntimeFileCommandsWithResolveTerminalPath extends RuntimeFileCommandsWithReadMobileFile { + // Resolves a mobile terminal tap to a worktree-relative path; relatives resolve against cwd, else the worktree root. + async resolveTerminalPath( + worktreeSelector: string, + pathText: string, + cwd?: string | null, + clientId?: string, + terminalHandle?: string | null, + crossWorkspace?: boolean, + nativeChatContext?: RuntimeNativeChatFileContext | null + ): Promise { + const store = this.host.requireStore() + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = target + // Why: mobile may attach after OSC7 cwd was emitted; the runtime still owns the terminal's latest cwd to resolve the tap. + const normalizedTerminalHandle = + terminalHandle && terminalHandle.trim().length > 0 ? terminalHandle.trim() : null + const terminalCwd = normalizedTerminalHandle + ? await this.host.resolveTerminalCwd?.(normalizedTerminalHandle) + : null + const terminalFileUriHostname = normalizedTerminalHandle + ? await this.host.resolveTerminalFileUriHostname?.(normalizedTerminalHandle) + : null + const base = terminalCwd || (cwd && cwd.trim().length > 0 ? cwd : worktree.path) + + const empty: RuntimeTerminalPathResolution = { + worktree: worktree.id, + relativePath: null, + absolutePath: null, + exists: false, + isDirectory: false + } + + // Why: SSH/WSL homes are unknown here; native-chat grants must not expand their ~/… paths against the local host home. + const isTilde = pathText.startsWith('~/') || pathText.startsWith('~\\') + if (isTilde && (connectionId || (nativeChatContext && parseWslPath(worktree.path)))) { + return empty + } + const expanded = isTilde ? resolveRuntimePath(homedir(), pathText.slice(2)) : pathText + const absolutePath = resolveTerminalAbsolutePath({ + base, + expanded, + worktreePath: worktree.path, + connectionId, + terminalFileUriHostname + }) + const relativePath = relativePathInsideRoot(worktree.path, absolutePath) + // Why: clients that predate crossWorkspace reuse their own worktree id for the + // follow-up files.open, so retargeting to a sibling workspace must be opt-in. + const knownWorkspaceTarget = + crossWorkspace && relativePath === null + ? await this.host.resolveKnownWorkspaceFileTarget?.( + absolutePath, + getRuntimeFileTargetExecutionHostId(target) + ) + : null + const ownedWorktree = knownWorkspaceTarget?.worktree ?? worktree + const ownedConnectionId = knownWorkspaceTarget?.connectionId ?? connectionId + const ownedRelativePath = knownWorkspaceTarget?.relativePath ?? relativePath + + try { + if ( + ownedRelativePath !== null && + (ownedRelativePath === '' || isSafeMobileRelativePath(ownedRelativePath)) + ) { + const stats = ownedConnectionId + ? await this.statRemoteTerminalPath(absolutePath, ownedConnectionId) + : await stat(await resolveAuthorizedPath(absolutePath, store)) + return { + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, + absolutePath, + exists: true, + isDirectory: stats.isDirectory(), + openTarget: stats.isDirectory() + ? undefined + : { + kind: 'worktree-file', + provider: ownedConnectionId ? 'ssh' : 'local', + relativePath: ownedRelativePath, + absolutePath + } + } + } + + if ( + nativeChatContext && + (await this.host.hasRecentNativeChatOutputPath?.( + worktree.id, + nativeChatContext, + pathText, + absolutePath + )) + ) { + const artifactPath = await this.resolveNativeChatArtifactPath(absolutePath, connectionId) + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + connectionId, + clientId, + readOnly: true, + provenance: 'native-chat' + }) + } + + // Why: mobile taps may hit agent artifacts outside the worktree; grant the exact path, not arbitrary absolute paths. + if (!normalizedTerminalHandle || !terminalCwd) { + return { ...empty, relativePath, absolutePath } + } + const terminalContext = this.host.resolveTerminalContext?.(normalizedTerminalHandle) + if ( + !terminalContext || + terminalContext.worktreeId !== worktree.id || + (terminalContext.connectionId ?? undefined) !== connectionId + ) { + return { ...empty, relativePath, absolutePath } + } + const artifactPath = await this.resolveAllowedTerminalArtifactPath({ + absolutePath, + connectionId, + worktreePath: worktree.path + }) + if (!artifactPath) { + return { ...empty, relativePath, absolutePath } + } + if ( + !(await this.host.hasRecentTerminalOutputPath?.( + normalizedTerminalHandle, + provenancePathCandidate(pathText, absolutePath), + artifactPath + )) + ) { + return { ...empty, relativePath, absolutePath } + } + return await this.resolveAbsoluteFileGrant({ + worktreeId: worktree.id, + artifactPath, + rejectedAbsolutePath: absolutePath, + connectionId, + clientId + }) + } catch (error) { + // Report genuine not-found as missing; let transport/permission errors surface so remote taps aren't all reported missing. + if ( + isENOENT(error) || + (ownedConnectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + return { + ...empty, + worktree: ownedWorktree.id, + relativePath: ownedRelativePath, + absolutePath + } + } + throw error + } + } + + protected async statRemoteTerminalPath( + absolutePath: string, + connectionId: string + ): Promise boolean }> { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const stats = await provider.stat(absolutePath) + return { ...stats, isDirectory: () => stats.type === 'directory' } + } +} diff --git a/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts b/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts new file mode 100644 index 00000000000..10e7454a548 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-revoke-terminal-file-grants-for-client.ts @@ -0,0 +1,127 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath } from './runtime-file-commands-resolve-allowed-terminal-artifact-path' +import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' +import { + MOBILE_FILE_READ_MAX_BYTES, + assertPreviewWithinTransportBudget +} from './runtime-file-commands-mobile-file-list-limit' +import type { RuntimeFilePreviewResult, RuntimeFileReadResult } from '../../shared/runtime-types' +import { isMobileBinaryPath } from './runtime-file-commands-host' +import { + openLocalTerminalArtifactGrant, + readLocalTerminalArtifactFileFromHandle, + readLocalTerminalArtifactPreviewFromHandle +} from './runtime-file-commands-terminal-artifact-paths' +import { constants } from 'node:fs/promises' +import { truncateMobileFilePreview } from './runtime-file-commands-terminal-artifact-security' + +export class RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient extends RuntimeFileCommandsWithResolveAllowedTerminalArtifactPath { + revokeTerminalFileGrantsForClient(clientId: string): void { + for (const [id, grant] of this.terminalFileGrants) { + if (grant.clientId === clientId) { + this.releaseTerminalFileGrant(id, grant) + } + } + } + + protected releaseTerminalFileGrant(id: string, grant: TerminalFileGrant): void { + this.terminalFileGrants.delete(id) + if (grant.expiryTimer) { + clearTimeout(grant.expiryTimer) + grant.expiryTimer = undefined + } + } + + protected scheduleTerminalFileGrantExpiry(grant: TerminalFileGrant): void { + if (grant.expiryTimer) { + clearTimeout(grant.expiryTimer) + } + grant.expiryTimer = setTimeout( + () => { + if (this.terminalFileGrants.get(grant.id) === grant && grant.expiresAt <= Date.now()) { + this.releaseTerminalFileGrant(grant.id, grant) + } + }, + Math.max(1, grant.expiresAt - Date.now()) + ) + grant.expiryTimer.unref?.() + } + + async readTerminalArtifactFile( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string + ): Promise { + const { grant, target } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (isMobileBinaryPath(grant.absolutePath)) { + throw new Error('binary_file') + } + let content: string + if (grant.connectionId) { + const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) + content = await this.readRemoteTerminalArtifactFile( + provider, + grant, + MOBILE_FILE_READ_MAX_BYTES + ) + } else { + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + content = await readLocalTerminalArtifactFileFromHandle(handle, grant) + } finally { + await handle.close() + } + } + this.refreshTerminalFileGrant(grant) + const truncated = truncateMobileFilePreview(content) + + return { + worktree: target.worktree.id, + relativePath: grant.absolutePath, + content: truncated.content, + truncated: truncated.truncated, + byteLength: truncated.byteLength + } + } + + async readTerminalArtifactPreview( + worktreeSelector: string, + grantId: string, + absolutePath: string, + clientId?: string, + maxContentBytes?: number + ): Promise { + const { grant } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (grant.connectionId) { + const provider = await this.assertRemoteTerminalFileGrantFreshForRead(grant) + this.refreshTerminalFileGrant(grant) + return assertPreviewWithinTransportBudget( + await this.readRemoteTerminalArtifactPreview(provider, grant, maxContentBytes), + maxContentBytes + ) + } + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + const preview = await readLocalTerminalArtifactPreviewFromHandle( + handle, + grant, + maxContentBytes + ) + this.refreshTerminalFileGrant(grant) + return assertPreviewWithinTransportBudget(preview, maxContentBytes) + } finally { + await handle.close() + } + } +} diff --git a/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts new file mode 100644 index 00000000000..74a3522781d --- /dev/null +++ b/src/main/runtime/runtime-file-commands-search-local-runtime-files.ts @@ -0,0 +1,211 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithSearchRuntimeFiles } from './runtime-file-commands-search-runtime-files' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' +import { + DEFAULT_SEARCH_MAX_RESULTS, + SEARCH_TIMEOUT_MS, + buildRgArgs, + createAccumulator, + finalize, + ingestRgJsonLine +} from '../../shared/text-search' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { checkRgAvailable } from '../ipc/rg-availability' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' +import type { ChildProcess } from 'node:child_process' +import { wslAwareSpawn } from '../git/runner' +import type { ResolvedRuntimeFileWorktree } from './runtime-file-commands-watcher-lifecycle' +import { joinWorktreeRelativePath, normalizeRuntimeRelativePath } from './runtime-relative-paths' +import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' + +export class RuntimeFileCommandsWithSearchLocalRuntimeFiles extends RuntimeFileCommandsWithSearchRuntimeFiles { + protected async searchLocalRuntimeFiles( + rootPath: string, + options: SearchOptions + ): Promise { + const store = this.host.requireStore() + const authorizedRootPath = await resolveAuthorizedPath(rootPath, store) + const localGitOptions = getLocalGitOptionsForRegisteredWorktree( + store, + rootPath, + authorizedRootPath + ) + const maxResults = Math.max( + 1, + Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) + ) + const wslInfo = parseWslPath(authorizedRootPath) + if ( + (wslInfo || localGitOptions.wslDistro) && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) + } + + return new Promise((resolvePromise) => { + const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` + const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) + const previousChild = this.activeRuntimeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } + + const acc = createAccumulator() + let stdoutBuffer = '' + let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let child: ChildProcess | null = null + const transformAbsPath = wslInfo + ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) + : undefined + + const finish = (result: SearchResult | PromiseLike): void => { + if (resolved) { + return + } + resolved = true + if (this.activeRuntimeTextSearches.get(searchKey) === child) { + this.activeRuntimeTextSearches.delete(searchKey) + } + cleanupListeners() + resolvePromise(result) + } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) + + let killTimeout: ReturnType | null = null + const cleanupListeners = (): void => { + if (killTimeout) { + clearTimeout(killTimeout) + killTimeout = null + } + child?.stdout?.off('data', onStdoutData) + child?.stderr?.off('data', onStderrData) + child?.off('error', onError) + child?.off('close', onClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + } + + const processLine = (line: string): void => { + const verdict = ingestRgJsonLine( + line, + authorizedRootPath, + acc, + maxResults, + transformAbsPath + ) + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) + } + } + + const nextChild = wslAwareSpawn('rg', rgArgs, { + cwd: authorizedRootPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}), + stdio: ['ignore', 'pipe', 'pipe'] + }) + child = nextChild + this.activeRuntimeTextSearches.set(searchKey, nextChild) + + nextChild.stdout!.setEncoding('utf-8') + const onStdoutData = (chunk: string): void => { + stdoutBuffer += chunk + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + } + const onStderrData = (): void => { + // Drain stderr so rg cannot block on a full pipe. + } + const onError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } + if (stdoutBuffer) { + processLine(stdoutBuffer) + } + resolveOnce() + } + + nextChild.stdout!.on('data', onStdoutData) + nextChild.stderr!.on('data', onStderrData) + nextChild.once('error', onError) + nextChild.once('close', onClose) + + killTimeout = setTimeout(() => { + acc.truncated = true + if (child) { + killSpawnedRipgrepProcess(child) + } + resolveOnce() + }, SEARCH_TIMEOUT_MS) + }) + } + + protected async resolveFileExplorerPath( + worktreeSelector: string, + relativePath: string + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }> { + const [target] = await this.resolveFileExplorerPaths(worktreeSelector, [relativePath]) + return target + } + + protected async resolveFileExplorerPaths( + worktreeSelector: string, + relativePaths: readonly string[] + ): Promise<{ worktree: ResolvedRuntimeFileWorktree; path: string; connectionId?: string }[]> { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + return relativePaths.map((relativePath) => ({ + worktree: target.worktree, + path: joinWorktreeRelativePath( + target.worktree.path, + normalizeRuntimeRelativePath(relativePath) + ), + connectionId: target.connectionId + })) + } + + protected async listRemoteMobileFiles( + rootPath: string, + connectionId: string, + maxResults?: number, + signal?: AbortSignal + ): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return [] + } + return provider.listFiles(rootPath, { maxResults, signal }) + } +} diff --git a/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts new file mode 100644 index 00000000000..f70c28de430 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-search-remote-quick-open-file-paths.ts @@ -0,0 +1,74 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithSearchLocalRuntimeFiles } from './runtime-file-commands-search-local-runtime-files' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { + MOBILE_FILE_READ_MAX_BYTES, + QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT +} from './runtime-file-commands-mobile-file-list-limit' +import { QuickOpenPathRanker } from '../../shared/quick-open-path-search' + +export class RuntimeFileCommandsWithSearchRemoteQuickOpenFilePaths extends RuntimeFileCommandsWithSearchLocalRuntimeFiles { + protected async searchRemoteQuickOpenFilePaths( + rootPath: string, + connectionId: string, + query: string, + limit: number, + excludePaths?: string[], + signal?: AbortSignal + ): Promise<{ paths: string[]; totalCount: number; truncated: boolean }> { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + return { paths: [], totalCount: 0, truncated: false } + } + if (!(await provider.supportsQuickOpenSearch?.({ signal }))) { + // Old relays ignore searchQuery. Keep the compatibility request below the + // 4 MiB frame ceiling even when legacy paths are near the 64 KiB path cap. + const legacyFiles = await provider.listFiles(rootPath, { + excludePaths, + maxResults: QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT, + signal + }) + const ranker = new QuickOpenPathRanker(query, limit) + for (const file of legacyFiles) { + ranker.consider(file) + } + const result = ranker.result() + return { + ...result, + truncated: + legacyFiles.length >= QUICK_OPEN_LEGACY_REMOTE_RESULT_LIMIT || result.totalCount > limit + } + } + const files = await provider.listFiles(rootPath, { + excludePaths, + maxResults: limit + 1, + searchQuery: query, + signal + }) + return { + paths: files.slice(0, limit), + totalCount: files.length, + truncated: files.length > limit + } + } + + protected async readRemoteMobileFile(filePath: string, connectionId: string): Promise { + const provider = getSshFilesystemProvider(connectionId) + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fileStat = await provider.stat(filePath) + // Why: no ranged reads over SSH here, so reject oversized previews instead of streaming a whole file just to trim it. + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const result = await provider.readFile(filePath) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } +} diff --git a/src/main/runtime/runtime-file-commands-search-runtime-files.ts b/src/main/runtime/runtime-file-commands-search-runtime-files.ts new file mode 100644 index 00000000000..f31b65c0cdf --- /dev/null +++ b/src/main/runtime/runtime-file-commands-search-runtime-files.ts @@ -0,0 +1,108 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithCreateFileExplorerDirNoClobber } from './runtime-file-commands-create-file-explorer-dir-no-clobber' +import type { SearchOptions, SearchResult } from '../../shared/code-search-types' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { QUICK_OPEN_LISTING_MAX_RESULTS } from '../../shared/quick-open-listing-limits' +import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget' +import { listQuickOpenFiles } from '../ipc/filesystem-list-files' +import type { MarkdownDocument } from '../../shared/filesystem-entry-types' +import { + listMarkdownDocuments, + markdownDocumentsFromRelativePaths +} from '../ipc/markdown-documents' +import { stat } from 'node:fs/promises' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' + +export class RuntimeFileCommandsWithSearchRuntimeFiles extends RuntimeFileCommandsWithCreateFileExplorerDirNoClobber { + async searchRuntimeFiles( + worktreeSelector: string, + options: Omit + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const rootPath = target.worktree.path + const searchOptions = { ...options, rootPath } + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + return provider.search(searchOptions) + } + return this.searchLocalRuntimeFiles(rootPath, searchOptions) + } + + async listRuntimeFiles( + worktreeSelector: string, + options: { + excludePaths?: string[] + maxContentBytes?: number + maxResults?: number + signal?: AbortSignal + } = {} + ): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + return [] + } + const maxResults = + options.maxResults ?? + (options.maxContentBytes === undefined ? undefined : QUICK_OPEN_LISTING_MAX_RESULTS) + const files = await provider.listFiles(target.worktree.path, { + excludePaths: options.excludePaths, + maxResults, + signal: options.signal + }) + return options.maxContentBytes === undefined + ? files + : limitQuickOpenFilesBySerializedBytes(files, options.maxContentBytes) + } + return listQuickOpenFiles( + target.worktree.path, + this.host.requireStore(), + options.excludePaths, + options.signal, + options.maxResults, + options.maxContentBytes + ) + } + + async listRuntimeMarkdownDocuments(worktreeSelector: string): Promise { + const target = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const relativePaths = await provider.listFiles(target.worktree.path) + return markdownDocumentsFromRelativePaths(target.worktree.path, relativePaths) + } + return listMarkdownDocuments(target.worktree.path) + } + + async statRuntimeFile( + worktreeSelector: string, + relativePath: string + ): Promise<{ size: number; isDirectory: boolean; mtime: number }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + const fileStat = await provider.stat(target.path) + return { + size: fileStat.size, + isDirectory: fileStat.type === 'directory', + mtime: fileStat.mtime + } + } + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + const stats = await stat(filePath) + return { size: stats.size, isDirectory: stats.isDirectory(), mtime: stats.mtimeMs } + } +} diff --git a/src/main/runtime/runtime-file-commands-ssh-watch-rearm.ts b/src/main/runtime/runtime-file-commands-ssh-watch-rearm.ts new file mode 100644 index 00000000000..3e74fc0e61d --- /dev/null +++ b/src/main/runtime/runtime-file-commands-ssh-watch-rearm.ts @@ -0,0 +1,91 @@ +// @ts-nocheck -- mechanically split declarations. +import type { FsChangeEvent } from '../../shared/filesystem-entry-types' +import { + runtimeWatcherReleaseKey, + sshFileExplorerWatchRearms +} from './runtime-file-commands-mobile-file-list-limit' +import { + getSshFilesystemProvider, + onSshFilesystemProviderRegistered +} from '../providers/ssh-filesystem-dispatch' + +export function armSshFileExplorerWatchRearm(args: { + runtimeId: string + connectionId: string + rootPath: string + callback: (events: FsChangeEvent[]) => void + onTerminalError: (error: Error) => void + signal?: AbortSignal + initialUnwatch: () => void +}): { unsubscribe: () => Promise } { + const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) + let currentUnwatch = args.initialUnwatch + let stopped = false + let reinstalling: Promise | null = null + + const reinstall = async (): Promise => { + const provider = getSshFilesystemProvider(args.connectionId) + if (stopped || !provider) { + return + } + // Why: the old handle is scoped to the dead transport; closing it here would only risk + // unwatching the root we just re-registered on the new one. + const nextUnwatch = await provider.watch(args.rootPath, args.callback, { + signal: args.signal, + onTerminalError: args.onTerminalError + }) + if (stopped) { + nextUnwatch() + return + } + currentUnwatch = nextUnwatch + args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) + } + + const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { + if (registeredId !== args.connectionId || stopped) { + return + } + // Why: reconnect storms can register repeatedly; chain so a second one can't double-install. + const attempt = (reinstalling ?? Promise.resolve()) + .then(reinstall) + .catch((error: unknown) => { + args.onTerminalError(error instanceof Error ? error : new Error(String(error))) + }) + .finally(() => { + if (reinstalling === attempt) { + reinstalling = null + } + }) + reinstalling = attempt + }) + + const stop = (): void => { + stopped = true + unsubscribeRearm() + const rearms = sshFileExplorerWatchRearms.get(key) + rearms?.delete(stop) + if (rearms?.size === 0) { + sshFileExplorerWatchRearms.delete(key) + } + } + const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() + rearms.add(stop) + sshFileExplorerWatchRearms.set(key, rearms) + + return { + unsubscribe: () => { + stop() + const close = async (): Promise => currentUnwatch() + // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the + // unwatch being issued on the same turn the lease releases it. + return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() + } + } +} + +export function stopSshFileExplorerWatchRearms(key: string): void { + for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { + stop() + } +} diff --git a/src/main/runtime/runtime-file-commands-terminal-artifact-paths.ts b/src/main/runtime/runtime-file-commands-terminal-artifact-paths.ts new file mode 100644 index 00000000000..b21e8de832a --- /dev/null +++ b/src/main/runtime/runtime-file-commands-terminal-artifact-paths.ts @@ -0,0 +1,225 @@ +// @ts-nocheck -- mechanically split declarations. +import { lstat, open, stat } from 'node:fs/promises' +import { basename, extname } from 'node:path' +import { isENOENT } from '../ipc/filesystem-path-containment' +import type { Store } from '../persistence' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + MOBILE_FILE_READ_MAX_BYTES, + OPEN_NOFOLLOW, + RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES, + previewableBinaryByteLimit +} from './runtime-file-commands-mobile-file-list-limit' +import type { FileHandle } from 'node:fs/promises' +import { + assertTerminalFileGrantFresh, + canonicalPathForArtifactComparison, + localTerminalArtifactRoots, + readFileHandleBufferBounded +} from './runtime-file-commands-terminal-artifact-security' +import { isBinaryBuffer } from './runtime-file-commands-host' +import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' +import { + isPathInsideOrEqual, + isRuntimePathAbsolute, + isWindowsAbsolutePathLike, + resolveRuntimePath +} from '../../shared/cross-platform-path' +import { parseWslPath, toWindowsWslPath } from '../wsl' + +export async function assertRuntimePathDoesNotExist(targetPath: string): Promise { + try { + await lstat(targetPath) + throw new Error( + `A file or folder named '${basename(targetPath)}' already exists in this location` + ) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } +} + +export function rethrowRuntimeFileCreateError(error: unknown, targetPath: string): never { + const name = basename(targetPath) + if (error instanceof Error && 'code' in error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EEXIST') { + throw new Error(`A file or folder named '${name}' already exists in this location`) + } + if (code === 'EACCES' || code === 'EPERM') { + throw new Error(`Permission denied: unable to create '${name}'`) + } + } + throw error +} + +export async function readLocalMobileFile(filePath: string, store: Store): Promise { + const authorizedPath = await resolveAuthorizedPath(filePath, store) + const fileStat = await stat(authorizedPath) + // Why: cap the read so opening a large file can't block the WebSocket (previews are read-only convenience views). + const readLimit = Math.min(fileStat.size, MOBILE_FILE_READ_MAX_BYTES + 1) + const handle = await open(authorizedPath, 'r') + try { + const buffer = Buffer.alloc(readLimit) + const { bytesRead } = await handle.read(buffer, 0, readLimit, 0) + return buffer.subarray(0, bytesRead).toString('utf8') + } finally { + await handle.close() + } +} + +export async function readLocalTerminalArtifactFileFromHandle( + handle: FileHandle, + grant: TerminalFileGrant +): Promise { + const fileStat = await handle.stat() + if (fileStat.isDirectory()) { + throw new Error('Cannot read a directory') + } + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + assertTerminalFileGrantFresh(grant, fileStat) + const buffer = await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1) + if (isBinaryBuffer(buffer)) { + throw new Error('binary_file') + } + return buffer.toString('utf8') +} + +export async function readLocalTerminalArtifactPreviewFromHandle( + handle: FileHandle, + grant: TerminalFileGrant, + maxContentBytes: number | undefined +): Promise { + const fileStats = await handle.stat() + if (fileStats.isDirectory()) { + throw new Error('Cannot preview a directory') + } + assertTerminalFileGrantFresh(grant, fileStats) + const mimeType = RUNTIME_PREVIEWABLE_BINARY_MIME_TYPES[extname(grant.absolutePath).toLowerCase()] + if (mimeType) { + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + if (fileStats.size > binaryMaxBytes) { + throw new Error('file_too_large') + } + const buffer = await readFileHandleBufferBounded(handle, binaryMaxBytes + 1) + if (buffer.byteLength > binaryMaxBytes) { + throw new Error('file_too_large') + } + return { + content: buffer.toString('base64'), + isBinary: true, + isImage: true, + mimeType + } + } + + const content = await readLocalTerminalArtifactFileFromHandle(handle, grant) + return { content, isBinary: false } +} + +export async function assertLocalTerminalArtifactPathStillCanonical( + filePath: string +): Promise { + const currentPath = await canonicalPathForArtifactComparison(filePath) + if (currentPath !== filePath) { + throw new Error('terminal_file_grant_stale') + } +} + +export async function openLocalTerminalArtifactGrant( + grant: TerminalFileGrant, + flags: number +): Promise { + await assertLocalTerminalArtifactPathStillCanonical(grant.absolutePath) + try { + return await open(grant.absolutePath, flags | OPEN_NOFOLLOW) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + throw new Error('terminal_file_grant_stale') + } + throw error + } +} + +export function resolveTerminalAbsolutePath(args: { + base: string + expanded: string + worktreePath: string + connectionId?: string + terminalFileUriHostname?: string | null +}): string { + const expanded = normalizeTerminalFileUriAuthorityPath( + args.expanded, + args.connectionId, + args.terminalFileUriHostname, + args.worktreePath + ) + const absolutePath = isRuntimePathAbsolute(expanded) + ? expanded + : resolveRuntimePath(args.base, expanded) + if (args.connectionId) { + return normalizeLeadingSlashDrivePath(absolutePath, args.worktreePath) + } + const wsl = parseWslPath(args.worktreePath) + if (wsl && absolutePath.startsWith('/') && !absolutePath.startsWith('//')) { + return toWindowsWslPath(absolutePath, wsl.distro) + } + return absolutePath +} + +export function normalizeTerminalFileUriAuthorityPath( + pathText: string, + connectionId?: string, + terminalFileUriHostname?: string | null, + worktreePath?: string +): string { + if (!pathText.startsWith('//')) { + return pathText + } + const match = /^\/\/([^/\\]+)([/\\].*)$/.exec(pathText) + if (!match) { + return pathText + } + const host = match[1]!.toLowerCase() + if (terminalFileUriHostname && host === terminalFileUriHostname.toLowerCase() && connectionId) { + return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) + } + if (isLoopbackFileUriHostname(host) && (connectionId || process.platform !== 'win32')) { + return normalizeLeadingSlashDrivePath(match[2]!, worktreePath) + } + // Why: without a verified host match, stripping the file-URI authority could open a same-path artifact on the wrong machine. + return pathText +} + +export function provenancePathCandidate(pathText: string, absolutePath: string): string { + return pathText.startsWith('//') ? pathText : absolutePath +} + +export function isLoopbackFileUriHostname(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' +} + +export function normalizeLeadingSlashDrivePath(pathText: string, worktreePath?: string): string { + return worktreePath && + isWindowsAbsolutePathLike(worktreePath) && + /^\/[A-Za-z]:[\\/]/.test(pathText) + ? pathText.slice(1) + : pathText +} + +export async function resolveAllowedLocalTerminalArtifactPath( + absolutePath: string, + worktreePath: string +): Promise { + const roots = await localTerminalArtifactRoots(worktreePath) + const canonicalPath = await canonicalPathForArtifactComparison(absolutePath) + return roots.some((root) => isPathInsideOrEqual(root, canonicalPath)) ? canonicalPath : null +} diff --git a/src/main/runtime/runtime-file-commands-terminal-artifact-security.ts b/src/main/runtime/runtime-file-commands-terminal-artifact-security.ts new file mode 100644 index 00000000000..03d1c9ff3f9 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-terminal-artifact-security.ts @@ -0,0 +1,100 @@ +// @ts-nocheck -- mechanically split declarations. +import { tmpdir } from 'node:os' +import { parseWslPath, toWindowsWslPath } from '../wsl' +import { realpath } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import type { + RuntimeFileStatLike, + TerminalFileGrant +} from './runtime-file-commands-mobile-file-list-limit' +import { MOBILE_FILE_READ_MAX_BYTES } from './runtime-file-commands-mobile-file-list-limit' + +export async function localTerminalArtifactRoots(worktreePath: string): Promise { + const roots = new Set([tmpdir()]) + if (process.platform !== 'win32') { + roots.add('/tmp') + roots.add('/private/tmp') + } + const wsl = parseWslPath(worktreePath) + if (wsl) { + roots.add(toWindowsWslPath('/tmp', wsl.distro)) + } + const canonicalRoots = await Promise.all( + Array.from(roots).map((root) => canonicalPathForArtifactComparison(root)) + ) + return Array.from(new Set([...roots, ...canonicalRoots])) +} + +export async function canonicalPathForArtifactComparison(path: string): Promise { + try { + return await realpath(path) + } catch { + return path + } +} + +export async function readFileHandleBufferBounded( + handle: FileHandle, + limit: number +): Promise { + const buffer = Buffer.alloc(limit) + const { bytesRead } = await handle.read(buffer, 0, limit, 0) + return buffer.subarray(0, bytesRead) +} + +export function terminalFileStatIdentity(stats: RuntimeFileStatLike): string | null { + const dev = typeof stats.dev === 'number' ? stats.dev : null + const ino = typeof stats.ino === 'number' ? stats.ino : null + const nlink = typeof stats.nlink === 'number' ? stats.nlink : null + const size = typeof stats.size === 'number' ? stats.size : null + const mtimeMs = + typeof stats.mtimeMs === 'number' + ? stats.mtimeMs + : typeof stats.mtime === 'number' + ? stats.mtime + : null + if (dev !== null && ino !== null && size !== null && mtimeMs !== null) { + return `${dev}:${ino}:${nlink ?? 'unknown'}:${size}:${mtimeMs}` + } + if (size !== null && mtimeMs !== null) { + return `${size}:${mtimeMs}` + } + return null +} + +export function assertTerminalFileGrantFresh( + grant: TerminalFileGrant, + stats: RuntimeFileStatLike +): void { + assertTerminalArtifactNotHardLinked(stats) + const nextIdentity = terminalFileStatIdentity(stats) + if (grant.statIdentity !== null && nextIdentity !== null && grant.statIdentity !== nextIdentity) { + throw new Error('terminal_file_grant_stale') + } +} + +export function assertTerminalArtifactNotHardLinked(stats: RuntimeFileStatLike): void { + if (isTerminalArtifactHardLinked(stats)) { + throw new Error('terminal_file_grant_stale') + } +} + +export function isTerminalArtifactHardLinked(stats: RuntimeFileStatLike): boolean { + return typeof stats.nlink === 'number' && stats.nlink > 1 +} + +export function truncateMobileFilePreview(content: string): { + content: string + truncated: boolean + byteLength: number +} { + const buffer = Buffer.from(content, 'utf8') + if (buffer.byteLength <= MOBILE_FILE_READ_MAX_BYTES) { + return { content, truncated: false, byteLength: buffer.byteLength } + } + return { + content: buffer.subarray(0, MOBILE_FILE_READ_MAX_BYTES).toString('utf8'), + truncated: true, + byteLength: buffer.byteLength + } +} diff --git a/src/main/runtime/runtime-file-commands-watcher-lifecycle.ts b/src/main/runtime/runtime-file-commands-watcher-lifecycle.ts new file mode 100644 index 00000000000..6ec55f3f014 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-watcher-lifecycle.ts @@ -0,0 +1,196 @@ +// @ts-nocheck -- mechanically split declarations. +import type { RuntimeFileWatcherLease } from './runtime-file-commands-mobile-file-list-limit' +import { + pendingRuntimeFileWatcherUnsubscribes, + runtimeFileWatcherLeasesByOwnerAndRoot, + runtimeWatcherReleaseKey, + sshFileExplorerWatchRearms, + trackRuntimeFileWatcherUnsubscribe +} from './runtime-file-commands-mobile-file-list-limit' +import { isWatcherProcessFailure } from '../ipc/parcel-watcher-process-failure' +import { stopSshFileExplorerWatchRearms } from './runtime-file-commands-ssh-watch-rearm' +import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { toSshExecutionHostId } from '../../shared/execution-host' + +export function registerRuntimeFileWatcherRelease( + runtimeId: string, + connectionId: string | undefined, + rootPaths: string[], + unsubscribe: () => Promise, + restart: () => Promise<() => Promise>, + onRestoreError: (error: Error) => void +): () => Promise { + const keys = Array.from( + new Set( + rootPaths.map((rootPath) => runtimeWatcherReleaseKey(runtimeId, connectionId, rootPath)) + ) + ) + let currentUnsubscribe: (() => Promise) | null = unsubscribe + let releasePromise: Promise | null = null + let physicalExitPromise: Promise | null = null + let resumePromise: Promise | null = null + let stopPromise: Promise | null = null + let logicallyStopped = false + const removeLease = (): void => { + for (const key of keys) { + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) + leases?.delete(lease) + if (leases?.size === 0) { + runtimeFileWatcherLeasesByOwnerAndRoot.delete(key) + } + } + } + const suspend = (): Promise => { + if (releasePromise) { + return releasePromise + } + const release = currentUnsubscribe + if (!release) { + return Promise.resolve() + } + const attempt = trackRuntimeFileWatcherUnsubscribe(rootPaths[0], release) + releasePromise = attempt + void attempt.then( + () => { + if (currentUnsubscribe === release) { + currentUnsubscribe = null + } + releasePromise = null + }, + (error: unknown) => { + if (isWatcherProcessFailure(error) && error.physicalExit) { + const physicalExit = error.physicalExit.then(() => { + if (currentUnsubscribe === release) { + currentUnsubscribe = null + } + releasePromise = null + if (physicalExitPromise === physicalExit) { + physicalExitPromise = null + } + if (logicallyStopped) { + removeLease() + } + }) + physicalExitPromise = physicalExit + } else { + // Why: a synchronous close failure retains the native owner so a later removal or unsubscribe can retry the same handle. + releasePromise = null + } + } + ) + return attempt + } + const lease: RuntimeFileWatcherLease = { + suspend, + resume: () => { + if (logicallyStopped || (currentUnsubscribe && !physicalExitPromise)) { + return Promise.resolve() + } + if (resumePromise) { + return physicalExitPromise ? Promise.resolve() : resumePromise + } + // Why: a timed-out child still owns native handles until physical exit; join that owner before starting a replacement. + const resumesAfterPhysicalExit = physicalExitPromise !== null + const attempt = Promise.resolve(physicalExitPromise ?? releasePromise) + .then(async () => { + if (logicallyStopped) { + return + } + const nextUnsubscribe = await restart() + if (logicallyStopped) { + await nextUnsubscribe() + return + } + currentUnsubscribe = nextUnsubscribe + }) + .catch((error: unknown) => { + const restoreError = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => onRestoreError(restoreError)) + throw restoreError + }) + .finally(() => { + resumePromise = null + }) + resumePromise = attempt + if (resumesAfterPhysicalExit) { + void attempt.catch(() => {}) + return Promise.resolve() + } + return attempt + }, + forget: () => { + logicallyStopped = true + removeLease() + } + } + for (const key of keys) { + const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) ?? new Set() + leases.add(lease) + runtimeFileWatcherLeasesByOwnerAndRoot.set(key, leases) + } + return () => { + if (stopPromise) { + return stopPromise + } + logicallyStopped = true + const release = + resumePromise && !physicalExitPromise + ? Promise.resolve(resumePromise) + .catch(() => undefined) + .then(suspend) + : suspend() + const attempt = release.then(removeLease).catch((error: unknown) => { + stopPromise = null + throw error + }) + stopPromise = attempt + return attempt + } +} + +export async function awaitRuntimeFileWatcherUnsubscribes(): Promise { + await Promise.allSettled(Array.from(pendingRuntimeFileWatcherUnsubscribes)) +} + +export function _getRuntimeFileWatcherReleaseCountForTests(): number { + const leases = new Set() + for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { + for (const lease of rootLeases) { + leases.add(lease) + } + } + return leases.size +} + +export function _resetRuntimeFileWatcherLeasesForTests(): void { + const leases = new Set() + for (const rootLeases of runtimeFileWatcherLeasesByOwnerAndRoot.values()) { + for (const lease of rootLeases) { + leases.add(lease) + } + } + for (const lease of leases) { + lease.forget() + } + for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { + stopSshFileExplorerWatchRearms(key) + } + runtimeFileWatcherLeasesByOwnerAndRoot.clear() +} + +export type ResolvedRuntimeFileWorktree = Worktree & { git: GitWorktreeInfo } + +export type ResolvedRuntimeFileTarget = { + worktree: ResolvedRuntimeFileWorktree + connectionId?: string +} + +export function getRuntimeFileTargetExecutionHostId( + target: ResolvedRuntimeFileTarget +): ExecutionHostId { + return ( + target.worktree.hostId ?? + (target.connectionId ? toSshExecutionHostId(target.connectionId) : 'local') + ) +} diff --git a/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts new file mode 100644 index 00000000000..767f270f532 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-write-file-explorer-file.ts @@ -0,0 +1,181 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithReadFileExplorerPreview } from './runtime-file-commands-read-file-explorer-preview' +import { assertRuntimeFileMutationExpectation } from './runtime-file-commands-mobile-file-list-limit' +import { + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE, + getSshFilesystemProvider +} from '../providers/ssh-filesystem-dispatch' +import { lstat, mkdir, writeFile } from 'node:fs/promises' +import { resolveAuthorizedPath } from '../ipc/filesystem-auth' +import { isENOENT } from '../ipc/filesystem-path-containment' +import { dirname } from 'node:path' +import { + assertRuntimePathDoesNotExist, + rethrowRuntimeFileCreateError +} from './runtime-file-commands-terminal-artifact-paths' + +export class RuntimeFileCommandsWithWriteFileExplorerFile extends RuntimeFileCommandsWithReadFileExplorerPreview { + async writeFileExplorerFile( + worktreeSelector: string, + relativePath: string, + content: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.writeFile(target.path, content) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + try { + const fileStats = await lstat(filePath) + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + } catch (error) { + if (!isENOENT(error)) { + throw error + } + } + await writeFile(filePath, content, 'utf-8') + return { ok: true } + } + + async writeFileExplorerFileBase64( + worktreeSelector: string, + relativePath: string, + contentBase64: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.writeFileBase64(target.path, contentBase64) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: 'wx' }) + return { ok: true } + } + + async writeFileExplorerFileBase64Chunk( + worktreeSelector: string, + relativePath: string, + contentBase64: string, + append: boolean, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + const content = Buffer.from(contentBase64, 'base64') + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.writeFileBase64Chunk(target.path, contentBase64, append) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, content, { flag: append ? 'a' : 'wx' }) + return { ok: true } + } + + async createFileExplorerFile( + worktreeSelector: string, + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.createFile(target.path) + return { ok: true } + } + + const filePath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await mkdir(dirname(filePath), { recursive: true }) + try { + await writeFile(filePath, '', { encoding: 'utf-8', flag: 'wx' }) + } catch (error) { + rethrowRuntimeFileCreateError(error, filePath) + } + return { ok: true } + } + + async createFileExplorerDir( + worktreeSelector: string, + relativePath: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ): Promise<{ ok: true }> { + const target = await this.resolveFileExplorerPath(worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.connectionId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + const provider = target.connectionId ? getSshFilesystemProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE) + } + await provider.createDir(target.path) + return { ok: true } + } + + const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) + await assertRuntimePathDoesNotExist(dirPath) + await mkdir(dirPath, { recursive: false }) + return { ok: true } + } +} diff --git a/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts new file mode 100644 index 00000000000..3cd21bb05d8 --- /dev/null +++ b/src/main/runtime/runtime-file-commands-write-terminal-artifact-file.ts @@ -0,0 +1,190 @@ +// @ts-nocheck -- mechanically split class members. +import { RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient } from './runtime-file-commands-revoke-terminal-file-grants-for-client' +import type { TerminalFileGrant } from './runtime-file-commands-mobile-file-list-limit' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + MOBILE_FILE_READ_MAX_BYTES, + previewableBinaryByteLimit +} from './runtime-file-commands-mobile-file-list-limit' +import { isBinaryBuffer, isMobileBinaryPath } from './runtime-file-commands-host' +import { + assertTerminalFileGrantFresh, + readFileHandleBufferBounded, + terminalFileStatIdentity +} from './runtime-file-commands-terminal-artifact-security' +import { openLocalTerminalArtifactGrant } from './runtime-file-commands-terminal-artifact-paths' +import { chmod, constants, rename, rm, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { randomUUID } from 'node:crypto' +import type { FileStat, IFilesystemProvider } from '../providers/types' +import type { RuntimeFilePreviewResult } from '../../shared/runtime-types' + +export class RuntimeFileCommandsWithWriteTerminalArtifactFile extends RuntimeFileCommandsWithRevokeTerminalFileGrantsForClient { + async writeTerminalArtifactFile( + worktreeSelector: string, + grantId: string, + absolutePath: string, + content: string, + clientId?: string + ): Promise<{ ok: true }> { + if (Buffer.byteLength(content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + const { grant } = await this.requireTerminalFileGrant( + worktreeSelector, + grantId, + absolutePath, + clientId + ) + if (grant.readOnly) { + throw new Error('terminal_file_grant_read_only') + } + if (isMobileBinaryPath(grant.absolutePath)) { + throw new Error('binary_file') + } + if (grant.connectionId) { + const { provider, fileStat } = await this.assertRemoteTerminalFileGrantFresh(grant) + if (fileStat.type === 'directory') { + throw new Error('Cannot write to a directory') + } + if (fileStat.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + if (!provider.writeTerminalArtifact) { + throw new Error('terminal_file_grant_unavailable') + } + const nextStat = await provider.writeTerminalArtifact( + grant.absolutePath, + content, + this.terminalArtifactAccessOptions(grant, MOBILE_FILE_READ_MAX_BYTES) + ) + grant.statIdentity = terminalFileStatIdentity(nextStat) + this.refreshTerminalFileGrant(grant) + return { ok: true } + } + + let originalMode: number | null = null + const handle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + const fileStats = await handle.stat() + originalMode = fileStats.mode + if (fileStats.isDirectory()) { + throw new Error('Cannot write to a directory') + } + if (fileStats.size > MOBILE_FILE_READ_MAX_BYTES) { + throw new Error('file_too_large') + } + assertTerminalFileGrantFresh(grant, fileStats) + if ( + isBinaryBuffer(await readFileHandleBufferBounded(handle, MOBILE_FILE_READ_MAX_BYTES + 1)) + ) { + throw new Error('binary_file') + } + } finally { + await handle.close() + } + const tempPath = join( + dirname(grant.absolutePath), + `.${basename(grant.absolutePath)}.${randomUUID()}.tmp` + ) + try { + await writeFile(tempPath, content, { encoding: 'utf-8', flag: 'wx' }) + if (typeof originalMode === 'number') { + await chmod(tempPath, originalMode & 0o7777) + } + const freshHandle = await openLocalTerminalArtifactGrant(grant, constants.O_RDONLY) + try { + assertTerminalFileGrantFresh(grant, await freshHandle.stat()) + } finally { + await freshHandle.close() + } + await rename(tempPath, grant.absolutePath) + grant.statIdentity = terminalFileStatIdentity( + await this.statLocalTerminalPath(grant.absolutePath) + ) + this.refreshTerminalFileGrant(grant) + return { ok: true } + } finally { + await rm(tempPath, { force: true }).catch(() => {}) + } + } + + protected async readRemoteTerminalArtifactPreview( + provider: IFilesystemProvider, + grant: TerminalFileGrant, + maxContentBytes: number | undefined + ): Promise { + const binaryMaxBytes = + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + const preview = await this.readRemoteTerminalArtifact(provider, grant, binaryMaxBytes) + if ( + !preview.isBinary && + Buffer.byteLength(preview.content, 'utf8') > MOBILE_FILE_READ_MAX_BYTES + ) { + throw new Error('file_too_large') + } + if ( + preview.isBinary && + maxContentBytes !== undefined && + Buffer.byteLength(preview.content, 'utf8') > maxContentBytes + ) { + throw new Error('file_too_large') + } + return preview + } + + protected async readRemoteTerminalArtifactFile( + provider: IFilesystemProvider, + grant: TerminalFileGrant, + maxBytes: number + ): Promise { + const result = await this.readRemoteTerminalArtifact(provider, grant, maxBytes) + if (result.isBinary) { + throw new Error('binary_file') + } + return result.content + } + + protected async readRemoteTerminalArtifact( + provider: IFilesystemProvider, + grant: TerminalFileGrant, + maxBytes: number + ): Promise { + if (!provider.readTerminalArtifact) { + throw new Error('terminal_file_grant_unavailable') + } + return provider.readTerminalArtifact( + grant.absolutePath, + this.terminalArtifactAccessOptions(grant, maxBytes) + ) + } + + protected terminalArtifactAccessOptions( + grant: TerminalFileGrant, + maxBytes: number + ): { expectedRealPath: string; expectedStatIdentity: string | null; maxBytes: number } { + return { + expectedRealPath: grant.absolutePath, + expectedStatIdentity: grant.statIdentity, + maxBytes + } + } + + protected async assertRemoteTerminalFileGrantFreshForRead( + grant: TerminalFileGrant + ): Promise { + const { provider } = await this.assertRemoteTerminalFileGrantFresh(grant) + return provider + } + + protected async assertRemoteTerminalFileGrantFresh( + grant: TerminalFileGrant + ): Promise<{ provider: IFilesystemProvider; fileStat: FileStat }> { + const provider = await this.assertRemoteTerminalFileGrantPathStillCanonical(grant) + const fileStat = await provider.stat(grant.absolutePath) + assertTerminalFileGrantFresh(grant, fileStat) + return { provider, fileStat } + } +} diff --git a/src/main/runtime/runtime-file-path-names.ts b/src/main/runtime/runtime-file-path-names.ts new file mode 100644 index 00000000000..b8a2b29abd0 --- /dev/null +++ b/src/main/runtime/runtime-file-path-names.ts @@ -0,0 +1,5 @@ +/** Returns the final path component without assuming a host path separator. */ +export function basenameFromRelativePath(relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/') + return normalized.slice(normalized.lastIndexOf('/') + 1) +} diff --git a/src/main/startup/branch-rename-hook.ts b/src/main/startup/branch-rename-hook.ts new file mode 100644 index 00000000000..120115aed51 --- /dev/null +++ b/src/main/startup/branch-rename-hook.ts @@ -0,0 +1,125 @@ +import { existsSync } from 'node:fs' +import { parseWorkspaceKey } from '../../shared/workspace-scope' +import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { maybeAutoRenameBranchOnFirstWork } from '../agent-hooks/first-work-branch-rename' +import { rememberBranchRenameFailureOutput } from '../agent-hooks/branch-rename-failure-output' +import { renameWorktreeFolderOnFirstWork } from '../agent-hooks/first-work-folder-rename' +import { moveWorktree } from '../git/worktree' +import { mainProcessState as state } from './main-process-state' + +const ENABLE_FIRST_WORK_FOLDER_RENAME = false + +export function maybeAutoRenameBranchOnFirstWorkFromHook(event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined +}): void { + const store = state.store + const runtime = state.runtime + if (!store || !runtime) { + return + } + void maybeAutoRenameBranchOnFirstWork( + { + paneKey: event.paneKey, + tabId: event.tabId, + worktreeId: event.worktreeId, + state: event.payload.state, + prompt: event.payload.prompt, + assistantMessage: event.payload.lastAssistantMessage, + isReplay: event.isReplay + }, + { + getSettings: () => store.getSettings(), + getRepo: (repoId) => store.getRepo(repoId), + getAgentEnvResolvers: () => runtime.getCommitMessageAgentEnvironmentResolvers(), + getCurrentDisplayName: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.name + : store.getWorktreeMeta(worktreeId)?.displayName + }, + getFolderWorkspacePath: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.folderPath + : undefined + }, + isPendingFirstAgentMessageRename: (worktreeId) => { + const scope = parseWorkspaceKey(worktreeId) + return scope?.type === 'folder' + ? store.getFolderWorkspace(scope.folderWorkspaceId)?.pendingFirstAgentMessageRename === + true + : store.getWorktreeMeta(worktreeId)?.pendingFirstAgentMessageRename === true + }, + canRenameOrcaCreatedBranch: (worktreeId) => { + const meta = store.getWorktreeMeta(worktreeId) + return !!meta?.orcaCreationSource && meta.preserveBranchOnDelete !== true + }, + setDisplayName: (worktreeId, displayName) => { + rememberBranchRenameFailureOutput(worktreeId, null) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + store.updateFolderWorkspace(scope.folderWorkspaceId, { + name: displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + runtime.notifyFolderWorkspaceChanged() + return + } + store.setWorktreeMeta(worktreeId, { + displayName, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + }) + }, + renameWorktreeFolder: ENABLE_FIRST_WORK_FOLDER_RENAME + ? (worktreeId, newLeaf) => + renameWorktreeFolderOnFirstWork(worktreeId, newLeaf, { + getRepo: (repoId) => store.getRepo(repoId), + getSettings: () => store.getSettings(), + migrateWorktreeIdentity: (oldId, newId) => + store.migrateWorktreeIdentity(oldId, newId), + notifyWorktreeRenamed: (repoId, oldId, newId) => + runtime.notifyWorktreeFolderRenamed(repoId, oldId, newId), + pathExists: async (candidate) => existsSync(candidate), + moveWorktree + }) + : undefined, + setRenameError: (worktreeId, error, failureOutput) => { + rememberBranchRenameFailureOutput(worktreeId, error === null ? null : failureOutput) + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const current = store.getFolderWorkspace( + scope.folderWorkspaceId + )?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.updateFolderWorkspace(scope.folderWorkspaceId, { + firstAgentMessageRenameError: error + }) + runtime.notifyFolderWorkspaceChanged() + return + } + const current = store.getWorktreeMeta(worktreeId)?.firstAgentMessageRenameError + if ((current ?? null) === (error ?? null)) { + return + } + store.setWorktreeMeta(worktreeId, { firstAgentMessageRenameError: error }) + runtime.notifyBranchRenamed(getRepoIdFromWorktreeId(worktreeId)) + }, + resolveWorktreeIdForTab: (tabId) => store.getWorktreeIdForTab(tabId), + onRenamed: (repoIdOrWorktreeId) => { + if (parseWorkspaceKey(repoIdOrWorktreeId)?.type === 'folder') { + runtime.notifyFolderWorkspaceChanged() + return + } + runtime.notifyBranchRenamed(repoIdOrWorktreeId) + } + } + ) +} diff --git a/src/main/startup/codex-launch-preparation.ts b/src/main/startup/codex-launch-preparation.ts new file mode 100644 index 00000000000..37624c8c6d0 --- /dev/null +++ b/src/main/startup/codex-launch-preparation.ts @@ -0,0 +1,80 @@ +import { app } from 'electron' +import type { CodexHomeLaunchContext } from '../ipc/pty' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { codexHookService } from '../codex/hook-service' +import { getDefaultWslDistro } from '../wsl' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexRuntimeHomeForLaunch( + target?: CodexAccountSelectionTarget, + launchEnv?: NodeJS.ProcessEnv, + launchContext?: CodexHomeLaunchContext +): Promise { + const runtimeHome = state.codexRuntimeHome + if (!runtimeHome) { + throw new Error('Codex runtime home service is not initialized') + } + if ( + target?.runtime !== 'wsl' && + launchContext?.launchAgent === 'codex' && + launchContext.workspacePath + ) { + try { + await markCodexProjectTrusted(launchContext.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark launch workspace:', error) + } + } + const ensureRealHomeHooksIfSelected = async (): Promise => { + if (target?.runtime === 'wsl' || !runtimeHome.isHostSystemDefaultRealHomeSelected(launchEnv)) { + return false + } + await ensureRealHomeCodexHookState({ + hooksEnabled: isAgentStatusHooksEnabled(state.store?.getSettings()), + userDataPath: app.getPath('userData') + }) + return true + } + let realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + let runtimeHomePath = runtimeHome.prepareForCodexLaunch(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + if (runtimeHomePath === null && !realHomeHooksPrepared) { + realHomeHooksPrepared = await ensureRealHomeHooksIfSelected() + if (realHomeHooksPrepared) { + runtimeHomePath = runtimeHome.prepareForCodexLaunch(target, launchEnv, { + unavailableManagedHomePath: launchContext?.unavailableManagedHomePath + }) + } + } + if (runtimeHomePath === null && target?.runtime !== 'wsl') { + return null + } + const hookTarget = + target?.runtime === 'wsl' + ? { runtime: 'wsl' as const, wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() } + : target + const hooksEnabled = isAgentStatusHooksEnabled(state.store?.getSettings()) + try { + const status = hooksEnabled + ? ((await codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget)) ?? + (await codexHookService.install(runtimeHomePath ?? undefined))) + : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? + (await codexHookService.refreshRuntimeUserHooks(runtimeHomePath ?? undefined))) + if (status.state === 'error') { + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + status.detail + ) + } + } catch (error) { + console.warn( + `[codex-hook-service] failed to ${hooksEnabled ? 'refresh' : 'refresh user'} runtime hooks before launch`, + error + ) + } + return runtimeHomePath +} diff --git a/src/main/startup/codex-session-resume-launch.ts b/src/main/startup/codex-session-resume-launch.ts new file mode 100644 index 00000000000..510295a5068 --- /dev/null +++ b/src/main/startup/codex-session-resume-launch.ts @@ -0,0 +1,98 @@ +import { app } from 'electron' +import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume' +import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import type { CodexSessionResumePreparation } from '../codex/codex-session-resume-home' +import { prepareCodexSessionResume } from '../codex/codex-session-resume-preparation' +import { prepareLegacySharedCodexSessionResume } from '../codex/codex-legacy-session-resume' +import { ManagedCodexHomeTemporarilyUnavailableError } from '../codex-accounts/host-codex-managed-home-ownership' +import { codexHookService } from '../codex/hook-service' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { markCodexProjectTrusted } from '../agent-trust-presets' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' +import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' +import { mainProcessState as state } from './main-process-state' + +export async function prepareCodexSessionResumeForLaunch(args: { + providerSession: AgentProviderSessionMetadata + target: CodexAccountSelectionTarget + launchEnv?: NodeJS.ProcessEnv + workspacePath?: string +}): Promise { + const runtimeHome = state.codexRuntimeHome + const store = state.store + if (args.target.runtime === 'wsl' || !runtimeHome || !store) { + return null + } + const systemHomePath = getSystemCodexHomePath() + const trustedHomes = [systemHomePath, ...runtimeHome.getHostCodexHomePathsForSessionDiscovery()] + const selectedAccountCodexHome = runtimeHome.resolveSelectedHostAccountCodexHomePathForResume() + const preparation = await prepareCodexSessionResume({ + sessionId: args.providerSession.id, + transcriptPath: args.providerSession.transcriptPath, + trustedCodexHomes: trustedHomes, + getSelectedAccountCodexHome: () => selectedAccountCodexHome, + systemCodexHomePath: systemHomePath, + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath(), + resolveVerifiedResumeHome: async (sessionSource) => { + let migrated = { useRealCodexHome: false } + try { + migrated = await prepareLegacySharedCodexSessionResume( + { + agent: 'codex', + executionHostId: 'local', + filePath: sessionSource.transcriptPath, + codexHome: sessionSource.homePath + }, + { + isHostSystemDefaultRealHome: () => runtimeHome.isHostSystemDefaultRealHome(), + systemCodexHomePath: systemHomePath + } + ) + } catch (error) { + if (error instanceof ManagedCodexHomeTemporarilyUnavailableError) { + throw error + } + console.warn( + '[codex-session-resume] Legacy rollout migration failed; using origin home:', + error + ) + } + const resumeHome = migrated.useRealCodexHome ? systemHomePath : sessionSource.homePath + if (args.workspacePath) { + try { + await markCodexProjectTrusted(args.workspacePath) + } catch (error) { + console.warn('[codex-project-trust] failed to pre-mark resumed workspace:', error) + } + } + const isSystemHome = + normalizeRuntimePathForComparison(resumeHome) === + normalizeRuntimePathForComparison(systemHomePath) + const hooksEnabled = isAgentStatusHooksEnabled(store.getSettings()) + try { + if (isSystemHome) { + await ensureRealHomeCodexHookState({ + hooksEnabled, + userDataPath: app.getPath('userData') + }) + } else if (hooksEnabled) { + await codexHookService.install(resumeHome) + } else { + await codexHookService.refreshRuntimeUserHooks(resumeHome) + } + } catch (error) { + console.warn('[codex-hook-service] failed to prepare automatic resume home:', error) + } + return resumeHome + } + }) + return preparation.outcome === 'resume' + ? { + ...preparation, + reconcileSharedRuntimeAuth: + normalizeRuntimePathForComparison(preparation.codexHomePath) === + normalizeRuntimePathForComparison(getOrcaManagedCodexHomePath()) + } + : preparation +} diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 232626ca9f5..4747f797cf9 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -775,9 +775,9 @@ describe('safe graphics mode startup switches', () => { // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. - it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {') + it('calls the throttling opt-out outside the GPU-fallback gate in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const gateStart = mainSource.indexOf('if (!state.gpuFallbackActiveThisLaunch) {') expect(gateStart).toBeGreaterThanOrEqual(0) const gateEnd = mainSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) @@ -789,14 +789,20 @@ describe('safe graphics mode startup switches', () => { // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready // top-level block and never move into the whenReady callback, where appendSwitch is a silent // no-op — the same invisible failure as parking it behind the GPU gate. - it('appends the throttling opt-out before app ready in index.ts', () => { - const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') - const readyStart = mainSource.indexOf('void app.whenReady()') + it('appends the throttling opt-out before app ready in preflight', () => { + const mainSource = readFileSync(join(__dirname, 'main-process-preflight.ts'), 'utf8') + const entrySource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const preflightEnd = mainSource.indexOf('\n return true') + const readyStart = entrySource.indexOf('void app.whenReady()') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightEnd).toBeGreaterThan(0) expect(readyStart).toBeGreaterThan(0) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(preflightCall).toBeLessThan(readyStart) const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') expect(callIndex).toBeGreaterThan(0) - expect(callIndex).toBeLessThan(readyStart) + expect(callIndex).toBeLessThan(preflightEnd) }) // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index b80d7adee41..adccfc9b18b 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -4,17 +4,35 @@ import { describe, expect, it } from 'vitest' describe('startup ordering', () => { it('passes the startup barrier into PTY handlers without blocking window creation', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachStart = source.indexOf('attachMainWindowServices(') - const attachEnd = source.indexOf('rateLimits.attach(window)', attachStart) - const attachBlock = source.slice(attachStart, attachEnd) + const attachSource = readFileSync( + join(process.cwd(), 'src/main/window/attach-main-window-services.ts'), + 'utf8' + ) + const startupSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const runtimeLaunchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const attachStart = attachSource.indexOf('export function attachMainWindowServices(') + const attachEnd = attachSource.indexOf(' registerSshHandlers(', attachStart) + const attachBlock = attachSource.slice(attachStart, attachEnd) // Why: anchor on the destructure head only — the settled-result variable's name is not the // contract, and pinning it turns a rename into a cryptic `expected -1` failure here. - const desktopStart = source.indexOf('const [win') + const desktopStart = runtimeLaunchSource.indexOf('async function launchDesktopMode(') // Why: anchor on code, not a comment — the previous comment anchor was silently reworded, so // this was -1 and sliced to EOF, letting the assertions below pass against never-run code. - const desktopEnd = source.indexOf("win.once('show'", desktopStart) - const desktopStartup = source.slice(desktopStart, desktopEnd) + const desktopEnd = runtimeLaunchSource.indexOf( + '\nexport async function initializeMainProcessRuntimeLaunch', + desktopStart + ) + const desktopStartup = runtimeLaunchSource.slice(desktopStart, desktopEnd) // Why: bound every anchor, not just the desktop pair — an unresolved one slices to EOF. expect(attachStart).toBeGreaterThanOrEqual(0) @@ -22,14 +40,20 @@ describe('startup ordering', () => { expect(desktopStart).toBeGreaterThanOrEqual(0) expect(desktopEnd).toBeGreaterThan(desktopStart) - expect(attachBlock).toContain('awaitLocalPtyStartup: () => localPtyStartupReady') - expect(attachBlock).toContain( - 'awaitLocalPtyProviderStartup: () => localPtyProviderStartupReady' + expect(coreSource).toContain('awaitLocalPtyStartup: () => state.localPtyStartupReady') + expect(coreSource).toContain( + 'awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady' ) - expect(source).toContain( + expect(attachBlock).toContain('awaitLocalPtyStartup: options?.awaitLocalPtyStartup') + expect(attachBlock).toContain( + 'awaitLocalPtyProviderStartup: options?.awaitLocalPtyProviderStartup' + ) + expect(startupSource).toContain( 'firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady)' ) - expect(source).toContain('localPtyStartupReady = services.then((value) => value.localPtyReady)') + expect(startupSource).toContain( + 'localPtyStartupReady = services.then((value) => value.localPtyReady)' + ) const windowIndex = desktopStartup.indexOf('Promise.resolve(desktopWindow ?? openMainWindow())') const rpcStartIndex = desktopStartup.indexOf('desktopRuntimeRpc.start()') @@ -50,25 +74,43 @@ describe('startup ordering', () => { }) it('resolves the browser hosting identity with nothing awaited before it', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyIndex = source.indexOf('app.whenReady().then(') - const initIndex = source.indexOf('initializeBrowserClientHostId(') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const readyIndex = entrySource.indexOf('void app.whenReady().then(async () => {') + const initReadyIndex = entrySource.indexOf('initializeMainProcessReady({') + const profileIndex = foundationSource.indexOf('const profile = ensureActiveOrcaProfile()') + const initIndex = foundationSource.indexOf( + 'initializeBrowserClientHostId(profile.profileDirectory)' + ) expect(readyIndex).toBeGreaterThanOrEqual(0) - expect(initIndex).toBeGreaterThan(readyIndex) + expect(initReadyIndex).toBeGreaterThan(readyIndex) + expect(profileIndex).toBeGreaterThanOrEqual(0) + expect(initIndex).toBeGreaterThan(profileIndex) // Why nothing may be awaited first: the identity is stamped into the renderer's argv when the // window is created, and a suspension here lets a window be created against a process-local // stand-in that the durable id then contradicts. The constraint is positional, so only a source // census can hold it — no behavioural test distinguishes "resolved" from "resolved in time". - expect(source.slice(readyIndex, initIndex)).not.toMatch(/\bawait\b/) + expect(foundationSource.slice(profileIndex, initIndex)).not.toMatch(/\bawait\b/) // Why the count: a second call site would leave the ordering claim above ambiguous. - expect(source.split('initializeBrowserClientHostId(')).toHaveLength(2) + expect(foundationSource.split('initializeBrowserClientHostId(')).toHaveLength(2) }) it('requires daemon authority before restored-subagent liveness runs', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const sweepStart = source.indexOf('function reapRestoredSubagentsWithoutLiveAgent()') - const sweepEnd = source.indexOf('function startTerminalRuntimeStartupServices()', sweepStart) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const sweepStart = source.indexOf( + 'export async function reapRestoredSubagentsWithoutLiveAgent()' + ) + const sweepEnd = source.indexOf( + 'export function startTerminalRuntimeStartupServices()', + sweepStart + ) const sweep = source.slice(sweepStart, sweepEnd) expect(sweepStart).toBeGreaterThanOrEqual(0) @@ -79,47 +121,53 @@ describe('startup ordering', () => { }) it('bounds WSL reconciliation before serve RPC while leaving desktop startup independent', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const barrierStart = source.indexOf("ipcMain.handle('app:awaitFirstWindowStartupServices'") - const barrierEnd = source.indexOf("'app:startupDiagnostic'", barrierStart) - const barrier = source.slice(barrierStart, barrierEnd) - const reconciliationStart = source.indexOf( - 'managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' ) - const serveStart = source.indexOf('if (serveOptions) {', reconciliationStart) - const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveEnd = source.indexOf('return', serveReady) - const desktopWindowStart = source.indexOf( - 'const desktopStartup = startWindowsDesktopBeforeShellPathReady(' + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' ) - const desktopWindowJoin = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveEnd + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' ) - const serveStartup = source.slice(serveStart, serveEnd) - const desktopStartup = source.slice(reconciliationStart, serveStart) + const barrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitFirstWindowStartupServices'" + ) + const barrierEnd = barrierSource.indexOf("'app:startupDiagnostic'", barrierStart) + const barrier = barrierSource.slice(barrierStart, barrierEnd) + const reconciliationStart = foundationSource.indexOf( + 'state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations(' + ) + const serveStart = runtimeSource.indexOf('async function launchServeMode(') + const serveEnd = runtimeSource.indexOf('\nasync function launchDesktopMode', serveStart) + const serveStartup = runtimeSource.slice(serveStart, serveEnd) + const desktopStart = runtimeSource.indexOf( + " if (process.platform === 'win32' && app.isPackaged && !serveOptions)" + ) + const desktopEnd = runtimeSource.indexOf(" app.on('activate'", desktopStart) + const desktopStartup = runtimeSource.slice(desktopStart, desktopEnd) expect(barrierStart).toBeGreaterThanOrEqual(0) expect(barrierEnd).toBeGreaterThan(barrierStart) expect(reconciliationStart).toBeGreaterThanOrEqual(0) - expect(serveStart).toBeGreaterThan(reconciliationStart) + expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveEnd).toBeGreaterThan(serveStart) - // Why: bound against serveEnd, not reconciliationStart — an earlier openMainWindow() call - // would steal this anchor, collapse desktopStartup to '', and pass the negative check below. - expect(desktopWindowStart).toBeGreaterThan(reconciliationStart) - expect(desktopWindowStart).toBeLessThan(serveStart) - expect(desktopWindowJoin).toBeGreaterThan(serveEnd) - expect(serveStartup).toContain('await managedWslCliStartupBarrierReady') - expect(serveStartup).not.toContain('await managedWslCliReconciliationReady') - expect(serveStartup.indexOf('await managedWslCliStartupBarrierReady')).toBeLessThan( + expect(desktopStart).toBeGreaterThanOrEqual(0) + expect(desktopEnd).toBeGreaterThan(desktopStart) + expect(serveStartup).toContain('await state.managedWslCliStartupBarrierReady') + expect(serveStartup).not.toContain('await state.managedWslCliReconciliationReady') + expect(serveStartup.indexOf('await state.managedWslCliStartupBarrierReady')).toBeLessThan( serveStartup.indexOf('await runtimeRpc.start()') ) - expect(desktopStartup).not.toContain('await managedWslCliReconciliationReady') + expect(desktopStartup).not.toContain('await state.managedWslCliReconciliationReady') expect(desktopStartup).toContain( "process.platform === 'win32' && app.isPackaged && !serveOptions" ) expect(desktopStartup).toContain( - 'openWindow: () => openMainWindow({ revealOnDidFinishLoad: true })' + 'openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true })' ) expect(desktopStartup).toContain('bindServices: bindTerminalRuntimeStartupServices') expect(desktopStartup).toContain('shellPathReady,') @@ -129,31 +177,32 @@ describe('startup ordering', () => { expect(barrier).toContain("ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup'") expect(barrier).toContain('recoverLegacyWorkerTerminalsForRendererStartup({') expect(barrier).toContain('localPtyProviderStartupReady,') - expect(barrier).toContain('await runtime?.refreshRestoredOrchestrationAuthority()') + expect(barrier).toContain('await state.runtime?.refreshRestoredOrchestrationAuthority()') expect(barrier).toContain( - 'return runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' + 'return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true })' ) }) it('reconciles retained Codex homes after authoritative daemon inventory', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const daemonInitIndex = source.indexOf('await initDaemonPtyProvider(signal') - const retainedPaneGateIndex = source.indexOf( + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), + 'utf8' + ) + const startupStart = source.indexOf('export function startTerminalRuntimeStartupServices()') + expect(startupStart).toBeGreaterThanOrEqual(0) + const startup = source.slice(startupStart) + const daemonInitIndex = startup.indexOf('await initDaemonPtyProvider(signal') + const retainedPaneGateIndex = startup.indexOf( 'hasRecordedManagedHostCodexPane()', daemonInitIndex ) - const inventoryIndex = source.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) - const reconciliation = 'codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' - const reconciliationIndex = source.indexOf(reconciliation, inventoryIndex) - const hookReconciliationIndex = source.indexOf( + const inventoryIndex = startup.indexOf('await listLiveDaemonPtyIds()', daemonInitIndex) + const reconciliation = 'state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes()' + const reconciliationIndex = startup.indexOf(reconciliation, inventoryIndex) + const hookReconciliationIndex = startup.indexOf( 'reconcileRetainedCodexHookHomes({', inventoryIndex ) - const serveIndex = source.indexOf('if (serveOptions) {', reconciliationIndex) - const desktopIndex = source.indexOf( - 'Promise.resolve(desktopWindow ?? openMainWindow())', - serveIndex - ) expect(daemonInitIndex).toBeGreaterThanOrEqual(0) expect(retainedPaneGateIndex).toBeGreaterThan(daemonInitIndex) @@ -162,35 +211,51 @@ describe('startup ordering', () => { expect(hookReconciliationIndex).toBeGreaterThan(inventoryIndex) expect(hookReconciliationIndex).toBeLessThan(reconciliationIndex) expect(reconciliationIndex).toBeGreaterThan(inventoryIndex) - expect(serveIndex).toBeGreaterThan(reconciliationIndex) - expect(desktopIndex).toBeGreaterThan(serveIndex) - expect(source.split(reconciliation)).toHaveLength(2) + // The call is intentionally kept after the authoritative inventory; anchoring on the state + // receiver avoids matching any prose that mentions the same operation. + expect(startup).toContain(reconciliation) }) it('exposes managed WSL reconciliation status to headless serve clients and diagnostics', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const serveSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const foundationSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) // Why: the barrier fails open, so the serve-ready payload must carry the // reconciliation state and the bounded wait must be traceable via a milestone. - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') - const readyEnd = source.indexOf('pairing: pairing.available', readyStart) - const readyPayload = source.slice(readyStart, readyEnd) + const readyStart = serveSource.indexOf('await state.serveReadinessPublisher.publish(') + const readyEnd = serveSource.indexOf('pairing: pairing.available', readyStart) + const readyPayload = serveSource.slice(readyStart, readyEnd) // Why: unbounded, a renamed pairing key slices to EOF and the status only has to survive // somewhere later in the file — not in the serve-ready payload this test is about. expect(readyStart).toBeGreaterThanOrEqual(0) expect(readyEnd).toBeGreaterThan(readyStart) - expect(readyPayload).toContain('managedWslCliReconciliation: managedWslCliReconciliationStatus') + expect(readyPayload).toContain( + 'managedWslCliReconciliation: state.managedWslCliReconciliationStatus' + ) - expect(source).toContain("managedWslCliReconciliationStatus = 'pending'") - expect(source).toContain("managedWslCliReconciliationStatus = 'settled'") - expect(source).toContain("managedWslCliReconciliationStatus = 'failed'") - expect(source).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'pending'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'settled'") + expect(foundationSource).toContain("state.managedWslCliReconciliationStatus = 'failed'") + expect(runtimeSource).toContain("logStartupMilestone('wsl-cli-barrier-resolved'") }) it('notifies the serve supervisor only after publishing readiness', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const readyStart = source.indexOf('await serveReadinessPublisher.publish(') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-serve.ts'), + 'utf8' + ) + const readyStart = source.indexOf('await state.serveReadinessPublisher.publish(') const supervisorReady = source.indexOf('notifyServeSupervisorReady(', readyStart) expect(readyStart).toBeGreaterThanOrEqual(0) @@ -198,7 +263,10 @@ describe('startup ordering', () => { }) it('does not run the rate-limit quota fetch before the first window can show results', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) const attachIndex = source.indexOf('rateLimits.attach(window)') const startIndex = source.indexOf('rateLimits.start({ fetchImmediately: false })') @@ -207,60 +275,67 @@ describe('startup ordering', () => { }) it('wires bounded teardown state to reporting but not recovery or close behavior', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const scopeStart = source.indexOf('function getExpectedTeardownScope(') - const scopeEnd = source.indexOf('function markRecoveryReloadInFlight(', scopeStart) - const scope = source.slice(scopeStart, scopeEnd) - const windowStart = source.indexOf('const window = createMainWindow(store, {') - const windowEnd = source.indexOf('onRendererRecoveryExhausted:', windowStart) - const windowOptions = source.slice(windowStart, windowEnd) - const recorderStart = source.indexOf('function recordProcessGoneCrash(') - const recorderEnd = source.indexOf('function shutdownWatchersOnce(', recorderStart) - const recorder = source.slice(recorderStart, recorderEnd) + const lifecycleSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-lifecycle-flags.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) - expect(scopeStart).toBeGreaterThanOrEqual(0) - expect(scopeEnd).toBeGreaterThan(scopeStart) - expect(scope).toContain('resolveExpectedTeardownScope({') - expect(scope).toContain('includeSystemSessionEnd') - expect(windowStart).toBeGreaterThanOrEqual(0) - expect(windowEnd).toBeGreaterThan(windowStart) - expect(windowOptions).toContain('getIsQuitting: () => isQuitting') - expect(windowOptions).toContain( + expect(lifecycleSource).toContain('export function getExpectedTeardownScope(') + expect(lifecycleSource).toContain('resolveExpectedTeardownScope({') + expect(lifecycleSource).toContain('includeSystemSessionEnd') + expect(windowSource).toContain('const window = createMainWindow(store, {') + expect(windowSource).toContain('getIsQuitting: () => state.isQuitting') + expect(windowSource).toContain( 'expectedTeardown: getExpectedTeardownScope(webContentsId, false)' ) - expect(recorderStart).toBeGreaterThanOrEqual(0) - expect(recorderEnd).toBeGreaterThan(recorderStart) - expect(recorder).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') + expect(lifecycleSource).toContain('expectedTeardown: getExpectedTeardownScope(webContentsId)') }) it('attaches renderer services before starting the TCC prompt watcher', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const attachIndex = source.indexOf('attachMainWindowServices(') - const tccNoticeIndex = source.indexOf('initTccPromptNotice(window', attachIndex) - const quitAbortStart = source.indexOf('onQuitAborted:') - const quitAbortEnd = source.indexOf('onRendererProcessGone:', quitAbortStart) + const coreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-controller.ts'), + 'utf8' + ) + const quitSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) + const attachIndex = coreSource.indexOf('attachMainWindowServices(') + const tccNoticeIndex = coreSource.indexOf('initTccPromptNotice(window', attachIndex) + const quitAbortStart = windowSource.indexOf('onQuitAborted:') + const quitAbortEnd = windowSource.indexOf('onRendererProcessGone:', quitAbortStart) expect(attachIndex).toBeGreaterThanOrEqual(0) expect(tccNoticeIndex).toBeGreaterThan(attachIndex) - expect(source.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( + expect(coreSource.slice(tccNoticeIndex, tccNoticeIndex + 120)).toContain( 'deferWatchUntilReadyToShow: true' ) - expect(source.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') - expect(source).toContain("process.once('exit', stopTccPromptNotice)") - const willQuitStart = source.indexOf("app.on('will-quit'") - const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) - expect(source.slice(willQuitStart, windowAllClosedStart)).toContain('stopTccPromptNotice()') - expect(source.slice(0, willQuitStart)).not.toContain('stopTccPromptNoticeForQuit') + expect(windowSource.slice(quitAbortStart, quitAbortEnd)).not.toContain('initTccPromptNotice') + expect(quitSource).toContain("process.once('exit', stopTccPromptNotice)") + const willQuitStart = quitSource.indexOf("app.on('will-quit'") + expect(quitSource.slice(willQuitStart)).toContain('stopTccPromptNotice()') + expect(quitSource).not.toContain('stopTccPromptNoticeForQuit') }) it('keeps the power bridge through vetoable before-quit and disposes after commit', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const beforeQuitStart = source.indexOf("app.on('before-quit'") const willQuitStart = source.indexOf("app.on('will-quit'", beforeQuitStart) const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const beforeQuit = source.slice(beforeQuitStart, willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) - const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(e)') + const commitIndex = willQuit.indexOf('quitTeardownStartGate.tryStart(event)') const disposeIndex = willQuit.indexOf('unsubscribeSystemResumeBroadcast?.()') expect(beforeQuitStart).toBeGreaterThanOrEqual(0) @@ -272,7 +347,10 @@ describe('startup ordering', () => { }) it('joins structured agent sessions to the committed quit barrier', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, willQuitEnd) @@ -286,7 +364,10 @@ describe('startup ordering', () => { }) it('joins agent-browser cleanup before the committed quit exits', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-quit.ts'), + 'utf8' + ) const willQuitStart = source.indexOf("app.on('will-quit'") const windowAllClosedStart = source.indexOf("app.on('window-all-closed'", willQuitStart) const willQuit = source.slice(willQuitStart, windowAllClosedStart) @@ -309,8 +390,11 @@ describe('startup ordering', () => { }) it('registers repeatable serve signal handling before headless startup completes', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const signalHandlers = source.indexOf('registerServeSignalHandlers(process', serveStart) const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) @@ -320,22 +404,34 @@ describe('startup ordering', () => { }) it('starts the automation scheduler before headless serve reports ready', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serveStart = source.indexOf('if (serveOptions) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const windowSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) + const serveStart = source.indexOf('async function launchServeMode(') const serveReady = source.indexOf('await printServeReady(serveOptions)', serveStart) - const serveReturn = source.indexOf('return', serveReady) + const serveEnd = source.indexOf('\nasync function launchDesktopMode', serveStart) const runtimeRpcStart = source.indexOf('await runtimeRpc.start()', serveStart) - const automationStart = source.indexOf('automations.start()', serveStart) - const desktopSetWebContents = source.indexOf('automations.setWebContents(window.webContents)') - const desktopAutomationStart = source.indexOf('automations.start()', desktopSetWebContents + 1) + const automationStart = source.indexOf('state.automations?.start()', serveStart) + const desktopSetWebContents = windowSource.indexOf( + 'automations.setWebContents(window.webContents)' + ) + const desktopAutomationStart = windowSource.indexOf( + 'automations.start()', + desktopSetWebContents + 1 + ) expect(serveStart).toBeGreaterThanOrEqual(0) expect(serveReady).toBeGreaterThan(serveStart) - expect(serveReturn).toBeGreaterThan(serveReady) + expect(serveEnd).toBeGreaterThan(serveReady) expect(runtimeRpcStart).toBeGreaterThan(serveStart) expect(automationStart).toBeGreaterThan(runtimeRpcStart) expect(automationStart).toBeLessThan(serveReady) - expect(automationStart).toBeLessThan(serveReturn) + expect(automationStart).toBeLessThan(serveEnd) expect(desktopSetWebContents).toBeGreaterThanOrEqual(0) expect(desktopAutomationStart).toBeGreaterThan(desktopSetWebContents) }) diff --git a/src/main/startup/gpu-lifecycle.ts b/src/main/startup/gpu-lifecycle.ts new file mode 100644 index 00000000000..45f825ba141 --- /dev/null +++ b/src/main/startup/gpu-lifecycle.ts @@ -0,0 +1,177 @@ +import { app, type BrowserWindow } from 'electron' +import { relaunchApp } from '../app-relaunch' +import { destroySystemTray } from '../tray/system-tray' +import { applyGpuFallbackCommandLineSwitches } from './gpu-fallback-switches' +import { + clearGpuFallbackMarker, + readActiveGpuFallbackMarker, + writeGpuFallbackMarker, + type WindowsGpuFallbackEnvironment +} from './gpu-fallback-marker' +import { + handleGpuFallbackRecoveredLaunch, + promptForGpuFallbackRecoveredLaunch +} from '../crash-reporting/gpu-fallback-recovered-launch' +import { promptForGpuFallbackRestart } from '../crash-reporting/gpu-fallback-restart-prompt' +import { engageGpuFallbackAfterCrashBurst } from '../crash-reporting/gpu-fallback-engagement' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { mainProcessState as state, gpuFallbackEnvironment } from './main-process-state' +import { createGpuAccelerationAboutPanelOptions } from '../menu/gpu-acceleration-about-panel' + +export function updateGpuAccelerationAboutPanel(): void { + app.setAboutPanelOptions( + createGpuAccelerationAboutPanelOptions({ + appName: app.name, + appVersion: app.getVersion(), + platform: process.platform, + gpuFallbackActive: state.gpuFallbackActiveThisLaunch, + gpuFeatureStatus: state.gpuFeatureStatus + }) + ) +} + +function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { + const environment = gpuFallbackEnvironment() + return environment.platform === 'win32' ? { ...environment, platform: 'win32' } : null +} + +function persistGpuFallbackMarker( + userDataPath: string, + info: { engagedAt: number; crashesInWindow: number; userConfirmed: boolean } +): boolean { + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return false + } + try { + writeGpuFallbackMarker(userDataPath, info, environment) + return true + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return false + } +} + +/** Apply persisted software-rendering switches before Electron consumes its command line. */ +export function maybeApplyGpuFallbackForThisLaunch(): void { + if (state.isServeMode || process.platform !== 'win32') { + return + } + const marker = readActiveGpuFallbackMarker(app.getPath('userData'), gpuFallbackEnvironment()) + if (!marker) { + return + } + state.activeGpuFallbackMarker = marker + app.disableHardwareAcceleration() + const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + state.gpuFallbackActiveThisLaunch = true + recordCrashBreadcrumb('gpu_fallback_applied', { + crashesInWindow: marker.crashesInWindow, + switches: appliedSwitches.join(',') + }) +} + +export async function presentGpuFallbackRecoveredLaunchPrompt( + window: BrowserWindow +): Promise { + const marker = state.activeGpuFallbackMarker + if (!marker || marker.userConfirmed || window.isDestroyed() || state.isQuitting) { + return + } + state.activeGpuFallbackMarker = null + const userDataPath = app.getPath('userData') + await handleGpuFallbackRecoveredLaunch({ + isQuitting: () => state.isQuitting, + prompt: () => promptForGpuFallbackRecoveredLaunch(window), + confirmSafeGraphics: () => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userConfirmed: true + }) + }, + clearSafeGraphics: () => clearGpuFallbackMarker(userDataPath), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show recovered-launch prompt:', error), + onSafeGraphicsKept: () => + recordDurableCrashBreadcrumb('gpu_fallback_safe_graphics_kept', { + crashesInWindow: marker.crashesInWindow + }), + restartWithHardware: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', { + mode: 'hardware-retry', + crashesInWindow: marker.crashesInWindow + }) + destroySystemTray() + app.exit(0) + } + }) +} + +export async function handleGpuChildCrash( + reason: string, + exitCode: number | null, + crashedAt: number +): Promise { + if (state.gpuFallbackActiveThisLaunch || state.isQuitting || state.isServeMode) { + return + } + const result = state.gpuCrashFallbackTracker.recordGpuCrash(crashedAt) + if (!result.shouldEngageFallback) { + return + } + const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } + const userDataPath = app.getPath('userData') + await engageGpuFallbackAfterCrashBurst( + { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, + { + isQuitting: () => state.isQuitting, + onEngaged: (engagement) => + recordCrashBreadcrumb('gpu_fallback_engaged', { + reason: engagement.reason, + exitCode: engagement.exitCode, + crashesInWindow: engagement.crashesInWindow + }), + persistMarker: (engagement) => + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: false + }), + confirmMarker: (engagement) => { + persistGpuFallbackMarker(userDataPath, { + engagedAt: engagement.engagedAt, + crashesInWindow: engagement.crashesInWindow, + userConfirmed: true + }) + }, + clearMarker: () => clearGpuFallbackMarker(userDataPath), + promptForRestart: () => + promptForGpuFallbackRestart( + state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + ), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show restart prompt:', error), + onRestartDeferred: () => + recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), + restartIntoSafeGraphics: () => { + state.isQuitting = true + relaunchApp('gpu-fallback', fallbackData) + destroySystemTray() + app.exit(0) + } + } + ) +} + +export function registerGpuLifecycleHandlers(): void { + app.on('gpu-info-update', () => { + state.gpuFeatureStatus = app.getGPUFeatureStatus() + state.gpuCrashDiagnostics?.warm() + if (app.isReady()) { + updateGpuAccelerationAboutPanel() + } + }) +} diff --git a/src/main/startup/headless-pty-hydration-ordering.test.ts b/src/main/startup/headless-pty-hydration-ordering.test.ts index 2263cdbf94f..e866a5d1926 100644 --- a/src/main/startup/headless-pty-hydration-ordering.test.ts +++ b/src/main/startup/headless-pty-hydration-ordering.test.ts @@ -21,9 +21,12 @@ describe('headless PTY registry hydration ordering', () => { }) it('hydrates Electron serve after provider and handler readiness but before RPC', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const serve = source.indexOf('if (serveOptions) {') - const provider = source.indexOf('await localPtyProviderStartupReady', serve) + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const serve = source.indexOf('async function launchServeMode(') + const provider = source.indexOf('await state.localPtyProviderStartupReady', serve) const handlersAndHydration = source.indexOf('await registerHeadlessPtyRuntime(', provider) const rpc = source.indexOf('await runtimeRpc.start()', handlersAndHydration) const readiness = source.indexOf('await printServeReady(serveOptions)', rpc) diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts index cfebf6cb0a8..56bf7424441 100644 --- a/src/main/startup/host-port-bootstrap-wiring.test.ts +++ b/src/main/startup/host-port-bootstrap-wiring.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from 'vitest' * startup, so there is no seam to assert against at runtime. */ describe('host port bootstrap wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const INSTALLS = [ 'setAppEnvironment(new ElectronAppEnvironment())', @@ -37,30 +41,35 @@ describe('host port bootstrap wiring', () => { } }) - it('installs every port before the runtime, the PTY handlers, or any window exists', () => { - // Why these three: they are the first things that resolve a path, seal a credential, - // or register against an injected surface. - const firstUse = Math.min( - ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow('] - .map((marker) => source.indexOf(marker)) - .filter((index) => index >= 0) - ) - expect(firstUse).toBeGreaterThan(0) - + it('installs every port during preflight before it hands off to ready services', () => { + // Why: the ready phase creates the runtime, PTY handlers, and windows. Keeping all host-port + // installs in the preflight phase preserves process-level defaults for both desktop and serve. + const preflightStart = source.indexOf('export function runMainProcessPreflight(') + const preflightReturn = source.indexOf('\n return true', preflightStart) + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightStart).toBeGreaterThanOrEqual(0) + expect(preflightReturn).toBeGreaterThan(preflightStart) + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse) + const installIndex = source.indexOf(install) + expect(installIndex, `${install} should run in preflight`).toBeGreaterThan(preflightStart) + expect(installIndex, `${install} should run before preflight completes`).toBeLessThan( + preflightReturn + ) } }) it('installs the ports at process level, not per window', () => { // Why: installing per window registered the PTY surfaces against no-ops on the // serve path, where no window ever opens. Caught in CI by the SSH docker E2E. - const openWindow = source.indexOf('function openMainWindow(') + const readyPhase = entrySource.indexOf('void app.whenReady().then(async () => {') + const preflightCall = entrySource.indexOf('runMainProcessPreflight({') + expect(preflightCall).toBeGreaterThanOrEqual(0) + expect(readyPhase).toBeGreaterThan(preflightCall) for (const install of INSTALLS) { - expect( - source.indexOf(install, openWindow), - `${install} must not be re-installed per window` - ).toBe(-1) + expect(source.split(install).length - 1, `${install} should be owned by preflight`).toBe(1) } }) }) diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts new file mode 100644 index 00000000000..4a47d2c2ec2 --- /dev/null +++ b/src/main/startup/main-process-account-services.ts @@ -0,0 +1,151 @@ +import { app } from 'electron' +import { RateLimitService } from '../rate-limits/service' +import { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import { CodexAccountService } from '../codex-accounts/service' +import { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import { ClaudeAccountService } from '../claude-accounts/service' +import { KeybindingService } from '../keybindings/keybinding-service' +import { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import { startCodexSessionBackfillInBackground } from '../codex/codex-session-backfill' +import { startCodexSessionIndexHealInBackground } from '../codex/codex-session-index-heal' +import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery' +import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { getInitialCodexRateLimitTarget } from '../rate-limits/codex-rate-limit-target' +import { getInitialClaudeRateLimitTarget } from '../rate-limits/claude-rate-limit-target' +import { getKimiRuntimeTarget, resolveKimiHome } from '../kimi/kimi-runtime-home' +import { readMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' +import { createAccountRuntimeTargetSettingsSync } from '../rate-limits/account-runtime-target-sync' +import { normalizeCodexRuntimeSelection } from '../codex-accounts/runtime-selection' +import { normalizeClaudeRuntimeSelection } from '../claude-accounts/runtime-selection' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { setSystemCodexHomeHookSweepSuppressed } from '../codex/hook-service' +import { isRealHomeCodexHookLaneUsable } from '../codex/codex-real-home-hook-install' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { browserManager } from '../browser/browser-manager' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAccountServices(): void { + const store = state.store + if (!store || !state.claudeUsage || !state.codexUsage || !state.openCodeUsage) { + throw new Error('Usage stores must be initialized before account services') + } + state.rateLimits = new RateLimitService() + state.codexRuntimeHome = new CodexRuntimeHomeService(store) + void startCodexStateDbBackfillRecoveryInBackground(getOrcaManagedCodexHomePath()) + state.codexRuntimeHome.setRealHomeLaneGate(() => isRealHomeCodexHookLaneUsable()) + setSystemCodexHomeHookSweepSuppressed( + () => + state.codexRuntimeHome !== null && + state.codexRuntimeHome.isHostSystemDefaultRealHome() && + isAgentStatusHooksEnabled(state.store?.getSettings()) + ) + state.codexSessionMigration = createCodexSessionMigrationScheduler({ + isEligible: () => + state.codexRuntimeHome?.isHostSystemDefaultSessionMigrationEligible() === true, + isQuitting: () => state.isQuitting, + resolveSystemCodexHomePathOverride: () => + resolveHostCodexSessionSourceHome(store.getSettings()), + prepareScheduledRun: (scanDates) => + state.codexRuntimeHome?.prepareHostSystemDefaultSessionMigrationPass(scanDates), + finishScheduledRun: () => state.codexRuntimeHome?.finishHostSystemDefaultSessionMigrationPass(), + startBackfill: startCodexSessionBackfillInBackground, + startIndexHeal: startCodexSessionIndexHealInBackground + }) + state.codexAccounts = new CodexAccountService(store, state.rateLimits, state.codexRuntimeHome, { + onHostSystemDefaultSelected: state.codexSessionMigration.requestRun + }) + state.codexSessionMigration.scheduleInitialRun() + state.claudeRuntimeAuth = new ClaudeRuntimeAuthService(store) + state.claudeAccounts = new ClaudeAccountService(store, state.rateLimits, state.claudeRuntimeAuth) + state.rateLimits.setCodexHomePathResolver((target) => + state.codexRuntimeHome!.prepareForRateLimitFetch(target) + ) + state.rateLimits.setCodexFetchTarget(getInitialCodexRateLimitTarget(store.getSettings())) + state.rateLimits.setKimiHomeResolver(() => + resolveKimiHome(getKimiRuntimeTarget(store.getSettings())) + ) + state.rateLimits.setClaudeFetchTarget(getInitialClaudeRateLimitTarget(store.getSettings())) + const syncAccountRuntimeTargets = createAccountRuntimeTargetSettingsSync( + state.rateLimits, + store.getSettings() + ) + store.onSettingsChanged((updates, settings) => { + void syncAccountRuntimeTargets(updates, settings).catch((error) => + console.warn('[rate-limits] Failed to apply account runtime target:', error) + ) + }) + state.rateLimits.setClaudeAuthPreparationResolver((target) => + state.claudeRuntimeAuth!.prepareForRateLimitFetch(target) + ) + // Live Claude sessions publish quota windows through their status-line hook; + // consume those snapshots before falling back to OAuth polling. + agentHookServer.setClaudeStatusLineListener((event) => { + state.rateLimits!.ingestLiveClaudeRateLimits(event) + }) + state.rateLimits.setOpenCodeGoConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: settings.opencodeSessionCookie, + workspaceIdOverride: settings.opencodeWorkspaceId + } + }) + state.rateLimits.setMiniMaxConfigResolver(() => { + const settings = store.getSettings() + return { + sessionCookie: readMiniMaxSessionCookie() ?? '', + groupId: settings.minimaxGroupId, + models: settings.minimaxUsageModels + } + }) + state.rateLimits.setGeminiCliOAuthEnabledResolver(() => store.getSettings().geminiCliOAuthEnabled) + state.rateLimits.setNetworkProxySettingsResolver(() => store.getSettings()) + state.keybindings = new KeybindingService({ + homePath: app.getPath('home'), + getLegacyOverrides: () => store.getSettings().keybindings, + legacyTabSwitchSeed: { + isPending: () => store.getSettings().tabSwitchKeybindingSeed === 'pending', + markSeeded: () => store.updateSettings({ tabSwitchKeybindingSeed: 'done' }) + } + }) + browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() })) + state.rateLimits.setInactiveClaudeAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeClaudeRuntimeSelection(settings).host, + ...Object.values(normalizeClaudeRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.claudeManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + managedAuthPath: account.managedAuthPath, + managedAuthRuntime: account.managedAuthRuntime, + wslDistro: account.wslDistro, + wslLinuxAuthPath: account.wslLinuxAuthPath + })) + }) + state.rateLimits.setInactiveCodexAccountsResolver(() => { + const settings = store.getSettings() + const activeIds = new Set( + [ + normalizeCodexRuntimeSelection(settings).host, + ...Object.values(normalizeCodexRuntimeSelection(settings).wsl) + ].filter(Boolean) + ) + return settings.codexManagedAccounts + .filter((account) => !activeIds.has(account.id)) + .map((account) => ({ + id: account.id, + resolveHome: () => { + const resolved = + state.codexRuntimeHome!.resolveCodexManagedAccountHomeForInactiveFetch(account) + return resolved.kind === 'ready' + ? { kind: 'ready' as const, managedHomePath: resolved.homePath } + : { kind: 'skip' as const } + } + })) + }) +} diff --git a/src/main/startup/main-process-automations.ts b/src/main/startup/main-process-automations.ts new file mode 100644 index 00000000000..77092ed66b8 --- /dev/null +++ b/src/main/startup/main-process-automations.ts @@ -0,0 +1,99 @@ +import { AutomationService } from '../automations/service' +import { createHeadlessAutomationOutputSnapshotBuffer } from '../automations/headless-dispatch' +import { buildHeadlessAutomationWorktreeCreateArgs } from '../automations/headless-workspace-create' +import { createRuntimeAutomationRunTerminalObserver } from '../automations/runtime-terminal-run-observer' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessAutomations(): AutomationService { + const store = state.store + const runtime = state.runtime + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + if (!store || !runtime || !claudeUsage || !codexUsage) { + throw new Error('Runtime and usage stores must be initialized before automations') + } + const service = new AutomationService(store, { + claudeUsage, + codexUsage, + terminalObserver: createRuntimeAutomationRunTerminalObserver(runtime), + onAutomationsChanged: (payload) => runtime.notifyAutomationsChanged(payload), + allowRemoteHostScheduling: state.isServeMode, + headlessDispatcher: state.isServeMode + ? async ({ automation, run, target }) => { + const terminalSnapshotLimit = 2_000 + let terminalHandle: string + let terminalSessionId: string | null = null + let terminalPaneKey: string | null = null + let terminalPtyId: string | null = null + let workspaceId: string + let workspaceDisplayName: string | null = null + if (automation.workspaceMode === 'new_per_run') { + const created = await runtime.createManagedWorktree( + buildHeadlessAutomationWorktreeCreateArgs({ automation, run, repo: target.repo }) + ) + terminalHandle = created.startupTerminal?.handle ?? '' + terminalSessionId = created.startupTerminal?.tabId ?? null + terminalPaneKey = created.startupTerminal?.paneKey ?? null + terminalPtyId = created.startupTerminal?.ptyId ?? null + workspaceId = created.worktree.id + workspaceDisplayName = created.worktree.displayName ?? null + if (!terminalHandle) { + throw new Error( + created.warning || + 'Automation workspace was created, but no agent terminal started.' + ) + } + } else { + if (!automation.workspaceId) { + throw new Error('The target workspace is no longer available.') + } + const terminal = await runtime.launchAgentTerminal(`id:${automation.workspaceId}`, { + agent: automation.agentId, + prompt: automation.prompt, + title: run.title + }) + terminalHandle = terminal.handle + terminalSessionId = terminal.tabId ?? null + terminalPaneKey = terminal.paneKey ?? null + terminalPtyId = terminal.ptyId ?? null + workspaceId = terminal.worktreeId + const worktree = await runtime.showManagedWorktree(`id:${workspaceId}`) + workspaceDisplayName = worktree.displayName ?? null + } + const completion = (async () => { + const wait = await runtime.waitForTerminal(terminalHandle, { condition: 'tui-idle' }) + const read = await runtime.readTerminal(terminalHandle, { + limit: terminalSnapshotLimit + }) + const snapshotBuffer = createHeadlessAutomationOutputSnapshotBuffer() + snapshotBuffer.append(read.tail.join('\n')) + if (wait.satisfied) { + return { + status: 'completed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: null + } + } + return { + status: 'dispatch_failed' as const, + outputSnapshot: snapshotBuffer.snapshot(), + error: wait.blockedReason + ? `Automation agent is blocked: ${wait.blockedReason}.` + : 'Automation agent did not report completion.' + } + })() + return { + workspaceId, + workspaceDisplayName, + terminalSessionId, + terminalPaneKey, + terminalPtyId, + completion + } + } + : undefined + }) + state.automations = service + runtime.setAutomationService(service) + return service +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts new file mode 100644 index 00000000000..b6e494c1ad0 --- /dev/null +++ b/src/main/startup/main-process-i18n-menu.ts @@ -0,0 +1,94 @@ +import { app, BrowserWindow } from 'electron' +import { ensureMainI18n, setMainUiLanguage } from '../i18n/main-i18n' +import { + registerAppMenu, + rebuildAppMenu, + getNextDefaultOnAppearanceSettingValue +} from '../menu/register-app-menu' +import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { mainProcessState as state } from './main-process-state' +import { + openSettingsFromSystemMenu, + runUserInitiatedUpdateCheck, + sendOpenCrashReport, + sendOpenFeatureTour, + sendOpenSetupGuide +} from './main-window-actions' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeMainProcessI18nAndMenu(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before menu') + } + await ensureMainI18n() + await setMainUiLanguage(store.getSettings().uiLanguage) + logStartupMilestone('i18n-ready') + registerAppMenu({ + appMenuLabel: state.devInstanceIdentity?.name ?? app.name, + onCheckForUpdates: (options) => { + ensureAutoUpdaterConfigured() + runUserInitiatedUpdateCheck(options) + }, + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + state.expectedRendererReload.mark(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onOpenSettings: openSettingsFromSystemMenu, + onOpenSetupGuide: (targetWindow) => { + recordCrashBreadcrumb('setup_guide_opened') + sendOpenSetupGuide(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenCrashReport: (targetWindow) => { + recordCrashBreadcrumb('crash_report_opened') + sendOpenCrashReport(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onOpenFeatureTour: (targetWindow) => { + recordCrashBreadcrumb('feature_tour_opened') + sendOpenFeatureTour(targetWindow instanceof BrowserWindow ? targetWindow : null) + }, + onZoomIn: () => { + if (!zoomDashboardPopoutIfFocused('in')) { + state.mainWindow?.webContents.send('terminal:zoom', 'in') + } + }, + onZoomOut: () => { + if (!zoomDashboardPopoutIfFocused('out')) { + state.mainWindow?.webContents.send('terminal:zoom', 'out') + } + }, + onZoomReset: () => { + if (!zoomDashboardPopoutIfFocused('reset')) { + state.mainWindow?.webContents.send('terminal:zoom', 'reset') + } + }, + onToggleLeftSidebar: () => state.mainWindow?.webContents.send('ui:toggleLeftSidebar'), + onToggleRightSidebar: () => state.mainWindow?.webContents.send('ui:toggleRightSidebar'), + onToggleAppearance: (key) => { + if (key === 'statusBarVisible') { + state.mainWindow?.webContents.send('ui:toggleStatusBar') + return + } + const current = store.getSettings() + const next = getNextDefaultOnAppearanceSettingValue(current[key]) + store.updateSettings({ [key]: next }, { notifyListeners: true }) + rebuildAppMenu() + }, + getAppearanceState: () => { + const settings = store.getSettings() + const ui = store.getUI() + return { + showTasksButton: settings.showTasksButton !== false, + showAutomationsButton: settings.showAutomationsButton !== false, + showMobileButton: settings.showMobileButton !== false, + showTitlebarAppName: settings.showTitlebarAppName !== false, + statusBarVisible: ui.statusBarVisible !== false + } + }, + getKeybindings: () => state.keybindings?.getOverrides() + }) +} diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts new file mode 100644 index 00000000000..a948e4fb13f --- /dev/null +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -0,0 +1,47 @@ +import { ipcMain } from 'electron' +import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' + +export function registerMainProcessIpcHandlers(): void { + ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + }) + ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([ + state.firstWindowStartupServicesReady, + state.managedWslCliStartupBarrierReady + ]) + await state.runtime?.prepareStructuredAgentSessionStartupRestoration() + }) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => + recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: state.firstWindowStartupServicesReady, + managedWslCliStartupBarrierReady: state.managedWslCliStartupBarrierReady, + localPtyProviderStartupReady: state.localPtyProviderStartupReady, + reconcile: async () => { + await state.runtime?.refreshRestoredOrchestrationAuthority() + return state.runtime?.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + }, + onDeferredRecoveryError: (error) => { + console.warn('[orchestration] legacy worker provider-ready recovery failed', error) + } + }) + ) + ipcMain.handle('ui:consumePendingOpenSettings', (event) => + state.pendingOpenSettings.matches(event.sender.id, { consume: true }) + ) + ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume()) + ipcMain.handle( + 'app:startupDiagnostic', + (_event, event: string, details?: Record) => { + if (!state.startupDiagnosticsEnabled || !event.startsWith('renderer-')) { + return + } + logStartupMilestone(event, details && typeof details === 'object' ? details : {}) + } + ) +} diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts new file mode 100644 index 00000000000..aa2dd6ea421 --- /dev/null +++ b/src/main/startup/main-process-observers.ts @@ -0,0 +1,131 @@ +import { app } from 'electron' +import { join } from 'node:path' +import { AgentAwakeService } from '../agent-awake-service' +import { normalizeComputerAwakeMode } from '../../shared/computer-awake-mode' +import { registerSystemResumeBroadcast } from '../system-resume-broadcast' +import { agentHookServer, type AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import { createHookProviderSessionInvalidator } from '../agent-hooks/hook-provider-session-invalidation' +import { createHookStatusSessionTabsInvalidator } from '../agent-hooks/hook-status-session-tabs-invalidation' +import { initTelemetry, track } from '../telemetry/client' +import { setCodexTrustGrantTelemetry } from '../codex/codex-trust-grant-telemetry' +import { initObservability } from '../observability' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { recoverPendingSkillTransactions } from '../skills/skill-transaction-startup-recovery' +import { initCohortClassifier } from '../telemetry/cohort-classifier' +import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier' +import { StatsCollector } from '../stats/collector' +import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' +import { ClaudeUsageStore } from '../claude-usage/store' +import { CodexUsageStore } from '../codex-usage/store' +import { OpenCodeUsageStore } from '../opencode-usage/store' +import { mainProcessState as state } from './main-process-state' + +export function initializeMainProcessObservers(): void { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before observers') + } + state.unsubscribeSystemResumeBroadcast = registerSystemResumeBroadcast() + state.agentAwakeService = new AgentAwakeService() + state.agentAwakeService.setMode( + normalizeComputerAwakeMode( + store.getSettings().computerAwakeMode, + store.getSettings().keepComputerAwakeWhileAgentsRun + ) + ) + state.agentAwakeService.setStatuses([]) + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + state.runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + state.runtime?.touchMobileSessionTabsForWorktree(worktreeId, { immediate: true }) + } + } + state.publishProviderSessionChanges = publishProviderSessionChanges + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + state.agentAwakeService?.setStatuses(statuses) + }) + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => publishProviderSessionChanges(sessions) + ) + const hookStatusChangedSessionTabs = createHookStatusSessionTabsInvalidator() + const unsubscribeHookStatusSessionTabs = agentHookServer.subscribeEnrichedStatus((enriched) => { + if (hookStatusChangedSessionTabs(enriched)) { + state.runtime?.touchMobileSessionTabsForPane(enriched.paneKey, enriched.worktreeId ?? null) + } + }) + const unsubscribeHookStatusClear = agentHookServer.subscribePaneStatusClear((clear) => { + const clearedPaneKeys = + 'paneKey' in clear + ? [clear.paneKey] + : hookStatusChangedSessionTabs.forgetConnection(clear.connectionId) + for (const paneKey of clearedPaneKeys) { + hookStatusChangedSessionTabs.forgetPane(paneKey) + state.runtime?.touchMobileSessionTabsForPane(paneKey) + } + }) + state.unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + unsubscribeHookStatusSessionTabs() + unsubscribeHookStatusClear() + } + initTelemetry(store) + if (state.hangDetection) { + track('main_thread_hang_detected', { + unresponsive_ms: Math.round(state.hangDetection.unresponsiveMs), + self_recovered: state.hangDetection.selfRecovered + }) + } + setCodexTrustGrantTelemetry(({ outcome, hostKind, lane, reason, errorClass, verifyClass }) => { + track('codex_trust_grant', { + outcome, + host_kind: hostKind, + lane, + ...(reason !== undefined ? { fallback_reason: reason } : {}), + ...(errorClass !== undefined ? { error_class: errorClass } : {}), + ...(verifyClass !== undefined ? { verify_class: verifyClass } : {}) + }) + }) + initObservability() + recordDurableCrashBreadcrumb('main_process_lifecycle_started', { + packaged: app.isPackaged, + platform: process.platform + }) + state.skillTransactionRecovery = recoverPendingSkillTransactions( + join(app.getPath('userData'), 'skill-installs') + ) + void state.skillTransactionRecovery + .then((report) => { + const result = report as { + scanned: number + recovered: number + failures: { code: string }[] + truncated: boolean + } + if (result.scanned || result.failures.length || result.truncated) { + console.info('[skills] startup transaction recovery:', { + scanned: result.scanned, + recovered: result.recovered, + failures: result.failures.map((failure) => failure.code), + truncated: result.truncated + }) + } + }) + .catch((error) => console.warn('[skills] startup transaction recovery failed:', error)) + initCohortClassifier(store) + initOnboardingCohortClassifier(store) + state.stats = new StatsCollector() + const agentSessionRecorder = new AgentSessionTransitionRecorder(state.stats) + agentHookServer.subscribeEnrichedStatus((enriched) => agentSessionRecorder.onStatus(enriched)) + agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) + state.claudeUsage = new ClaudeUsageStore(store) + state.codexUsage = new CodexUsageStore(store) + state.openCodeUsage = new OpenCodeUsageStore(store) +} diff --git a/src/main/startup/main-process-plugins.ts b/src/main/startup/main-process-plugins.ts new file mode 100644 index 00000000000..5a71cc6f8b3 --- /dev/null +++ b/src/main/startup/main-process-plugins.ts @@ -0,0 +1,158 @@ +import { app, BrowserWindow } from 'electron' +import { performance } from 'node:perf_hooks' +import { PluginService } from '../plugins/plugin-service' +import { PluginKillListService } from '../plugins/plugin-kill-list-service' +import { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import { PluginBundledBootstrapCoordinator } from '../plugins/plugin-bundled-bootstrap-coordinator' +import { getPluginsDataDir } from '../plugins/plugin-discovery' +import { resolveBundledPluginRoot } from '../plugins/plugin-bundled-bootstrap' +import { resolvePluginHostEntryPath } from '../plugins/plugin-host-process' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { setPluginServiceForRpc } from '../runtime/rpc/methods/plugins' +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import { setMainPluginLanguagePacks, setMainUiLanguage } from '../i18n/main-i18n' +import { rebuildAppMenu } from '../menu/register-app-menu' +import { logStartupMilestone } from './startup-diagnostics' +import { agentHookServer } from '../agent-hooks/server' +import { emitPluginWorktreeLifecycle } from './main-process-pty-startup' +import { mainProcessState as state } from './main-process-state' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +export async function initializeMainProcessPlugins(runtime: OrcaRuntimeService): Promise { + const store = state.store + const keybindings = state.keybindings + if (!store || !keybindings) { + throw new Error('Store and keybindings must be initialized before plugins') + } + const pluginSystemStartupStartedAt = performance.now() + state.pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await state.pluginKillListService.initialize() + state.pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store.getSettings().pluginSystemEnabled !== true) { + return + } + void state.pluginMarketplaceService + ?.seedOfficialSource() + .catch((error) => + console.warn('[plugins] failed to configure the official marketplace:', error) + ) + } + state.pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: state.pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null + }) + state.pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isPluginSystemEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(state.store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(state.store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(state.store?.getSettings().devPluginPaths), + getKeybindings: () => state.keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => state.pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => state.store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => state.pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => state.pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => console.warn('[plugins] failed to bootstrap bundled plugins:', error)) + } + state.pluginKillListService.onChanged(() => { + void state.pluginService + ?.reconcileActivationState() + .catch((error) => + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + ) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void state.pluginKillListService + ?.refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + }) + setPluginServiceForRpc(state.pluginService, { + applyConsent: (request) => + applyPluginConsent({ store, pluginService: state.pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store, pluginService: state.pluginService!, pluginKey, enabled }) + }) + void state.pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: state.pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => console.warn('[plugins] failed to initialize plugin service:', error)) + if (app.isPackaged && store.getSettings().pluginSystemEnabled === true) { + void state.pluginKillListService + .refresh() + .catch((error) => + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + ) + } + state.pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(state.pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + agentHookServer.subscribeEnrichedStatus((enriched) => { + if (enriched.restoredUnconfirmed) { + return + } + state.pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtime.onWorktreeLifecycle(emitPluginWorktreeLifecycle) +} diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts new file mode 100644 index 00000000000..3cdb4e056d9 --- /dev/null +++ b/src/main/startup/main-process-preflight.ts @@ -0,0 +1,244 @@ +import { app, ipcMain, powerMonitor, session } from 'electron' +import { is } from '@electron-toolkit/utils' +import os from 'node:os' +import { join } from 'node:path' +import { maybeRedirectAppImageCliLaunch } from './appimage-cli-redirect' +import { maybeRedirectPackagedCliEntryLaunch } from './packaged-cli-entry-redirect' +import { argvRequestsServeMode, normalizeServeModeArgv } from './serve-mode-argv' +import { + configureDevUserDataPath, + configureElectronNetworkCompatibility, + configureOrcaUserDataPathEnv, + disableUnsupportedChromiumFeatures, + enableMainProcessGpuFeatures, + installDevParentDisconnectQuit, + installDevParentSignalQuit, + installDevParentWatchdog, + patchPackagedProcessPath, + optOutOfHiddenPageWakeUpThrottling +} from './configure-process' +import { installServeSupervisorDisconnectQuit } from '../serve-update-handoff' +import { + installUncaughtPipeErrorGuard, + installUnhandledRejectionLogging +} from './main-process-error-guards' +import { hydrateShellPath, mergePathSegments } from './hydrate-shell-path' +import { configureRemoteServerUpdater } from '../runtime/remote-server-updater' +import { + getRemoteServerUpdaterSnapshot, + checkForRemoteServerUpdate, + downloadRemoteServerUpdate, + installRemoteServerUpdate, + isQuittingForUpdate +} from '../updater' +import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' +import { enableRendererHeapHeadroom } from './renderer-heap-headroom' +import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' +import { startEventLoopStallProbe } from './event-loop-stall-probe' +import { startMainThreadChurnProbe } from '../diagnostics/main-thread-churn-probe' +import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation' +import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary' +import { createServeDesktopActivationGate } from './serve-desktop-activation' +import { + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, + acquireSingleInstanceLock, + logSingleInstanceLockBypass, + logSingleInstanceLockFailure, + SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE +} from './single-instance-lock' +import { setAppEnvironment } from '../../shared/app-environment' +import { ElectronAppEnvironment } from '../host/electron-app-environment' +import { setSecretStore } from '../../shared/secret-store' +import { ElectronSecretStore } from '../host/electron-secret-store' +import { setPtyHostBindings } from '../ipc/pty-host-bindings' +import { electronRuntimeDesktopSurface } from '../host/electron-runtime-desktop-surface' +import { setRuntimeDesktopSurface } from '../runtime/runtime-desktop-surface' +import { electronRuntimeBrowserCommandsFactory } from '../host/electron-browser-commands' +import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' +import { electronHttpClient } from '../host/electron-http-client' +import { setMainHttpClient } from '../network/http-client' +import { electronSpeechServiceFactories } from '../host/electron-speech-services' +import { setSpeechServiceFactories } from '../speech/speech-runtime-service' +import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' +import { desktopWorktreeWatcherRemoval } from '../ipc/filesystem-watcher' +import { setDefaultProxySessionResolver } from '../network/proxy-settings' +import { initDataPath, getCanonicalUserDataPath } from '../persistence' +import { applyMacPressAndHoldDefaultAtStartup } from '../macos-press-and-hold-default' +import { initSessionParseCachePersistence } from '../ai-vault/session-parse-cache-persistence' +import { initOrcaProfilePaths } from '../orca-profiles/profile-index-store' +import { initStatsPath } from '../stats/collector' +import { initClaudeUsagePath } from '../claude-usage/store' +import { initCodexUsagePath } from '../codex-usage/store' +import { initOpenCodeUsagePath } from '../opencode-usage/store' +import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' +import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' +import { CrashReportStore } from '../crash-reporting/crash-report-store' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { getMainProcessLifecycleIdentity } from '../crash-reporting/main-process-lifecycle-identity' +import { ensureVirtualDisplayForHeadlessServe } from './ensure-virtual-display' +import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from './gpu-lifecycle' +import { mainProcessState as state } from './main-process-state' +import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' + +export type MainProcessPreflightOptions = { + focusExistingWindow: () => void + requestDesktopActivation: (argv?: readonly string[]) => void +} + +/** Performs all module-scope work that must happen before Electron's ready event. */ +export function runMainProcessPreflight(options: MainProcessPreflightOptions): boolean { + const packagedRedirect = maybeRedirectPackagedCliEntryLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (packagedRedirect.redirected) { + app.exit(packagedRedirect.status) + } + const appImageRedirect = maybeRedirectAppImageCliLaunch({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + execPath: process.execPath + }) + if (appImageRedirect.redirected) { + app.exit(appImageRedirect.status) + } + if (argvRequestsServeMode(process.argv)) { + process.argv = normalizeServeModeArgv(process.argv) + } + state.isServeMode = process.argv.includes('--serve') + if (state.isServeMode) { + reserveServeStdoutForReadiness() + } + state.devInstanceIdentity = getDevInstanceIdentity(is.dev) + state.devAgentHookEndpointNamespace = state.devInstanceIdentity.isDev + ? state.devInstanceIdentity.appUserModelId + : undefined + state.desktopActivationGate = createServeDesktopActivationGate({ + initialState: state.isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + if (!isQuittingForUpdate()) { + options.focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) + }) + installUncaughtPipeErrorGuard() + installUnhandledRejectionLogging() + process.env.ORCA_APP_VERSION = app.getVersion() + configureRemoteServerUpdater({ + getSnapshot: getRemoteServerUpdaterSnapshot, + check: checkForRemoteServerUpdate, + download: downloadRemoteServerUpdate, + install: installRemoteServerUpdate + }) + patchPackagedProcessPath() + if (app.isPackaged && process.platform !== 'win32') { + void hydrateShellPath().then((result) => { + if (result.ok) { + mergePathSegments(result.segments) + } else { + console.warn( + `[shell-path] login-shell probe failed (${result.failureReason}); using seeded PATH` + ) + } + }) + } + const isDev = is.dev + configureDevUserDataPath(isDev) + configureOrcaUserDataPathEnv() + installServeSupervisorDisconnectQuit(state.isServeMode) + state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('before-single-instance-lock', { + version: app.getVersion(), + packaged: app.isPackaged, + platform: process.platform, + osRelease: os.release(), + userData: app.getPath('userData'), + e2eUserData: Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + }) + startEventLoopStallProbe() + } + startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) + const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) + if (bypass) { + logSingleInstanceLockBypass() + } + const hasLock = skip || bypass || acquireSingleInstanceLock(app, options.requestDesktopActivation) + if (state.startupDiagnosticsEnabled) { + logStartupDiagnostic('single-instance-lock-result', { + acquired: hasLock, + bypassed: bypass, + skippedForDev: skip + }) + } + if (!hasLock) { + logSingleInstanceLockFailure() + app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) + return false + } + setAppEnvironment(new ElectronAppEnvironment()) + setSecretStore(new ElectronSecretStore()) + setPtyHostBindings({ ipc: ipcMain, power: powerMonitor }) + setRuntimeDesktopSurface(electronRuntimeDesktopSurface) + setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory) + setDefaultProxySessionResolver(() => session.defaultSession) + setMainHttpClient(electronHttpClient) + setSpeechServiceFactories(electronSpeechServiceFactories) + setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval) + const shouldCoupleToDevParent = isDev && !state.isServeMode + installDevParentDisconnectQuit(shouldCoupleToDevParent) + installDevParentWatchdog(shouldCoupleToDevParent) + installDevParentSignalQuit(shouldCoupleToDevParent) + initDataPath() + applyMacPressAndHoldDefaultAtStartup(getCanonicalUserDataPath()) + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) + initOrcaProfilePaths() + initStatsPath() + initClaudeUsagePath() + initCodexUsagePath() + initOpenCodeUsagePath() + if (state.devInstanceIdentity && shouldApplyPreReadyAppName(state.devInstanceIdentity)) { + app.setName(state.devInstanceIdentity.appName) + } + registerDocPreviewSchemePrivileges() + startCrashpadCapture() + state.crashReports = CrashReportStore.fromUserData() + state.gpuCrashDiagnostics = + process.platform === 'win32' + ? new GpuCrashDiagnosticsRecorder({ + provider: { + getGPUInfo: (infoType) => app.getGPUInfo(infoType), + getGPUFeatureStatus: () => app.getGPUFeatureStatus() + }, + recordBreadcrumb: (data) => recordDurableCrashBreadcrumb('gpu_crash_hardware', data) + }) + : null + recordCrashBreadcrumb('app_started', { + packaged: app.isPackaged, + platform: process.platform, + ...getMainProcessLifecycleIdentity() + }) + disableUnsupportedChromiumFeatures() + optOutOfHiddenPageWakeUpThrottling() + configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() + maybeApplyGpuFallbackForThisLaunch() + if (!state.gpuFallbackActiveThisLaunch) { + enableMainProcessGpuFeatures() + } + state.headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ + isServeMode: state.isServeMode + }) + initializeSyntheticTitleRuntime() + registerGpuLifecycleHandlers() + return true +} diff --git a/src/main/startup/main-process-pty-startup.ts b/src/main/startup/main-process-pty-startup.ts new file mode 100644 index 00000000000..7f9feebcb63 --- /dev/null +++ b/src/main/startup/main-process-pty-startup.ts @@ -0,0 +1,188 @@ +import { app } from 'electron' +import { classifyError } from '../telemetry/classify-error' +import { track } from '../telemetry/client' +import { getPtyIdForPaneKey } from '../ipc/pty' +import { + getDaemonProvider, + initDaemonPtyProvider, + listLiveDaemonPtyIds +} from '../daemon/daemon-init' +import { + getCodexPaneAccount, + hasRecordedManagedHostCodexPane, + isCodexPaneHomeRouteProvenAwayFromSharedHome, + reconcileCodexPaneAccountsWithLivePtys, + type CodexPaneHomeRoute +} from '../codex/codex-pane-account-registry' +import { reconcileRetainedCodexHookHomes } from '../codex/retained-codex-hook-state' +import { codexHookService } from '../codex/hook-service' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { agentHookServer } from '../agent-hooks/server' +import { + indexPersistedPaneKeyPtyIds, + isLocalExecutionHost, + resolveAgentWorkspaceExecutionHostId, + sweepRestoredSubagentsWithoutLiveAgent +} from '../agent-hooks/restored-subagent-liveness-sweep' +import { startFirstWindowStartupServices } from './first-window-startup-services' +import { logStartupMilestone } from './startup-diagnostics' +import type { WindowsDesktopStartupServices } from './windows-desktop-shell-path-startup' +import type { RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' +import { mainProcessState as state } from './main-process-state' + +export function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + state.pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} + +export function handleCodexHomePtySpawned(args: { + id: string + codexHomePath: string | null + reattached?: boolean + reattachedHomeRoute?: CodexPaneHomeRoute | null + launchEnv?: NodeJS.ProcessEnv + startedAt?: Date + startedSequence?: number +}): void { + if (args.reattached && args.startedSequence !== undefined) { + const paneAccount = getCodexPaneAccount(args.id) + const homeRoute = + args.reattachedHomeRoute !== undefined + ? (args.reattachedHomeRoute ?? undefined) + : paneAccount?.homeRoute + if (state.codexSessionMigration && isCodexPaneHomeRouteProvenAwayFromSharedHome(homeRoute)) { + state.codexSessionMigration.ignoreLaunch(args.id, args.startedSequence) + return + } + } + const fullScanRequired = + state.codexRuntimeHome?.beginHostSystemDefaultSessionMigrationLaunch(args.codexHomePath, { + reattached: args.reattached, + launchEnv: args.launchEnv + }) ?? null + if (fullScanRequired !== null) { + state.codexSessionMigration?.beginLaunch( + args.id, + args.reattached === true || fullScanRequired, + args.startedAt, + args.startedSequence + ) + } +} + +export function handlePtyExit(id: string, exitSequence: number): void { + state.codexSessionMigration?.finishLaunch(id, exitSequence) +} + +export async function reapRestoredSubagentsWithoutLiveAgent(): Promise { + const store = state.store + if (!store) { + return + } + const provider = getDaemonProvider() + if (!provider) { + return + } + const persistedPtyIdByPaneKey = indexPersistedPaneKeyPtyIds( + store.getWorkspaceSession().terminalLayoutsByTabId ?? {} + ) + await sweepRestoredSubagentsWithoutLiveAgent({ + probeLiveLocalPty: (ptyId) => provider.probePtyLiveness(ptyId), + isLocalExecutionHost: (worktreeId) => + isLocalExecutionHost( + resolveAgentWorkspaceExecutionHostId(worktreeId, { + getRepo: (repoId) => store.getRepo(repoId), + getWorktreeMeta: (resolvedWorktreeId) => store.getWorktreeMeta(resolvedWorktreeId), + getFolderWorkspace: (folderWorkspaceId) => store.getFolderWorkspace(folderWorkspaceId), + getProjectGroups: () => store.getProjectGroups() + }) + ), + getBoundPtyIdForPaneKey: getPtyIdForPaneKey, + getPersistedPtyIdForPaneKey: (paneKey) => persistedPtyIdByPaneKey.get(paneKey), + reap: (isLocalHost, isLocalPaneAgentLive, isLocalPaneLivenessEvidenceCurrent) => + agentHookServer.reapRestoredClaudeSubagentsWithoutLiveAgent( + isLocalHost, + isLocalPaneAgentLive, + isLocalPaneLivenessEvidenceCurrent + ) + }) +} + +export function startTerminalRuntimeStartupServices(): WindowsDesktopStartupServices { + logStartupMilestone('first-window-startup-services-start') + const startupServices = startFirstWindowStartupServices({ + // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. + startDaemonPtyProvider: async (signal) => { + logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !state.isServeMode + }) + if (state.codexRuntimeHome && hasRecordedManagedHostCodexPane()) { + const livePtyIds = await listLiveDaemonPtyIds() + if (livePtyIds) { + reconcileCodexPaneAccountsWithLivePtys(livePtyIds) + const settings = state.store?.getSettings() + void reconcileRetainedCodexHookHomes({ + hookService: codexHookService, + hooksEnabled: + isAgentStatusHooksEnabled(settings) && + settings?.disabledTuiAgents.includes('codex') !== true, + runtimeHomePaths: state.codexRuntimeHome.getRetainedHostCodexHookHomePaths(livePtyIds) + }).catch((error) => + console.warn('[codex-hook-service] retained Codex home reconcile failed:', error) + ) + } + } + state.codexRuntimeHome?.reconcileLegacySharedHomeForRetainedPanes() + logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) + }, + startAgentHookServer: async () => { + const settings = state.store?.getSettings() + if (!isAgentStatusHooksEnabled(settings)) { + return + } + logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) + agentHookServer.setTransportInterferenceListener((report) => { + track('agent_hook_transport_blocked', { count: report.count }) + }) + await agentHookServer.start({ + env: app.isPackaged ? 'production' : 'development', + userDataPath: app.getPath('userData'), + endpointNamespace: state.devAgentHookEndpointNamespace + }) + logStartupMilestone('startup-service-done', { service: 'agent-hook-server' }) + }, + onDaemonError: (error) => { + const reason = error instanceof Error ? error.message : String(error) + console.error( + `[daemon] STARTUP FAILED — falling back to local PTYs; terminals will not persist across quit. Reason: ${reason}` + ) + track('daemon_start_failed', classifyError(error)) + }, + onAgentHookServerError: (error) => { + console.error('[agent-hooks] Failed to start local hook server:', error) + } + }) + void startupServices.firstWindowReady.then(() => + logStartupMilestone('first-window-startup-services-ready') + ) + void startupServices.localPtyReady.then(() => { + logStartupMilestone('local-pty-startup-ready') + void reapRestoredSubagentsWithoutLiveAgent().catch((error) => + console.warn('[agent-hooks] restored-subagent liveness probe failed:', error) + ) + }) + return startupServices +} + +export function bindTerminalRuntimeStartupServices( + services: Promise +): void { + state.firstWindowStartupServicesReady = services.then((value) => value.firstWindowReady) + state.localPtyStartupReady = services.then((value) => value.localPtyReady) + state.localPtyProviderStartupReady = services.then((value) => value.localPtyProviderReady) +} diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts new file mode 100644 index 00000000000..4505e51ba93 --- /dev/null +++ b/src/main/startup/main-process-quit.ts @@ -0,0 +1,217 @@ +import { app, type Event } from 'electron' +import { closeAllWatchers } from '../ipc/filesystem-watcher' +import { disposeWorktreeBaseDirectoryWatchers } from '../ipc/worktree-base-directory-watcher' +import { stopFolderRepoGitUpgradeWatch } from '../ipc/folder-repo-git-upgrade' +import { killAllPty } from '../ipc/pty' +import { disconnectDaemon, shutdownDaemon } from '../daemon/daemon-init' +import { beginSshShutdown } from '../ipc/ssh-shutdown-drain' +import { agentHookServer } from '../agent-hooks/server' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { removeManagedAgentHooksAsync } from '../agent-hooks/managed-agent-hook-controls' +import { stopStructuredAgentSessionRuntime } from '../runtime/structured-agent-session-runtime' +import { awaitRuntimeFileWatcherUnsubscribes } from '../runtime/orca-runtime-files' +import { clearRuntimeMetadataIfOwned } from '../runtime/runtime-metadata' +import { shutdownPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' +import { browserManager } from '../browser/browser-manager' +import { stopCodexStateDbBackfillRecoveries } from '../codex/codex-state-db-backfill-recovery' +import { settleTeardownWithinDeadline, settleWithinMs } from '../quit-teardown-deadline' +import { quitTeardownStartGate } from '../quit-teardown-start-gate' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' +import { destroySystemTray } from '../tray/system-tray' +import { shutdownTelemetry } from '../telemetry/client' +import { shutdownObservability } from '../observability' +import { isQuittingForUpdate } from '../updater' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' +import { mainProcessState as state } from './main-process-state' +import { isDevParentShutdownRequested } from './configure-process' +import { getCanonicalUserDataPath } from '../persistence' + +let daemonDisconnectDone = false +let watcherShutdownPromise: Promise | null = null +const GROK_HOOK_CLEANUP_DEADLINE_MS = 2_000 + +function shutdownWatchersOnce(): Promise { + if (state.watcherShutdownDone) { + return Promise.resolve() + } + if (!watcherShutdownPromise) { + stopFolderRepoGitUpgradeWatch() + watcherShutdownPromise = Promise.allSettled([ + closeAllWatchers(), + disposeWorktreeBaseDirectoryWatchers() + ]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') { + console.error('[filesystem-watcher] shutdown failed:', result.reason) + } + } + }) + .then(() => { + state.watcherShutdownDone = true + }) + } + return watcherShutdownPromise +} + +function installBeforeQuitHandler(): void { + app.on('before-quit', () => { + if (isQuittingForUpdate()) { + recordUpdaterLifecycle('before_quit_allowed', undefined, { + message: 'before-quit allowed for update install' + }) + } + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + state.runtimeRpc?.setMobileRelayPairingProvider(null) + state.unsubscribeAgentAwakeStatusChanges?.() + state.unsubscribeAgentAwakeStatusChanges = null + state.agentAwakeService?.dispose() + state.agentAwakeService = null + state.rateLimits?.stop() + }) +} + +function installWillQuitHandler(): void { + app.on('will-quit', (event: Event) => { + if (daemonDisconnectDone) { + return + } + if (!quitTeardownStartGate.tryStart(event)) { + return + } + state.unsubscribeSystemResumeBroadcast?.() + state.unsubscribeSystemResumeBroadcast = null + stopTccPromptNotice() + const updateQuitInProgress = isQuittingForUpdate() + if (updateQuitInProgress) { + recordUpdaterLifecycle( + 'will_quit_cleanup_started', + { daemonTeardown: 'disconnect' }, + { message: 'will-quit cleanup for update install; daemonTeardown=disconnect' } + ) + } + destroySystemTray() + state.starNag?.stop() + state.automations?.stop() + state.pluginKillListService = null + state.pluginMarketplaceService = null + state.pluginMarketplaceInstaller = null + const pluginHostShutdown = state.pluginService?.dispose() ?? Promise.resolve() + const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() + state.pluginService = null + setUnreadDockBadgeCount(0) + agentHookServer.stop() + const grokHookCleanup = + process.platform === 'win32' + ? settleWithinMs( + removeManagedAgentHooksAsync({ agents: ['grok'] }), + GROK_HOOK_CLEANUP_DEADLINE_MS + ).then((settled) => { + if (settled.outcome === 'timed-out') { + console.warn('[agent-hooks] Grok hook cleanup on quit timed out') + return + } + if (settled.outcome === 'failed') { + console.warn('[agent-hooks] Grok hook cleanup on quit failed:', settled.error) + return + } + for (const status of settled.value.filter((entry) => entry.detail)) { + console.warn(`[agent-hooks] ${status.agent} hook cleanup on quit: ${status.detail}`) + } + }) + : Promise.resolve() + wslHookRelayManager.disposeAll() + const statsFlush = state.stats?.flushAsync() ?? Promise.resolve() + const browserShutdown = (async (): Promise => { + await state.runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + await state.runtime?.getAgentBrowserBridge()?.destroyAllSessions() + })() + const localSshRouteShutdown = import('../browser/local-ssh-browser-route') + .then((routes) => routes.closeAllLocalSshBrowserRoutes()) + .catch(() => {}) + browserManager.setBrowserGuestStateChangedListener(null) + const emulatorShutdown = + state.runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() + const sshShutdown = beginSshShutdown() + killAllPty() + const watcherShutdown = shutdownWatchersOnce() + const storeFlush = state.store?.flushAsync() ?? Promise.resolve() + const usageCacheFlush = Promise.all([ + state.claudeUsage?.flush(), + state.codexUsage?.flush(), + state.openCodeUsage?.flush() + ]).then(() => {}) + const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() + const skillUploadShutdown = state.runtime?.disposeSkillUploadSessions() ?? Promise.resolve() + const ownedPid = process.pid + const ownedRuntimeId = state.runtime?.getRuntimeId() + const rpcStopAndClear = state.runtimeRpc + ? state.runtimeRpc + .stop() + .then(() => awaitRuntimeFileWatcherUnsubscribes()) + .then(() => { + if (ownedRuntimeId) { + clearRuntimeMetadataIfOwned(getCanonicalUserDataPath(), ownedPid, ownedRuntimeId) + } + }) + .catch((error) => console.error('[runtime] Failed to stop local RPC transport:', error)) + : Promise.resolve() + const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon() + settleTeardownWithinDeadline([ + { name: 'daemon', promise: daemonTeardown }, + { name: 'browser', promise: browserShutdown }, + { name: 'runtime-rpc', promise: rpcStopAndClear }, + { name: 'watchers', promise: watcherShutdown }, + { name: 'emulator', promise: emulatorShutdown }, + { name: 'browser-client-hosts', promise: browserClientHostShutdown }, + { name: 'local-ssh-browser-routes', promise: localSshRouteShutdown }, + { name: 'ssh', promise: sshShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown }, + { name: 'skill-uploads', promise: skillUploadShutdown }, + { name: 'grok-hooks', promise: grokHookCleanup }, + { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, + { name: 'usage-cache', promise: usageCacheFlush }, + { name: 'stats', promise: statsFlush }, + { name: 'state', promise: storeFlush } + ]) + .then((pendingTeardowns) => { + if (pendingTeardowns.length > 0) { + console.warn('[shutdown] Quit teardown deadline reached', { pendingTeardowns }) + } + }) + .then(() => shutdownTelemetry()) + .then(() => shutdownObservability()) + .catch(() => {}) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) + }) +} + +function installWindowAllClosedHandler(): void { + app.on('window-all-closed', () => { + if ( + shouldQuitWhenAllWindowsClosed({ + platform: process.platform, + isQuitting: state.isQuitting, + isServeMode: state.isServeMode + }) + ) { + app.quit() + } + }) +} + +/** Installs the process-level shutdown listeners once during bootstrap. */ +export function installMainProcessQuitHandlers(): void { + process.once('exit', stopTccPromptNotice) + installBeforeQuitHandler() + installWillQuitHandler() + installWindowAllClosedHandler() +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts new file mode 100644 index 00000000000..5f0e3c4a80e --- /dev/null +++ b/src/main/startup/main-process-ready-foundation.ts @@ -0,0 +1,237 @@ +import { app, session } from 'electron' +import { electronApp, is } from '@electron-toolkit/utils' +import { applyBackgroundActivationPolicy } from '../window/foreground-activation-policy' +import { applyElectronProxySettings } from '../network/proxy-settings' +import { installElectronProxyRequestGuard } from '../network/electron-proxy-request-guard' +import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' +import { installMainThreadHangWatchdog } from '../hang-watchdog/main-thread-hang-watchdog' +import { + consumeHangDetectionMarker, + hangDetectionMarkerPath +} from '../hang-watchdog/hang-detection-marker' +import { browserCertificateTrustController } from '../browser/browser-manager' +import { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import { Store, getCanonicalUserDataPath } from '../persistence' +import { initializeBrowserClientHostId } from '../browser/browser-client-host-id' +import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report' +import { initSshHostKeyStoreFile } from '../ssh/ssh-host-key-store' +import { neutralizeLegacyTerminalShimDir } from '../pty/legacy-terminal-shim-dir' +import { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import { + configureWindowsHostGitEnvironmentReadiness, + setDefaultWslDistroOverride +} from '../git/runner' +import { wslHookRelayManager } from '../agent-hooks/wsl-hook-relay-manager' +import { + attachClaudeLivePtyPersistence, + onLiveClaudePtysDrained, + seedLiveClaudePtysFromPersistence +} from '../claude-accounts/live-pty-gate' +import { applyAppIcon } from '../app-icon' +import { + shouldSuppressDevEducation, + suppressDevEducationForStore +} from './dev-education-suppression' +import { + applyBrowserSessionProxies, + setBrowserNetworkProxySettingsResolver +} from '../browser/browser-session-proxy' +import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' +import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' +import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { browserSessionRegistry } from '../browser/browser-session-registry' +import { logStartupMilestone } from './startup-diagnostics' +import { mainProcessState as state } from './main-process-state' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { syncMacMenuBarIcon } from './main-window-actions' +import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' +import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' +import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' + +export async function initializeReadyFoundation(): Promise { + logStartupMilestone('app-ready') + applyBackgroundActivationPolicy({ warn: console.warn }) + installElectronProxyRequestGuard(session.defaultSession) + app.on('login', (event, webContents, details, authInfo, callback) => { + handleElectronProxyLogin( + event, + webContents, + details, + authInfo, + callback, + session.defaultSession + ) + }) + const canonicalUserDataPath = getCanonicalUserDataPath() + installMainThreadHangWatchdog({ userDataPath: canonicalUserDataPath }) + state.hangDetection = consumeHangDetectionMarker(hangDetectionMarkerPath(canonicalUserDataPath)) + if (state.hangDetection) { + recordDurableCrashBreadcrumb('main_thread_hang_detected', { + unresponsiveMs: state.hangDetection.unresponsiveMs, + previousPid: state.hangDetection.parentPid, + selfRecovered: state.hangDetection.selfRecovered + }) + } + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) + const identity = state.devInstanceIdentity + if (!identity) { + throw new Error('Development identity is unavailable') + } + electronApp.setAppUserModelId(identity.appUserModelId) + app.setName(identity.appName) + updateGpuAccelerationAboutPanel() + state.managedWslCliReconciliationStatus = 'pending' + state.managedWslCliReconciliationReady = reconcileManagedWslCliRegistrations({ + isPackaged: app.isPackaged, + userDataPath: canonicalUserDataPath, + appVersion: app.getVersion() + }) + .then((results) => { + for (const result of results) { + if (result.outcome === 'failed') { + console.warn( + `[wsl-cli] ${result.distro} managed registration reconciliation failed: ${result.error}` + ) + } else if (result.outcome === 'repaired') { + console.log(`[wsl-cli] Repaired managed registration in ${result.distro}.`) + } + } + state.managedWslCliReconciliationStatus = 'settled' + }) + .catch((error) => { + state.managedWslCliReconciliationStatus = 'failed' + console.warn( + '[wsl-cli] Managed registration reconciliation discovery failed:', + error instanceof Error ? error.message : String(error) + ) + }) + state.managedWslCliStartupBarrierReady = createWslCliReconciliationStartupBarrier( + state.managedWslCliReconciliationReady + ) + const profile = ensureActiveOrcaProfile() + state.activeOrcaProfile = profile + initializeBrowserClientHostId(profile.profileDirectory) + const store = new Store({ + dataFile: profile.dataFile, + storageAuthority: state.isServeMode ? 'runtime' : 'desktop' + }) + state.store = store + const initialProxyApplication = applyElectronProxySettings(store.getSettings()) + installElectronProxyRequestGuard(session.defaultSession) + scheduleSecretProtectionGapReport({ + dataFile: profile.dataFile, + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !state.isServeMode + }) + initSshHostKeyStoreFile(profile.dataFile) + neutralizeLegacyTerminalShimDir(app.getPath('userData')) + const windowsShellPathHydration = createWindowsShellPathHydration() + state.windowsShellPathHydration = windowsShellPathHydration + configureWindowsHostGitEnvironmentReadiness( + process.platform === 'win32' ? windowsShellPathHydration.whenReady : null + ) + if (process.platform === 'win32') { + const settings = store.getSettings() + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + wslHookRelayManager.setManagedHookSettingsResolver(() => state.store?.getSettings() ?? null) + logStartupMilestone('store-loaded') + setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) + store.onSettingsChanged((updates, settings) => { + if ('terminalWindowsWslDistro' in updates) { + setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) + } + if ( + ('terminalWindowsShell' in updates || 'terminalWindowsPowerShellImplementation' in updates) && + process.platform === 'win32' + ) { + if (app.isPackaged) { + void windowsShellPathHydration.hydrate( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } else { + windowsShellPathHydration.configure( + settings.terminalWindowsShell, + settings.terminalWindowsPowerShellImplementation + ) + } + } + if ('showMenuBarIcon' in updates) { + syncMacMenuBarIcon(settings.showMenuBarIcon !== false) + } + if ('agentStatusHooksEnabled' in updates) { + if (isAgentStatusHooksEnabled(settings)) { + wslHookRelayManager.resumeStoppedRelays() + } else { + wslHookRelayManager.disposeAll({ permanent: false }) + } + } + }) + attachClaudeLivePtyPersistence(store) + onLiveClaudePtysDrained(() => { + void state.rateLimits?.refreshAfterClaudeLivePtysDrained() + }) + const persistedClaudePtyIds = store.getClaudeLivePtySessionIds() + seedLiveClaudePtysFromPersistence(persistedClaudePtyIds) + if (persistedClaudePtyIds.length > 0) { + console.log( + `[claude-live-pty] Seeded ${persistedClaudePtyIds.length} persisted Claude session id(s) into the refresh gate` + ) + } + applyAppIcon(store.getSettings().appIcon) + if (shouldSuppressDevEducation({ isDev: is.dev })) { + suppressDevEducationForStore(store) + } + try { + const proxyApplyResult = await initialProxyApplication + if (proxyApplyResult.source === 'invalid-settings') { + console.warn('[proxy] persisted proxy settings are invalid; using direct networking') + } + } catch { + console.warn('[proxy] Failed to apply network proxy settings') + } + setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings()) + installDocPreviewProtocolHandler() + registerDocPreviewGrantHandlers() + initializeBrowserSessionsForApp({ + orcaProfileId: profile.profile.id, + profileDirectory: profile.profileDirectory, + listLocalSshTargetIds: () => { + const currentStore = state.store + if (!currentStore) { + throw new Error('ssh target store unavailable at partition sweep') + } + return currentStore.getSshTargets().map((target) => target.id) + } + }) + try { + await applyBrowserSessionProxies(browserSessionRegistry.listProfiles(), store.getSettings()) + } catch { + console.warn('[proxy] Failed to apply network proxy settings to browser sessions') + } +} diff --git a/src/main/startup/main-process-ready-runtime.ts b/src/main/startup/main-process-ready-runtime.ts new file mode 100644 index 00000000000..c2e92607f2e --- /dev/null +++ b/src/main/startup/main-process-ready-runtime.ts @@ -0,0 +1,134 @@ +import { app, nativeTheme } from 'electron' +import { randomUUID } from 'node:crypto' +import { performance } from 'node:perf_hooks' +import { is } from '@electron-toolkit/utils' +import { StarNagService } from '../star-nag/service' +import { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import { EmulatorBridge } from '../emulator/emulator-bridge' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { browserManager } from '../browser/browser-manager' +import { configureBrowserClientPageAutomationRuntime } from '../browser/browser-client-page-automation-runtime' +import { BrowserClientPageCommandError } from '../browser/browser-client-page-command-failure' +import { startPreGoneProcessMetricsSampling } from '../crash-reporting/process-gone-diagnostics' +import { recordProcessGoneCrash } from './main-window-lifecycle-flags' +import { handleGpuChildCrash } from './gpu-lifecycle' +import { isGpuFallbackCrashCandidate } from '../crash-reporting/gpu-crash-fallback-decision' +import { ensureRealHomeCodexHookState } from '../codex/codex-real-home-hook-install' +import { + installManagedAgentHooks, + resolveStartupManagedHookAction, + shouldContinueManagedHookStartup, + shouldInstallStartupManagedAgentHook +} from '../agent-hooks/managed-agent-hook-controls' +import { shouldInstallManagedHooks } from './configure-process' +import { recordManagedHookInstallFailure } from '../agent-hooks/install-telemetry' +import { mainProcessState as state } from './main-process-state' +import { initializeMainProcessObservers } from './main-process-observers' +import { initializeMainProcessAccountServices } from './main-process-account-services' +import { + initializeMainProcessRuntime, + configureRuntimeServices +} from './main-process-runtime-service' +import { initializeMainProcessAutomations } from './main-process-automations' +import { initializeMainProcessPlugins } from './main-process-plugins' +import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from '../worktree-trash' +import { logStartupMilestone } from './startup-diagnostics' + +export async function initializeReadyRuntimeServices(): Promise { + const store = state.store + if (!store) { + throw new Error('Store must be initialized before ready services') + } + initializeMainProcessObservers() + initializeMainProcessAccountServices() + const runtime = initializeMainProcessRuntime() + initializeMainProcessAutomations() + configureRuntimeServices(runtime) + await initializeMainProcessPlugins(runtime) + state.starNag = new StarNagService(store, state.stats!) + state.starNag.start() + state.starNag.registerIpcHandlers() + state.agentBrowserBridge = new AgentBrowserBridge(browserManager, { + onTabsChanged: (worktreeId) => runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + runtime.setAgentBrowserBridge(state.agentBrowserBridge) + void state.agentBrowserBridge.sweepOrphanedSessions() + const browserClientAutomationDispatcher = new RpcDispatcher({ runtime }) + configureBrowserClientPageAutomationRuntime({ + browserManager, + getAgentBrowserBridge: () => state.agentBrowserBridge, + executeRpc: async (method, params, signal) => { + const response = await browserClientAutomationDispatcher.dispatch( + { id: randomUUID(), authToken: 'local-browser-client-automation', method, params }, + { signal } + ) + if (!response.ok) { + throw new BrowserClientPageCommandError(response.error.code) + } + return response.result + } + }) + state.emulatorBridge = new EmulatorBridge() + runtime.setEmulatorBridge(state.emulatorBridge) + // Remove directories left behind by an interrupted worktree deletion. + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) + nativeTheme.themeSource = store.getSettings().theme ?? 'system' + const startupManagedHookSettings = store.getSettings() + const shouldReconcileStartupManagedHooks = + shouldInstallManagedHooks(is.dev) && + resolveStartupManagedHookAction(startupManagedHookSettings) === 'install' + const realHomeCodexHookState = + shouldReconcileStartupManagedHooks && + shouldInstallStartupManagedAgentHook(startupManagedHookSettings, 'codex') && + state.codexRuntimeHome?.isHostSystemDefaultRealHomeSelected() + ? ensureRealHomeCodexHookState({ + hooksEnabled: true, + userDataPath: app.getPath('userData') + }).catch((error: unknown) => { + console.warn('[codex-real-home-hooks] startup ensure failed:', error) + }) + : Promise.resolve() + if (shouldReconcileStartupManagedHooks) { + const managedHookStore = store + void realHomeCodexHookState + .then(() => + installManagedAgentHooks(managedHookStore.getSettings(), { + shouldHydrateShellPath: app.isPackaged, + onInstallError: recordManagedHookInstallFailure, + shouldContinue: (agent) => + shouldContinueManagedHookStartup( + state.isQuitting, + managedHookStore.getSettings(), + agent + ) + }) + ) + .catch((error: unknown) => + console.warn('[agent-hooks] failed to reconcile managed hooks on startup:', error) + ) + } + startPreGoneProcessMetricsSampling() + app.on('child-process-gone', (_event, details) => { + recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { + name: details.name, + serviceName: details.serviceName, + type: details.type + }) + if ( + isGpuFallbackCrashCandidate({ + platform: process.platform, + processType: details.type, + reason: details.reason + }) + ) { + const crashedAt = performance.now() + void state.gpuCrashDiagnostics?.record() + void handleGpuChildCrash(details.reason, details.exitCode ?? null, crashedAt) + } + }) + logStartupMilestone('services-initialized') +} diff --git a/src/main/startup/main-process-ready.ts b/src/main/startup/main-process-ready.ts new file mode 100644 index 00000000000..e6d8d782e6e --- /dev/null +++ b/src/main/startup/main-process-ready.ts @@ -0,0 +1,17 @@ +import { initializeMainProcessI18nAndMenu } from './main-process-i18n-menu' +import { initializeReadyFoundation } from './main-process-ready-foundation' +import { initializeReadyRuntimeServices } from './main-process-ready-runtime' +import { + initializeMainProcessRuntimeLaunch, + type MainProcessRuntimeLaunchOptions +} from './main-process-runtime-launch' + +/** Runs the ready-phase composition in the same dependency order as the legacy entry point. */ +export async function initializeMainProcessReady( + options: MainProcessRuntimeLaunchOptions +): Promise { + await initializeReadyFoundation() + await initializeReadyRuntimeServices() + await initializeMainProcessI18nAndMenu() + await initializeMainProcessRuntimeLaunch(options) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts new file mode 100644 index 00000000000..64160f3d65c --- /dev/null +++ b/src/main/startup/main-process-runtime-launch.ts @@ -0,0 +1,283 @@ +import { app, powerMonitor, type BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import { getOrcaCloudAuthConfig } from '../orca-profiles/profile-cloud-auth-config' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { + getCanonicalUserDataPath, + migrateMobilePairingDataToCanonicalUserDataPath +} from '../persistence' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { registerMobileHandlers } from '../ipc/mobile' +import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' +import { HEADLESS_RUNTIME_WINDOW_ID } from '../../shared/runtime-types' +import { OffscreenBrowserBackend } from '../browser/offscreen-browser-backend' +import { browserManager } from '../browser/browser-manager' +import { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import { getServeOptions, getBundledWebClientRoot, printServeReady } from './main-process-serve' +import { + bindTerminalRuntimeStartupServices, + handleCodexHomePtySpawned, + handlePtyExit, + startTerminalRuntimeStartupServices +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { startWindowsDesktopBeforeShellPathReady } from './windows-desktop-shell-path-startup' +import { registerServeSignalHandlers } from './serve-signal-handlers' +import { settleServeDesktopActivation } from './serve-desktop-activation' +import { + recordRuntimeRpcStartFailure, + showRuntimeRpcStartupFailureDialog +} from '../runtime/runtime-rpc-startup-failure' +import { CliInstaller } from '../cli/cli-installer' +import { installLinuxBareOrcaDispatcher } from '../cli/linux-bare-orca-dispatcher' +import { scheduleAllPendingHistoryTreeRemovals } from '../terminal-history-deletion' +import { triggerStartupNotificationRegistration } from '../ipc/startup-notification-registration' +import { mainProcessState as state } from './main-process-state' +import { logStartupMilestone } from './startup-diagnostics' + +type RuntimeService = NonNullable + +export type MainProcessRuntimeLaunchOptions = { + openMainWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow + handleMacAppActivation: () => void +} + +function settleDesktopActivation(): void { + const gate = state.desktopActivationGate + if (!gate) { + return + } + settleServeDesktopActivation(gate, { + hasPersistentPtyProvider: !(getLocalPtyProvider() instanceof LocalPtyProvider) + }) +} + +function installRuntimeRpc( + runtime: RuntimeService, + serveOptions: ReturnType | null +): OrcaRuntimeRpcServer { + migrateMobilePairingDataToCanonicalUserDataPath(app.getPath('userData')) + const isE2E = Boolean(process.env.ORCA_E2E_USER_DATA_DIR) + const requestedE2EWsPort = process.env.ORCA_E2E_RUNTIME_WS_PORT + const e2eWsPort = requestedE2EWsPort === undefined ? 0 : Number(requestedE2EWsPort) + if (isE2E && (!Number.isInteger(e2eWsPort) || e2eWsPort < 0 || e2eWsPort > 65_535)) { + throw new Error(`Invalid ORCA_E2E_RUNTIME_WS_PORT value: ${requestedE2EWsPort}`) + } + const devWsPort = is.dev && !isE2E ? 6769 : undefined + const runtimeRpc = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: getCanonicalUserDataPath(), + enableWebSocket: true, + exposeNetworkByDefault: Boolean(serveOptions) || isE2E, + ...(isE2E ? { wsPort: e2eWsPort } : {}), + ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { wsPort: serveOptions.wsPort, preferPinnedWsPort: true } + : {}), + webClientRoot: getBundledWebClientRoot() + }) + state.runtimeRpc = runtimeRpc + registerMobileHandlers(runtimeRpc, { + getRelayStatus: () => state.desktopRelayStatus, + consumePendingUnpairedDeviceAuthFailure: (webContentsId) => { + if ( + !state.mainWindow || + state.mainWindow.isDestroyed() || + state.mainWindow.webContents.id !== webContentsId || + !state.pendingUnpairedDeviceAuthFailure + ) { + return false + } + state.pendingUnpairedDeviceAuthFailure = false + return true + } + }) + runtimeRpc.setOnUnpairedDeviceAuthFailure(() => { + state.pendingUnpairedDeviceAuthFailure = true + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('mobile:unpairedDeviceAuthFailure') + } + }) + return runtimeRpc +} + +async function launchServeMode( + runtime: RuntimeService, + runtimeRpc: OrcaRuntimeRpcServer, + serveOptions: NonNullable> +): Promise { + logStartupMilestone('wsl-cli-barrier-start') + await state.managedWslCliStartupBarrierReady + logStartupMilestone('wsl-cli-barrier-resolved', { + reconciliation: state.managedWslCliReconciliationStatus + }) + await state.localPtyStartupReady + await state.localPtyProviderStartupReady + await registerHeadlessPtyRuntime( + runtime, + prepareCodexRuntimeHomeForLaunch, + () => state.store!.getSettings(), + (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target), + state.store!, + prepareCodexSessionResumeForLaunch, + { onCodexHomePtySpawned: handleCodexHomePtySpawned, onPtyExit: handlePtyExit } + ) + await runtime.refreshRestoredOrchestrationAuthority() + await runtime.reconcileLegacyWorkerTerminals() + if (state.headlessBrowserDisplayAvailable) { + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => state.agentBrowserBridge + }) + ) + } + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + await runtimeRpc.start().catch((error) => { + console.error('[runtime] Failed to start headless RPC transport:', error) + throw error + }) + settleDesktopActivation() + registerServeSignalHandlers(process, () => app.quit()) + if (process.platform === 'darwin' || process.platform === 'linux') { + try { + const cliStatus = await new CliInstaller({ + privilegedRunner: async () => { + throw new Error('serve CLI auto-install must not request administrator privileges') + } + }).install() + console.log( + `[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}` + ) + } catch (error) { + console.warn( + '[serve] orca CLI install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) { + try { + const dispatcher = await installLinuxBareOrcaDispatcher({ + resourcesPath: process.resourcesPath + }) + console.log( + `[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` + + `${dispatcher.target ? ` -> ${dispatcher.target}` : ''}` + ) + } catch (error) { + console.warn( + '[serve] bare orca dispatcher install skipped:', + error instanceof Error ? error.message : String(error) + ) + } + } + state.automations?.start() + scheduleAllPendingHistoryTreeRemovals() + await printServeReady(serveOptions) +} + +async function launchDesktopMode( + runtimeRpc: OrcaRuntimeRpcServer, + shellPathReady: Promise, + desktopWindow: BrowserWindow | null, + openMainWindow: MainProcessRuntimeLaunchOptions['openMainWindow'] +): Promise { + // Preserve the pre-split startup failure contract if composition ever hands + // this phase an incomplete runtime graph. + if (!runtimeRpc) { + throw new Error('runtime_rpc_unavailable') + } + const [win, runtimeRpcStartResult] = await Promise.all([ + Promise.resolve(desktopWindow ?? openMainWindow()), + shellPathReady + .then(() => runtimeRpc.start()) + .then( + () => ({ ok: true as const }), + (error: unknown) => { + recordRuntimeRpcStartFailure(error) + return { ok: false as const, error } + } + ) + ]) + if (!runtimeRpcStartResult.ok) { + void showRuntimeRpcStartupFailureDialog(win, runtimeRpcStartResult.error) + } + const cloudAuth = getOrcaCloudAuthConfig() + if (cloudAuth.configured) { + try { + const relayService = new DesktopRelayService({ + authConfig: cloudAuth.config, + userDataPath: getProfileUserDataPath(), + appVersion: app.getVersion(), + runtimeRpc, + onStatus: (status) => { + state.desktopRelayStatus = status + state.mainWindow?.webContents.send('mobile:relayStatusChanged', status) + } + }) + state.desktopRelayService = relayService + runtimeRpc.setMobileRelayPairingProvider({ + createPairingRelay: (relayDeviceId) => relayService.createPairingRelay(relayDeviceId), + onDeviceRevokeQueued: (item) => relayService.onDeviceRevokeQueued(item), + onDemandStateChanged: () => relayService.demandStateChanged(), + getEndpoints: (context, params) => relayService.getEndpoints(context, params), + provisionRelay: (context, params) => relayService.provisionRelay(context, params) + }) + relayService.start() + powerMonitor.on('resume', () => state.desktopRelayService?.ensureLive()) + } catch (error) { + console.warn( + '[relay] Desktop relay startup unavailable:', + error instanceof Error ? error.message : String(error) + ) + } + } + win.once('show', () => { + const store = state.store + if (store && store.getOnboarding().closedAt !== null) { + triggerStartupNotificationRegistration(store) + } + }) +} + +export async function initializeMainProcessRuntimeLaunch( + options: MainProcessRuntimeLaunchOptions +): Promise { + const runtime = state.runtime + const shellPathHydration = state.windowsShellPathHydration + if (!runtime || !shellPathHydration) { + throw new Error('Runtime and shell-path services must be initialized before launch') + } + let serveOptions: ReturnType | null = null + try { + serveOptions = state.isServeMode ? getServeOptions() : null + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + app.exit(1) + return + } + state.serveOptions = serveOptions + const runtimeRpc = installRuntimeRpc(runtime, serveOptions) + const shellPathReady = shellPathHydration.whenReady() + let desktopWindow: BrowserWindow | null = null + if (process.platform === 'win32' && app.isPackaged && !serveOptions) { + const desktopStartup = startWindowsDesktopBeforeShellPathReady({ + bindServices: bindTerminalRuntimeStartupServices, + openWindow: () => options.openMainWindow({ revealOnDidFinishLoad: true }), + shellPathReady, + startServices: startTerminalRuntimeStartupServices + }) + desktopWindow = desktopStartup.window + } else { + await shellPathReady + bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices())) + } + app.on('activate', options.handleMacAppActivation) + if (serveOptions) { + await launchServeMode(runtime, runtimeRpc, serveOptions) + return + } + await launchDesktopMode(runtimeRpc, shellPathReady, desktopWindow, options.openMainWindow) +} diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts new file mode 100644 index 00000000000..46e65c8c01f --- /dev/null +++ b/src/main/startup/main-process-runtime-service.ts @@ -0,0 +1,136 @@ +import { app } from 'electron' +import { OrcaRuntimeService } from '../runtime/orca-runtime' +import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import { browserManager } from '../browser/browser-manager' +import { loadAgentSessionClaimSigner } from '../runtime/agent-session-claim-identity' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' +import { getDaemonProvider } from '../daemon/daemon-init' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' +import type { OrchestrationEnvironmentTransport } from '../runtime/orchestration/environment-transport' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' +import { fingerprintOrchestrationPeer } from '../runtime/orchestration/environment-transport' +import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' +import { ArtifactCloudService } from '../artifacts/artifact-cloud-service' +import { SkillCloudService } from '../skills/skill-cloud-service' +import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' + +export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const activation = state.desktopActivationGate + if (!activation) { + return 'available' + } + const value = activation.getState() + return value === 'ready' ? 'openable' : value +} + +export function initializeMainProcessRuntime(): OrcaRuntimeService { + const store = state.store + const stats = state.stats + if (!store || !stats) { + throw new Error('Store and stats must be initialized before runtime') + } + const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { + resolve: (selector) => { + const environment = resolveEnvironment(app.getPath('userData'), selector) + const pairing = getPreferredPairingOffer(environment) + return { + environmentId: environment.id, + name: environment.name, + peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + } + }, + call: (selector, method, params, timeoutMs, envelope) => + callRuntimeEnvironment( + app.getPath('userData'), + selector, + method, + params, + timeoutMs, + undefined, + envelope + ) + } + const runtime = new OrcaRuntimeService(store, stats, { + agentSessionClaimSigner: loadAgentSessionClaimSigner( + getProfileUserDataPath(), + getProfileUserDataPath() + ), + getLocalProvider: () => getLocalPtyProvider(), + getSshProvider: (connectionId) => getSshPtyProvider(connectionId), + onPtyStopped: clearProviderPtyState, + onTerminalAgentStatus: (event) => agentHookServer.ingestTerminalStatus(event), + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + state.mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus, + getAgentStatusSnapshot: () => + agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), + attestAgentHookCompatibilityAuthority: (candidate) => + agentHookServer.attestCompatibilityAuthority(candidate), + retireAgentHookCompatibilityAuthority: (paneKey) => + agentHookServer.retirePaneAuthority(paneKey), + reconcileAgentStatusForEndedProcess: (paneKeys) => + agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, + getPairedDeviceName: (pairedDeviceId) => + state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, + getAdditionalAiVaultCodexHomePaths: () => + state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: state.codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), + buildAgentHookPtyEnv: () => + isAgentStatusHooksEnabled(state.store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + orchestrationEnvironmentTransport, + skillTransactionRecovery: state.skillTransactionRecovery + }) + state.runtime = runtime + runtime.prepareLegacyWorkerTerminalRecovery() + runtime.rehydrateClientHostedBrowserPages() + state.publishProviderSessionChanges?.(agentHookServer.getProviderSessionIdentities()) + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtime.notifyMobileSessionTabsChanged(worktreeId) + }) + return runtime +} + +export function configureRuntimeServices(runtime: OrcaRuntimeService): void { + const store = state.store + const claudeAccounts = state.claudeAccounts + const codexAccounts = state.codexAccounts + const rateLimits = state.rateLimits + if (!store || !claudeAccounts || !codexAccounts || !rateLimits) { + throw new Error('Account services must be initialized before runtime wiring') + } + runtime.setArtifactService( + new ArtifactCloudService(app.getPath('userData'), () => + isArtifactSharingEnabled(state.store?.getSettings()) + ) + ) + runtime.setSkillCloudService(new SkillCloudService(app.getPath('userData'))) + runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits }) + runtime.setCommitMessageAgentEnvironmentResolvers({ + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => state.claudeRuntimeAuth!.prepareForClaudeLaunch(target) + }) +} diff --git a/src/main/startup/main-process-serve.ts b/src/main/startup/main-process-serve.ts new file mode 100644 index 00000000000..b38d7b3ac62 --- /dev/null +++ b/src/main/startup/main-process-serve.ts @@ -0,0 +1,131 @@ +import { existsSync, statSync } from 'node:fs' +import { isAbsolute, join } from 'node:path' +import { app } from 'electron' +import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint' +import { notifyServeSupervisorReady } from '../serve-update-handoff' +import { mainProcessState as state } from './main-process-state' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +export function getServeOptions(argv = process.argv): ServeOptions { + const valueAfter = (flag: string): string | null => { + const index = argv.indexOf(flag) + if (index === -1) { + return null + } + const value = argv[index + 1] + return value && !value.startsWith('--') ? value : null + } + const rawPort = valueAfter('--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + return { + json: argv.includes('--serve-json'), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter('--serve-pairing-address'), + noPairing: argv.includes('--serve-no-pairing'), + mobilePairing: argv.includes('--serve-mobile-pairing'), + recipeJson: argv.includes('--serve-recipe-json'), + projectRoot: valueAfter('--serve-project-root') + } +} + +export function getBundledWebClientRoot(): string | undefined { + const appPath = app.getAppPath() + const roots = [ + join(appPath, 'out', 'web'), + // Why: unpacked electron-vite entrypoints set appPath to out/main, next to the web bundle. + join(appPath, '..', 'web') + ] + return roots.find((root) => existsSync(join(root, 'web-index.html'))) +} + +async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') + try { + return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) + } catch { + try { + return await QRCode.toString(pairingUrl, { type: 'utf8' }) + } catch { + return null + } + } +} + +export async function printServeReady(options: ServeOptions): Promise { + const runtime = state.runtime + const runtimeRpc = state.runtimeRpc + if (!runtime || !runtimeRpc) { + throw new Error('Runtime server must be initialized before printing serve readiness') + } + if (options.recipeJson) { + if (!options.projectRoot) { + throw new Error('--serve-recipe-json requires --serve-project-root') + } + if (!isAbsolute(options.projectRoot)) { + throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`) + } + if (!statSync(options.projectRoot).isDirectory()) { + throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`) + } + } + const boundEndpoint = runtimeRpc.getWebSocketEndpoint() + const advertised = boundEndpoint + ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress) + : null + const pairing = options.noPairing + ? ({ + available: false, + reason: 'disabled_by_operator', + guidance: 'Restart without --no-pairing to create a client pairing offer.' + } as const) + : runtimeRpc.createPairingOffer({ + address: options.pairingAddress, + name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`, + scope: options.mobilePairing ? 'mobile' : 'runtime' + }) + const pairingQr = + pairing.available && options.mobilePairing + ? await renderTerminalPairingQr(pairing.pairingUrl) + : null + await state.serveReadinessPublisher.publish( + { + runtimeId: runtime.getRuntimeId(), + boundEndpoint, + advertisedEndpoint: advertised?.ok ? advertised.endpoint : null, + managedWslCliReconciliation: state.managedWslCliReconciliationStatus, + pairing: pairing.available + ? { + available: true, + url: pairing.pairingUrl, + endpoint: pairing.endpoint, + deviceId: pairing.deviceId, + webClientUrl: pairing.webClientUrl, + scope: options.mobilePairing ? 'mobile' : 'runtime', + qr: pairingQr + } + : pairing + }, + options.recipeJson + ? { mode: 'recipe-json', projectRoot: options.projectRoot! } + : { mode: options.json ? 'json' : 'human' } + ) + notifyServeSupervisorReady(runtime.getRuntimeId()) +} diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts new file mode 100644 index 00000000000..330f690d268 --- /dev/null +++ b/src/main/startup/main-process-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow, Tray } from 'electron' +import { app } from 'electron' +import type { Store } from '../persistence' +import type { StatsCollector } from '../stats/collector' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' +import type { CodexAccountService } from '../codex-accounts/service' +import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' +import type { ClaudeAccountService } from '../claude-accounts/service' +import type { ClaudeRuntimeAuthService } from '../claude-accounts/runtime-auth-service' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { RateLimitService } from '../rate-limits/service' +import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import type { DesktopRelayService } from '../runtime/relay/desktop-relay-service' +import type { StarNagService } from '../star-nag/service' +import type { AgentAwakeService } from '../agent-awake-service' +import type { CrashReportStore } from '../crash-reporting/crash-report-store' +import type { AutomationService } from '../automations/service' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginKillListService } from '../plugins/plugin-kill-list-service' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { KeybindingService } from '../keybindings/keybinding-service' +import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker' +import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' +import type { AgentHookProviderSessionIdentity } from '../agent-hooks/server' +import type { EmulatorBridge } from '../emulator/emulator-bridge' +import type { GpuFallbackMarker, GpuFallbackEnvironment } from './gpu-fallback-marker' +import type { createCodexSessionMigrationScheduler } from '../codex/codex-session-migration-scheduler' +import type { getDevInstanceIdentity } from './dev-instance-identity' +import type { createServeDesktopActivationGate } from './serve-desktop-activation' +import type { ensureActiveOrcaProfile } from '../orca-profiles/profile-index-store' +import type { createWindowsShellPathHydration } from './windows-shell-path-hydration' +import type { ServeOptions } from './main-process-serve' +import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker' +import { ServeReadinessPublisher } from '../server/serve-readiness' +import { SkillShareDeepLinkState } from './skill-share-deep-link-state' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker +} from '../crash-reporting/gpu-crash-fallback-decision' +import type { GpuCrashDiagnosticsRecorder } from '../crash-reporting/gpu-crash-diagnostics' +import { createWebContentsTimedFlag } from './web-contents-timed-flag' + +/** Mutable composition-root state shared by startup, window, serve, and quit phases. */ +export const mainProcessState = { + mainWindow: null as BrowserWindow | null, + isQuitting: false, + store: null as Store | null, + stats: null as StatsCollector | null, + claudeUsage: null as ClaudeUsageStore | null, + codexUsage: null as CodexUsageStore | null, + openCodeUsage: null as OpenCodeUsageStore | null, + codexAccounts: null as CodexAccountService | null, + codexRuntimeHome: null as CodexRuntimeHomeService | null, + codexSessionMigration: null as ReturnType | null, + claudeAccounts: null as ClaudeAccountService | null, + claudeRuntimeAuth: null as ClaudeRuntimeAuthService | null, + runtime: null as OrcaRuntimeService | null, + rateLimits: null as RateLimitService | null, + runtimeRpc: null as OrcaRuntimeRpcServer | null, + serveReadinessPublisher: new ServeReadinessPublisher(), + desktopRelayService: null as DesktopRelayService | null, + desktopRelayStatus: 'offline' as RelayBrokerStatus, + pendingUnpairedDeviceAuthFailure: false, + headlessBrowserDisplayAvailable: false, + starNag: null as StarNagService | null, + agentAwakeService: null as AgentAwakeService | null, + crashReports: null as CrashReportStore | null, + unsubscribeAgentAwakeStatusChanges: null as (() => void) | null, + publishProviderSessionChanges: null as + | ((identities: AgentHookProviderSessionIdentity[]) => void) + | null, + unsubscribeSystemResumeBroadcast: null as (() => void) | null, + watcherShutdownPromise: null as Promise | null, + watcherShutdownDone: false, + automations: null as AutomationService | null, + pluginService: null as PluginService | null, + pluginKillListService: null as PluginKillListService | null, + pluginMarketplaceService: null as PluginMarketplaceService | null, + pluginMarketplaceInstaller: null as PluginMarketplaceInstaller | null, + keybindings: null as KeybindingService | null, + expectedRendererReload: createWebContentsTimedFlag(), + recoveryReloadInFlight: createWebContentsTimedFlag(), + pendingOpenSettings: createWebContentsTimedFlag(), + skillShareDeepLinks: new SkillShareDeepLinkState(), + firstWindowStartupServicesReady: Promise.resolve(), + managedWslCliReconciliationReady: Promise.resolve(), + managedWslCliStartupBarrierReady: Promise.resolve(), + managedWslCliReconciliationStatus: 'settled' as 'pending' | 'settled' | 'failed', + gpuCrashFallbackTracker: new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }), + activeGpuFallbackMarker: null as GpuFallbackMarker | null, + gpuFallbackActiveThisLaunch: false, + gpuFeatureStatus: null as Electron.GPUFeatureStatus | null, + gpuCrashDiagnostics: null as GpuCrashDiagnosticsRecorder | null, + localPtyStartupReady: Promise.resolve(), + localPtyProviderStartupReady: Promise.resolve(), + isServeMode: false, + devInstanceIdentity: null as ReturnType | null, + devAgentHookEndpointNamespace: undefined as string | undefined, + startupDiagnosticsEnabled: false, + desktopActivationGate: null as ReturnType | null, + activeOrcaProfile: null as ReturnType | null, + windowsShellPathHydration: null as ReturnType | null, + shellPathReady: Promise.resolve(), + hangDetection: null as HangDetectionMarker | null, + skillTransactionRecovery: Promise.resolve() as Promise, + serveOptions: null as ServeOptions | null, + desktopWindow: null as BrowserWindow | null, + agentBrowserBridge: null as AgentBrowserBridge | null, + emulatorBridge: null as EmulatorBridge | null, + tray: null as Tray | null +} + +/** Environment passed to GPU fallback marker helpers. */ +export function gpuFallbackEnvironment(): GpuFallbackEnvironment { + return { + appVersion: app.getVersion(), + electronVersion: process.versions.electron ?? '', + platform: process.platform + } +} diff --git a/src/main/startup/main-window-actions.ts b/src/main/startup/main-window-actions.ts new file mode 100644 index 00000000000..1ea15b90ab6 --- /dev/null +++ b/src/main/startup/main-window-actions.ts @@ -0,0 +1,165 @@ +import { app, dialog, type BrowserWindow, type Tray } from 'electron' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { checkForUpdatesFromMenu, isQuittingForUpdate } from '../updater' +import { + createSystemTray, + setMacMenuBarIconVisible, + type SystemTrayOptions +} from '../tray/system-tray' +import { ensureAutoUpdaterConfigured } from '../window/attach-main-window-services' +import { focusExistingMainWindow, safelyRevealWindow } from '../window/focus-existing-window' +import { mainProcessState as state } from './main-process-state' +import { loadMainWindow } from '../window/createMainWindow' + +// The window module injects this callback to avoid a cycle between actions and lifecycle code. +let openWindow: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +export function setMainWindowOpener( + opener: (options?: { revealOnDidFinishLoad?: boolean }) => BrowserWindow +): void { + openWindow = opener +} + +export function focusExistingWindow(): void { + focusExistingMainWindow({ + app, + getWindow: () => state.mainWindow, + openWindow, + warn: console.warn + }) +} + +export function showMainWindowFromTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + safelyRevealWindow(state.mainWindow) + return + } + if (!isQuittingForUpdate()) { + openWindow() + } +} + +export function openSettingsFromSystemMenu(): void { + showMainWindowFromTray() + const targetWindow = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null + if (!targetWindow) { + return + } + recordCrashBreadcrumb('settings_opened') + targetWindow.webContents.send('ui:openSettings') + state.pendingOpenSettings.mark(targetWindow.webContents.id, Number.POSITIVE_INFINITY) +} + +export function quitFromSystemTray(): void { + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + showMainWindowFromTray() + } + state.isQuitting = true + app.quit() +} + +export function runUserInitiatedUpdateCheck(options?: UpdateCheckOptions): void { + ensureAutoUpdaterConfigured() + checkForUpdatesFromMenu(options) +} + +export function getSystemTrayOptions(): SystemTrayOptions | null { + const store = state.store + if (!store) { + return null + } + return { + appIcon: store.getSettings().appIcon, + isDevInstance: state.devInstanceIdentity?.isDev ?? false, + devInstanceLabel: state.devInstanceIdentity?.devLabel ?? null, + onOpen: showMainWindowFromTray, + onOpenSettings: openSettingsFromSystemMenu, + onCheckForUpdates: () => { + showMainWindowFromTray() + runUserInitiatedUpdateCheck() + }, + onQuit: quitFromSystemTray + } +} + +export function syncMacMenuBarIcon(showMenuBarIcon: boolean): Tray | null { + if (process.platform !== 'darwin' || state.isServeMode) { + return null + } + const options = getSystemTrayOptions() + return options ? setMacMenuBarIconVisible(showMenuBarIcon, options) : null +} + +export function createSystemTrayDeferred( + window: BrowserWindow, + onCreated?: () => void +): () => void { + let trayCreated = false + return () => { + if (trayCreated || window.isDestroyed() || state.isQuitting || !state.store) { + return + } + trayCreated = true + if (process.platform === 'darwin') { + if (syncMacMenuBarIcon(state.store.getSettings().showMenuBarIcon !== false)) { + onCreated?.() + } + return + } + const options = getSystemTrayOptions() + if (options && createSystemTray(options)) { + onCreated?.() + } + } +} + +export function sendOpenFeatureTour(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openFeatureTour') +} + +export function sendOpenSetupGuide(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openSetupGuide') +} + +export function sendOpenCrashReport(targetWindow?: BrowserWindow | null): void { + const webContents = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow.webContents + : state.mainWindow?.webContents + webContents?.send('ui:openCrashReport') +} + +export async function presentRendererRecoveryPrompt(recentRecoveryCount: number): Promise { + if (state.isQuitting) { + return + } + const window = state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : undefined + const options = { + type: 'error' as const, + buttons: ['Reload', 'Quit'], + defaultId: 0, + cancelId: 1, + title: 'Orca keeps failing to load', + message: 'The app window crashed repeatedly and stopped reloading automatically.', + detail: `Orca tried to recover ${recentRecoveryCount} times in a row without success. This is often a graphics-driver or installation problem. Reload to try again, or quit and relaunch Orca.` + } + const { response } = window + ? await dialog.showMessageBox(window, options) + : await dialog.showMessageBox(options) + if (response === 0 && state.mainWindow && !state.mainWindow.isDestroyed()) { + recordDurableCrashBreadcrumb('renderer_recovery_manual_retry') + loadMainWindow(state.mainWindow) + } else if (response === 1) { + state.isQuitting = true + app.quit() + } +} diff --git a/src/main/startup/main-window-agent-status.ts b/src/main/startup/main-window-agent-status.ts new file mode 100644 index 00000000000..629c0550581 --- /dev/null +++ b/src/main/startup/main-window-agent-status.ts @@ -0,0 +1,138 @@ +import type { BrowserWindow } from 'electron' +import { agentHookServer } from '../agent-hooks/server' +import { setMigrationUnsupportedPtyListener } from '../agent-hooks/migration-unsupported-pty-state' +import { getDashboardPopoutWindow } from '../window/dashboard-popout-window' +import { isAskUserQuestionTool } from '../../shared/agent-question-answered-intent' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook +} from '../../shared/synthetic-agent-title' +import { + driveSyntheticTitleFromHook, + shouldSuppressCodexAutoApprovalSyntheticTitleFromHook, + stopAllSyntheticTitleSpinners +} from './synthetic-title-runtime' +import { mainProcessState as state } from './main-process-state' + +export type MainWindowAgentStatusOptions = { + window: BrowserWindow + maybeAutoRenameBranchOnFirstWork: (event: { + paneKey: string + tabId: string | undefined + worktreeId: string | undefined + payload: { state: string; prompt?: string; lastAssistantMessage?: string } + isReplay: boolean | undefined + }) => void + onRecordAgentState: (agentType: string, status: string) => void +} + +export function installMainWindowAgentStatusListeners(options: MainWindowAgentStatusOptions): void { + agentHookServer.setListener( + ({ + paneKey, + tabId, + worktreeId, + connectionId, + payload, + receivedAt, + stateStartedAt, + launchToken, + providerSession, + providerSessionOnly, + promptInteractionKey, + restoredUnconfirmed, + observation, + isReplay + }) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (providerSessionOnly) { + state.mainWindow?.webContents.send('agentStatus:set', { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(observation ? { observation } : {}), + providerSessionOnly: true + }) + return + } + if (!restoredUnconfirmed) { + options.maybeAutoRenameBranchOnFirstWork({ paneKey, tabId, worktreeId, payload, isReplay }) + } + const runtime = state.runtime + const orchestration = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey) + const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey) + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + const statusEvent = { + ...payload, + paneKey, + ...(launchToken ? { launchToken } : {}), + ...(terminalHandle ? { terminalHandle } : {}), + tabId, + worktreeId, + connectionId, + receivedAt, + stateStartedAt, + ...(providerSession ? { providerSession } : {}), + ...(promptInteractionKey ? { promptInteractionKey } : {}), + ...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}), + ...(observation ? { observation } : {}), + ...(orchestration ? { orchestration } : {}) + } + state.mainWindow?.webContents.send('agentStatus:set', statusEvent) + if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) { + getDashboardPopoutWindow()?.webContents.send('agentStatus:set', statusEvent) + } + options.onRecordAgentState(payload.agentType ?? 'unknown', payload.state) + const profile = getSyntheticAgentTitleProfile(payload.agentType) + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { + driveSyntheticTitleFromHook(paneKey, payload.state, profile) + } + } + ) + agentHookServer.setPaneStatusClearListener((clear) => { + if (state.mainWindow?.isDestroyed()) { + return + } + state.mainWindow?.webContents.send('agentStatus:clear', clear) + getDashboardPopoutWindow()?.webContents.send('agentStatus:clear', clear) + }) + setMigrationUnsupportedPtyListener((event) => { + if (state.mainWindow?.isDestroyed()) { + return + } + if (event.type === 'set') { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupported', event.entry) + } else { + state.mainWindow?.webContents.send('agentStatus:migrationUnsupportedClear', { + ptyId: event.ptyId + }) + } + }) +} + +export function clearMainWindowAgentStatusListeners(): void { + agentHookServer.setListener(null) + agentHookServer.setPaneStatusClearListener(null) + setMigrationUnsupportedPtyListener(null) + stopAllSyntheticTitleSpinners() +} diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts new file mode 100644 index 00000000000..df75bb6b0ae --- /dev/null +++ b/src/main/startup/main-window-controller.ts @@ -0,0 +1,186 @@ +import { app, type BrowserWindow } from 'electron' +import { createMainWindow, loadMainWindow } from '../window/createMainWindow' +import { + recordCrashBreadcrumb, + recordCoalescedCrashBreadcrumb +} from '../crash-reporting/crash-breadcrumb-store' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' +import { shouldRecoverRendererAfterProcessGone } from '../crash-reporting/process-gone-classification' +import { resolveConsent } from '../telemetry/consent' +import { trackAppOpenedOnce } from '../telemetry/client' +import { ensureWindowsUserDataAclGrant } from './windows-user-data-acl' +import { probeWindowsInstallDirAcl } from './windows-install-dir-acl-probe' +import { logStartupMilestone } from './startup-diagnostics' +import { notifyMainWindowBecameVisible } from '../window/main-window-visibility' +import { setTrayAttention } from '../tray/system-tray' +import { + createSystemTrayDeferred, + getSystemTrayOptions, + presentRendererRecoveryPrompt, + showMainWindowFromTray, + syncMacMenuBarIcon +} from './main-window-actions' +import { attachMainWindowCoreServices } from './main-window-core-services' +import { + clearMainWindowAgentStatusListeners, + installMainWindowAgentStatusListeners +} from './main-window-agent-status' +import { mainProcessState as state } from './main-process-state' +import { + clearExpectedRendererReload, + markExpectedRendererReload, + markRecoveryReloadInFlight, + getExpectedTeardownScope, + recordProcessGoneCrash +} from './main-window-lifecycle-flags' +import { presentGpuFallbackRecoveredLaunchPrompt } from './gpu-lifecycle' +import { maybeAutoRenameBranchOnFirstWorkFromHook } from './branch-rename-hook' +import { + resumeSyntheticTitleSpinnerTimer, + stopSyntheticTitleSpinnerTimer +} from './synthetic-title-runtime' +import { requireMainWindowServices } from './main-window-service-readiness' + +const TRAY_CREATE_FALLBACK_MS = 12_000 +const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 + +export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow { + logStartupMilestone('open-main-window-start') + const { store, keybindings } = requireMainWindowServices({ + store: state.store, + runtime: state.runtime, + stats: state.stats, + claudeUsage: state.claudeUsage, + codexUsage: state.codexUsage, + openCodeUsage: state.openCodeUsage, + rateLimits: state.rateLimits, + automations: state.automations, + codexAccounts: state.codexAccounts, + codexRuntimeHome: state.codexRuntimeHome, + claudeAccounts: state.claudeAccounts, + claudeRuntimeAuth: state.claudeRuntimeAuth, + keybindings: state.keybindings + }) + if (process.platform === 'win32') { + logStartupMilestone('acl-grant-start') + ensureWindowsUserDataAclGrant(app.getPath('userData'), { + onDone: (result) => { + logStartupMilestone('acl-grant-done', { mode: result.mode }) + if (result.mode === 'failed') { + console.warn('[win32-acl] userData ACL grant failed:', result.reason) + } + } + }) + probeWindowsInstallDirAcl({ isServeMode: state.isServeMode }) + } + const window = createMainWindow(store, { + getIsQuitting: () => state.isQuitting, + onQuitAborted: () => { + state.isQuitting = false + clearExpectedRendererReload() + }, + onRendererProcessGone: (details, webContentsId) => + recordProcessGoneCrash( + 'renderer', + 'renderer', + details.reason, + details.exitCode ?? null, + { processType: 'renderer' }, + webContentsId + ), + shouldRecoverRenderer: (details, webContentsId) => + shouldRecoverRendererAfterProcessGone({ + reason: details.reason, + expectedTeardown: getExpectedTeardownScope(webContentsId, false) + }), + onRendererRecoveryExhausted: ({ details, recentRecoveryCount }) => { + recordDurableCrashBreadcrumb('renderer_recovery_circuit_breaker_open', { + reason: details.reason, + exitCode: details.exitCode ?? null, + recentRecoveryCount + }) + void presentRendererRecoveryPrompt(recentRecoveryCount) + }, + deferLoad: true, + ...(options.revealOnDidFinishLoad === true ? { revealOnDidFinishLoad: true } : {}), + title: state.devInstanceIdentity?.name ?? app.name, + getKeybindings: () => keybindings.getOverrides(), + onBeforeReload: ({ ignoreCache, webContentsId }) => { + if (state.mainWindow?.webContents.id === webContentsId) { + markExpectedRendererReload(webContentsId) + } + recordCrashBreadcrumb('manual_reload_requested', { ignoreCache }) + }, + onBeforeRecoveryReload: (webContentsId) => { + markRecoveryReloadInFlight(webContentsId) + recordDurableCrashBreadcrumb('renderer_recovery_reload') + } + }) + recordCrashBreadcrumb('main_window_created') + logStartupMilestone('window-created') + const createTray = createSystemTrayDeferred(window, () => logStartupMilestone('tray-created')) + window.once('ready-to-show', () => { + logStartupMilestone('ready-to-show') + setImmediate(createTray) + }) + window.once('show', () => { + logStartupMilestone('window-shown') + void presentGpuFallbackRecoveredLaunchPrompt(window) + }) + const trayCreateFallback = setTimeout(createTray, TRAY_CREATE_FALLBACK_MS) + trayCreateFallback.unref?.() + const rendererWebContentsId = window.webContents.id + const onFirstWindowLoad = (): void => { + clearExpectedRendererReload(rendererWebContentsId) + recordCrashBreadcrumb('main_window_loaded') + logStartupMilestone('did-finish-load') + const currentStore = state.store + if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') { + trackAppOpenedOnce() + } + } + window.webContents.on('did-finish-load', onFirstWindowLoad) + attachMainWindowCoreServices(window, { + markExpectedRendererReload, + recordRendererReload: (ignoreCache) => + recordCrashBreadcrumb('renderer_reload_requested', { ignoreCache }) + }) + state.mainWindow = window + window.on('show', resumeSyntheticTitleSpinnerTimer) + window.on('restore', resumeSyntheticTitleSpinnerTimer) + window.on('hide', stopSyntheticTitleSpinnerTimer) + window.on('minimize', stopSyntheticTitleSpinnerTimer) + window.on('show', notifyMainWindowBecameVisible) + window.on('restore', notifyMainWindowBecameVisible) + window.on('show', () => setTrayAttention(false)) + window.on('restore', () => setTrayAttention(false)) + installMainWindowAgentStatusListeners({ + window, + maybeAutoRenameBranchOnFirstWork: maybeAutoRenameBranchOnFirstWorkFromHook, + onRecordAgentState: (agentType, status) => + recordCoalescedCrashBreadcrumb({ + name: 'agent_state_changed', + data: { agentType, state: status }, + coalesceKey: `agent:${agentType}:${status}`, + minIntervalMs: AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS + }) + }) + window.on('closed', () => { + if (state.mainWindow === window) { + state.mainWindow = null + } + clearExpectedRendererReload(rendererWebContentsId) + state.automations?.setWebContents(null) + clearMainWindowAgentStatusListeners() + }) + logStartupMilestone('load-start') + loadMainWindow(window) + return window +} + +export function configureWindowActions(): void { + // Kept as a named seam for startup composition; action callbacks are state-backed. + void getSystemTrayOptions + void showMainWindowFromTray + void syncMacMenuBarIcon +} diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts new file mode 100644 index 00000000000..f6f51e221ec --- /dev/null +++ b/src/main/startup/main-window-core-services.ts @@ -0,0 +1,130 @@ +import type { BrowserWindow } from 'electron' +import { registerCoreHandlers } from '../ipc/register-core-handlers/register-core-handlers' +import { attachMainWindowServices } from '../window/attach-main-window-services' +import { initTccPromptNotice } from '../macos-tcc-prompt-notice' +import { resolveUpdateInstallMode } from '../updater' +import { mainProcessState as state } from './main-process-state' +import { prepareCodexAiVaultSessionResume } from '../codex/codex-ai-vault-session-resume' +import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home' +import { preserveAgentAuthBeforeRestart } from '../agent-auth-restart-preservation' +import { + emitPluginWorktreeLifecycle, + handleCodexHomePtySpawned, + handlePtyExit +} from './main-process-pty-startup' +import { prepareCodexRuntimeHomeForLaunch } from './codex-launch-preparation' +import { prepareCodexSessionResumeForLaunch } from './codex-session-resume-launch' +import { isRecoveryReloadInFlight } from './main-window-lifecycle-flags' + +export function attachMainWindowCoreServices( + window: BrowserWindow, + deps: { + markExpectedRendererReload: (webContentsId: number) => void + recordRendererReload: (ignoreCache: boolean) => void + } +): void { + const store = state.store + const runtime = state.runtime + const stats = state.stats + const claudeUsage = state.claudeUsage + const codexUsage = state.codexUsage + const openCodeUsage = state.openCodeUsage + const codexAccounts = state.codexAccounts + const claudeAccounts = state.claudeAccounts + const rateLimits = state.rateLimits + const automations = state.automations + const keybindings = state.keybindings + const codexRuntimeHome = state.codexRuntimeHome + const claudeRuntimeAuth = state.claudeRuntimeAuth + if ( + !store || + !runtime || + !stats || + !claudeUsage || + !codexUsage || + !openCodeUsage || + !codexAccounts || + !claudeAccounts || + !rateLimits || + !automations || + !keybindings || + !codexRuntimeHome || + !claudeRuntimeAuth + ) { + throw new Error('Main window services must be initialized before attaching') + } + registerCoreHandlers( + store, + runtime, + stats, + claudeUsage, + codexUsage, + openCodeUsage, + codexAccounts, + claudeAccounts, + rateLimits, + window.webContents.id, + automations, + { + prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, + prepareForClaudeLaunch: (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target) + }, + state.agentAwakeService ?? undefined, + state.crashReports ?? undefined, + keybindings, + { + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery(), + prepareAiVaultSessionResume: (args) => + prepareCodexAiVaultSessionResume(args, { + runtimeHome: codexRuntimeHome, + systemCodexHomePath: resolveHostCodexSessionSourceHome(store.getSettings()) + }), + onBeforeRelaunch: async () => { + state.isQuitting = true + state.desktopRelayService?.fenceAndCloseNow() + await preserveAgentAuthBeforeRestart({ + codexRuntimeHome, + claudeRuntimeAuth, + store + }) + }, + onOrcaProfileAuthMutation: () => state.desktopRelayService?.authMutated(), + onBeforeOrcaProfileSignOut: () => state.desktopRelayService?.fenceAndCloseNow() + }, + state.pluginService ?? undefined, + state.pluginMarketplaceService && state.pluginMarketplaceInstaller + ? { marketplace: state.pluginMarketplaceService, installer: state.pluginMarketplaceInstaller } + : undefined + ) + automations.setWebContents(window.webContents) + automations.start() + attachMainWindowServices( + window, + store, + runtime, + prepareCodexRuntimeHomeForLaunch, + (target) => claudeRuntimeAuth.prepareForClaudeLaunch(target), + { + prepareCodexSessionResume: prepareCodexSessionResumeForLaunch, + awaitLocalPtyStartup: () => state.localPtyStartupReady, + awaitLocalPtyProviderStartup: () => state.localPtyProviderStartupReady, + onBeforeRendererReload: ({ ignoreCache, webContentsId }) => { + if (window.webContents.id === webContentsId) { + deps.markExpectedRendererReload(webContentsId) + } + deps.recordRendererReload(ignoreCache) + }, + isRecoveryReloadInFlight, + onCodexHomePtySpawned: handleCodexHomePtySpawned, + onPtyExit: handlePtyExit, + onBeforeUpdateQuit: () => + preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), + updateInstallMode: resolveUpdateInstallMode(state.isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle + } + ) + initTccPromptNotice(window, { deferWatchUntilReadyToShow: true }) + rateLimits.attach(window) + rateLimits.start({ fetchImmediately: false }) +} diff --git a/src/main/startup/main-window-lifecycle-flags.ts b/src/main/startup/main-window-lifecycle-flags.ts new file mode 100644 index 00000000000..2a4964c8f4e --- /dev/null +++ b/src/main/startup/main-window-lifecycle-flags.ts @@ -0,0 +1,53 @@ +import { resolveExpectedTeardownScope } from '../crash-reporting/expected-teardown-state' +import type { ExpectedTeardownScope } from '../crash-reporting/process-gone-classification' +import { recordProcessGoneCrash as recordProcessGoneCrashEvent } from '../crash-reporting/process-gone-recorder' +import { isQuittingForUpdate } from '../updater' +import { mainProcessState as state } from './main-process-state' + +export function markExpectedRendererReload(webContentsId: number, durationMs = 10_000): void { + state.expectedRendererReload.mark(webContentsId, durationMs) +} + +export function clearExpectedRendererReload(webContentsId?: number): void { + state.expectedRendererReload.clear(webContentsId) +} + +export function getExpectedTeardownScope( + webContentsId?: number, + includeSystemSessionEnd = true +): ExpectedTeardownScope { + return resolveExpectedTeardownScope({ + isQuitting: state.isQuitting, + isQuittingForUpdate: isQuittingForUpdate(), + isExpectedRendererReload: + webContentsId !== undefined && state.expectedRendererReload.matches(webContentsId), + includeSystemSessionEnd + }) +} + +export function markRecoveryReloadInFlight(webContentsId: number, durationMs = 10_000): void { + state.recoveryReloadInFlight.mark(webContentsId, durationMs) +} + +export function isRecoveryReloadInFlight(webContentsId: number): boolean { + return state.recoveryReloadInFlight.matches(webContentsId, { consume: true }) +} + +export function recordProcessGoneCrash( + source: 'renderer' | 'child', + processType: string, + reason: string, + exitCode: number | null, + details: Record, + webContentsId?: number +): void { + recordProcessGoneCrashEvent(state.crashReports, { + source, + processType, + reason, + exitCode, + expectedTeardown: getExpectedTeardownScope(webContentsId), + details, + ...(webContentsId !== undefined ? { webContentsId } : {}) + }) +} diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts new file mode 100644 index 00000000000..2adfd612ba7 --- /dev/null +++ b/src/main/startup/main-window-service-readiness.ts @@ -0,0 +1,42 @@ +/** + * Keep startup diagnostics specific and ordered. These messages are useful + * when a partial bootstrap opens a window and are part of the existing contract. + */ +const MAIN_WINDOW_SERVICE_REQUIREMENTS = [ + ['store', 'Store must be initialized before opening the main window'], + ['runtime', 'Runtime must be initialized before opening the main window'], + ['stats', 'Stats must be initialized before opening the main window'], + ['claudeUsage', 'Claude usage store must be initialized before opening the main window'], + ['codexUsage', 'Codex usage store must be initialized before opening the main window'], + ['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'], + ['rateLimits', 'Rate limit service must be initialized before opening the main window'], + ['automations', 'Automation service must be initialized before opening the main window'], + ['codexAccounts', 'Codex account service must be initialized before opening the main window'], + [ + 'codexRuntimeHome', + 'Codex runtime home service must be initialized before opening the main window' + ], + ['claudeAccounts', 'Claude account service must be initialized before opening the main window'], + [ + 'claudeRuntimeAuth', + 'Claude runtime auth service must be initialized before opening the main window' + ], + ['keybindings', 'Keybinding service must be initialized before opening the main window'] +] as const + +type MainWindowServiceKey = (typeof MAIN_WINDOW_SERVICE_REQUIREMENTS)[number][0] + +type RequiredServices> = { + [K in keyof T]: NonNullable +} + +export function requireMainWindowServices>( + services: T +): RequiredServices { + for (const [key, message] of MAIN_WINDOW_SERVICE_REQUIREMENTS) { + if (!services[key]) { + throw new Error(message) + } + } + return services as RequiredServices +} diff --git a/src/main/startup/secret-protection-report-deferral-wiring.test.ts b/src/main/startup/secret-protection-report-deferral-wiring.test.ts index eda9721667f..2d97c51d7b0 100644 --- a/src/main/startup/secret-protection-report-deferral-wiring.test.ts +++ b/src/main/startup/secret-protection-report-deferral-wiring.test.ts @@ -13,18 +13,22 @@ import { describe, expect, it } from 'vitest' * as a dead host); `false` puts the blocking probe back in front of the window. Deleting the * call entirely restores the original regression. * - * Source-level because that is the property: this runs once inside `app.whenReady()` during - * startup, so there is no seam to assert against at runtime. + * Source-level because that is the property: this runs once during the ready-phase foundation, + * so there is no seam to assert against at runtime. */ describe('secret protection report deferral wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const SCHEDULE = 'scheduleSecretProtectionGapReport({' it('arms the deferred report exactly once and never calls the blocking one directly', () => { expect(source.split(SCHEDULE).length - 1, `${SCHEDULE} should appear exactly once`).toBe(1) expect(source).toContain( - "import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'" + "import { scheduleSecretProtectionGapReport } from '../host/deferred-secret-protection-report'" ) // Why also assert the absence: re-importing the blocking entry point reinstates the // pre-window probe without touching the call site the next test pins. Note the scheduling @@ -48,26 +52,27 @@ describe('secret protection report deferral wiring', () => { // Why anchor the indent: `SCHEDULE` matches anywhere, including as the body of an added // `if (...) schedule(...)` guard, which leaves every assertion here true while the call // stops running unconditionally. Pinning it as a statement at whenReady's own indent is - // what makes "this runs on every desktop startup" the thing under test. + // what makes "this runs on every startup" the thing under test. expect(source).toContain(`\n ${SCHEDULE}`) - expect(call).toContain('deferUntilFirstWindow: !isServeMode') + expect(call).toContain('deferUntilFirstWindow: !state.isServeMode') // Why assert the constants are absent too: `!isServeMode` being present does not stop a // later property in the same literal from overriding it. expect(call).not.toContain('deferUntilFirstWindow: true') expect(call).not.toContain('deferUntilFirstWindow: false') }) - it('arms the report after the profile exists and inside app readiness', () => { + it('arms the report after the profile exists during app readiness', () => { // Why: the report remembers what it last said beside the profile data file, so arming it // before the profile is resolved would key the state off a path that does not exist yet. // Anchored on code, never a comment — a reworded comment silently becomes -1. - const ready = source.indexOf('app.whenReady().then(') - const profile = source.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const ready = entrySource.indexOf('void app.whenReady().then(async () => {') + const profile = source.indexOf('const profile = ensureActiveOrcaProfile()') const schedule = source.indexOf(SCHEDULE) expect(ready).toBeGreaterThanOrEqual(0) - expect(profile).toBeGreaterThan(ready) + expect(profile).toBeGreaterThanOrEqual(0) expect(schedule).toBeGreaterThan(profile) + expect(entrySource.indexOf('initializeMainProcessReady({')).toBeGreaterThan(ready) }) }) diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts index 0aea295c657..64d628c51ff 100644 --- a/src/main/startup/serve-desktop-activation-wiring.test.ts +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -3,59 +3,84 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' describe('serve desktop activation wiring', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const runtimeSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const runtimeServiceSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-service.ts'), + 'utf8' + ) + const windowCoreSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-window-core-services.ts'), + 'utf8' + ) it('routes second-instance and windowless app activation through one safety gate', () => { - expect(source).toContain('createServeDesktopActivationGate({') - expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') - expect(source).toContain('createMacAppActivationHandler({') - expect(source).toContain("app.on('activate', handleMacAppActivation)") - expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + expect(preflightSource).toContain('createServeDesktopActivationGate({') + expect(preflightSource).toContain( + 'acquireSingleInstanceLock(app, options.requestDesktopActivation)' + ) + expect(entrySource).toContain('createMacAppActivationHandler({') + expect(runtimeSource).toContain("app.on('activate', options.handleMacAppActivation)") + expect(runtimeServiceSource).toContain('getDesktopWindowStatus,') }) it('settles the persistent provider before headless PTY registration', () => { - const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') - const startupIndex = source.indexOf( - 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))', - appReadyIndex + const startupIndex = runtimeSource.indexOf( + 'bindTerminalRuntimeStartupServices(Promise.resolve(startTerminalRuntimeStartupServices()))' ) - const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) - const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) - const providerReadyIndex = source.indexOf('await localPtyProviderStartupReady', serveIndex) - const headlessRegistrationIndex = source.indexOf( + const serveLaunchIndex = runtimeSource.indexOf('async function launchServeMode(') + const serveDispatchIndex = runtimeSource.indexOf(' if (serveOptions) {', startupIndex) + const ptyReadyIndex = runtimeSource.indexOf( + 'await state.localPtyStartupReady', + serveLaunchIndex + ) + const providerReadyIndex = runtimeSource.indexOf( + 'await state.localPtyProviderStartupReady', + serveLaunchIndex + ) + const headlessRegistrationIndex = runtimeSource.indexOf( 'await registerHeadlessPtyRuntime(', - serveIndex + serveLaunchIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveLaunchIndex) expect(startupIndex).toBeGreaterThanOrEqual(0) - expect(startupIndex).toBeLessThan(serveIndex) - expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(serveDispatchIndex).toBeGreaterThan(startupIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveLaunchIndex) expect(providerReadyIndex).toBeGreaterThan(ptyReadyIndex) expect(headlessRegistrationIndex).toBeGreaterThan(providerReadyIndex) expect(headlessRegistrationIndex).toBeLessThan(rpcIndex) - expect(source).not.toContain( + expect(runtimeSource).not.toContain( 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' ) }) it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { - const serveIndex = source.indexOf('if (serveOptions) {') - const sentinelIndex = source.indexOf( + const serveIndex = runtimeSource.indexOf('async function launchServeMode(') + const sentinelIndex = runtimeSource.indexOf( 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', serveIndex ) - const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) - const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + const rpcIndex = runtimeSource.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = runtimeSource.indexOf('settleDesktopActivation()', rpcIndex) expect(serveIndex).toBeGreaterThanOrEqual(0) expect(sentinelIndex).toBeGreaterThan(serveIndex) expect(rpcIndex).toBeGreaterThan(sentinelIndex) expect(settleIndex).toBeGreaterThan(rpcIndex) - expect(source).not.toContain('runtime.syncWindowGraph(0,') + expect(runtimeSource).not.toContain('runtime.syncWindowGraph(0,') }) it('keeps the headless install policy after desktop promotion', () => { - expect(source).toContain('updateInstallMode: resolveUpdateInstallMode(isServeMode)') + expect(windowCoreSource).toContain( + 'updateInstallMode: resolveUpdateInstallMode(state.isServeMode)' + ) }) }) diff --git a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts index 3a389bb80d4..24c0a67803e 100644 --- a/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts +++ b/src/main/startup/serve-mode-argv-cli-redirect-order.test.ts @@ -45,14 +45,17 @@ describe('serve argv rewrite vs AppImage CLI redirect ordering', () => { expect(getAppImageCliArgs(argv, MOUNTED_APPIMAGE_ENV, REDIRECT_OPTIONS)).toEqual(['status']) }) - // Why source text: the ordering only exists as statement order at index.ts module scope, and the + // Why source text: the ordering is the preflight phase's executable statement order, and the // cases above stay green if it is reversed — nothing else would catch the regression. - it('keeps index.ts running both CLI redirects before the argv rewrite', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + it('keeps the preflight running both CLI redirects before the argv rewrite', () => { + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) const packagedRedirect = source.indexOf('maybeRedirectPackagedCliEntryLaunch({') const appImageRedirect = source.indexOf('maybeRedirectAppImageCliLaunch({') const rewrite = source.indexOf('process.argv = normalizeServeModeArgv(process.argv)') - const serveModeCheck = source.indexOf("const isServeMode = process.argv.includes('--serve')") + const serveModeCheck = source.indexOf("state.isServeMode = process.argv.includes('--serve')") expect(packagedRedirect).toBeGreaterThanOrEqual(0) expect(appImageRedirect).toBeGreaterThanOrEqual(0) diff --git a/src/main/startup/single-instance-lock-exit.electron.test.ts b/src/main/startup/single-instance-lock-exit.electron.test.ts index 7cf61a4af0f..15623c2459f 100644 --- a/src/main/startup/single-instance-lock-exit.electron.test.ts +++ b/src/main/startup/single-instance-lock-exit.electron.test.ts @@ -34,10 +34,13 @@ afterAll(() => { /** The `app.*` call the shipped lock-loss gate executes, so a revert to `app.quit()` fails here. */ function readLockLossTermination(): string { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = source.indexOf('if (!hasSingleInstanceLock) {') + const source = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const start = source.indexOf('if (!hasLock) {') expect(start).toBeGreaterThanOrEqual(0) - const end = source.indexOf('\n}', start) + const end = source.indexOf('\n }', start) expect(end).toBeGreaterThan(start) return source diff --git a/src/main/startup/single-instance-lock-headless-exit.test.ts b/src/main/startup/single-instance-lock-headless-exit.test.ts index 3c9468f595e..f79afa3f5ba 100644 --- a/src/main/startup/single-instance-lock-headless-exit.test.ts +++ b/src/main/startup/single-instance-lock-headless-exit.test.ts @@ -19,28 +19,32 @@ function readSystemdUnitBlocks(doc: string): Map { } describe('headless lock-loss exit contract', () => { - const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const preflightSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-preflight.ts'), + 'utf8' + ) + const entrySource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const doc = readFileSync(join(process.cwd(), 'docs/reference/headless-linux-server.md'), 'utf8') it('exits the lock-losing launch immediately instead of scheduling a graceful quit', () => { - const gateStart = source.indexOf('if (!hasSingleInstanceLock) {') + const gateStart = preflightSource.indexOf('if (!hasLock) {') // Why: bound the anchor — an unresolved indexOf slices to EOF and passes vacuously. expect(gateStart).toBeGreaterThanOrEqual(0) - const gateEnd = source.indexOf('\n}', gateStart) + const gateEnd = preflightSource.indexOf('\n }', gateStart) expect(gateEnd).toBeGreaterThan(gateStart) - const gate = source.slice(gateStart, gateEnd) + const gate = preflightSource.slice(gateStart, gateEnd) expect(gate).toContain('app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)') expect(gate).not.toContain('app.quit()') }) it('keeps a duplicate serve launch from promoting the live server to a desktop window', () => { - const activationStart = source.indexOf('function requestDesktopActivation(') + const activationStart = entrySource.indexOf('function requestDesktopActivation(') expect(activationStart).toBeGreaterThanOrEqual(0) - const activationEnd = source.indexOf('\n}', activationStart) + const activationEnd = entrySource.indexOf('\n}', activationStart) expect(activationEnd).toBeGreaterThan(activationStart) - expect(source.slice(activationStart, activationEnd)).toContain( + expect(entrySource.slice(activationStart, activationEnd)).toContain( 'shouldActivateDesktopForSecondInstance(argv)' ) }) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts new file mode 100644 index 00000000000..dde50f4d2c8 --- /dev/null +++ b/src/main/startup/synthetic-title-runtime.ts @@ -0,0 +1,187 @@ +import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty' +import { agentHookServer } from '../agent-hooks/server' +import type { AgentStatusState } from '../../shared/agent-status-types' +import { + getSyntheticAgentTitleProfile, + shouldDriveSyntheticAgentTitleFromHook, + type SyntheticAgentTitleProfile +} from '../../shared/synthetic-agent-title' +import { + advanceSyntheticTitleSpinnerEntries, + getSyntheticTitleSpinnerPaneKeyToStop, + type SyntheticTitleSpinnerEntry +} from '../synthetic-title-spinner' +import { shouldSendSyntheticTitleFrame } from '../synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from '../synthetic-title-frame-routing' +import { resolveTuiAgentPermissionMode } from '../../shared/tui-agent-permissions' +import { mainProcessState as state } from './main-process-state' + +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const syntheticTitleSpinnerByPaneKey = new Map< + string, + SyntheticTitleSpinnerEntry +>() +let syntheticTitleSpinnerTimer: ReturnType | null = null + +function isSyntheticTitleWindowVisible(): boolean { + const window = state.mainWindow + return window !== null && !window.isDestroyed() && window.isVisible() && !window.isMinimized() +} + +function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { + const window = state.mainWindow + if (!window || window.isDestroyed()) { + return + } + if ( + !shouldSendSyntheticTitleFrame({ + force: options.force === true, + windowVisible: isSyntheticTitleWindowVisible() + }) + ) { + return + } + state.runtime?.ingestSyntheticTitleFrame(ptyId, data) + if (shouldCopySyntheticTitleFrameToPtyData(state.store?.getSettings())) { + window.webContents.send('pty:data', { id: ptyId, data }) + } +} + +function canSendDecorativeSyntheticTitle(): boolean { + return shouldSendSyntheticTitleFrame({ + force: false, + windowVisible: isSyntheticTitleWindowVisible() + }) +} + +export function stopSyntheticTitleSpinner(paneKey: string): void { + if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { + stopSyntheticTitleSpinnerTimerIfIdle() + } +} + +export function stopAllSyntheticTitleSpinners(): void { + syntheticTitleSpinnerByPaneKey.clear() + stopSyntheticTitleSpinnerTimer() +} + +export function stopSyntheticTitleSpinnerTimer(): void { + if (syntheticTitleSpinnerTimer) { + clearInterval(syntheticTitleSpinnerTimer) + syntheticTitleSpinnerTimer = null + } +} + +function stopSyntheticTitleSpinnerTimerIfIdle(): void { + if (syntheticTitleSpinnerByPaneKey.size === 0) { + stopSyntheticTitleSpinnerTimer() + } +} + +function tickSyntheticTitleSpinners(): void { + if (!canSendDecorativeSyntheticTitle()) { + stopSyntheticTitleSpinnerTimer() + return + } + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries: syntheticTitleSpinnerByPaneKey, + frameCount: SPINNER_FRAMES.length, + getPtyIdForPaneKey + }) + for (const tick of ticks) { + sendSyntheticTitle( + tick.ptyId, + `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` + ) + } + stopSyntheticTitleSpinnerTimerIfIdle() +} + +function ensureSyntheticTitleSpinnerTimer(): void { + if ( + syntheticTitleSpinnerTimer || + syntheticTitleSpinnerByPaneKey.size === 0 || + !canSendDecorativeSyntheticTitle() + ) { + return + } + syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) +} + +export function resumeSyntheticTitleSpinnerTimer(): void { + ensureSyntheticTitleSpinnerTimer() +} + +export function driveSyntheticTitleFromHook( + paneKey: string, + agentState: AgentStatusState, + profile: SyntheticAgentTitleProfile +): void { + const ptyId = getPtyIdForPaneKey(paneKey) + if (!ptyId) { + return + } + if (agentState === 'working') { + const existing = syntheticTitleSpinnerByPaneKey.get(paneKey) + const frame = existing ? existing.frame : 0 + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[frame]} ${profile.workingLabel}\x07`) + if (existing) { + existing.profile = profile + return + } + syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) + ensureSyntheticTitleSpinnerTimer() + return + } + stopSyntheticTitleSpinner(paneKey) + const needsUserInput = agentState === 'blocked' || agentState === 'waiting' + const label = needsUserInput ? profile.permissionLabel : profile.idleLabel + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { agentArgs?: string | null; agentEnv?: Record | null } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function initializeSyntheticTitleRuntime(): void { + registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) + // Retire synthetic titles with either pane-scoped clears or explicit status drops. + agentHookServer.subscribePaneStatusClear((clear) => { + const paneKey = getSyntheticTitleSpinnerPaneKeyToStop(clear) + if (paneKey) { + stopSyntheticTitleSpinner(paneKey) + } + }) + agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) +} + +export function driveSyntheticTitleForAgentStatus( + paneKey: string, + agentType: string | null | undefined, + agentState: AgentStatusState +): void { + const profile = getSyntheticAgentTitleProfile(agentType) + if (profile && shouldDriveSyntheticAgentTitleFromHook(agentType, agentState)) { + driveSyntheticTitleFromHook(paneKey, agentState, profile) + } +} diff --git a/src/main/startup/web-contents-timed-flag.ts b/src/main/startup/web-contents-timed-flag.ts new file mode 100644 index 00000000000..4e281c29a02 --- /dev/null +++ b/src/main/startup/web-contents-timed-flag.ts @@ -0,0 +1,31 @@ +/** A short-lived renderer intent scoped to one WebContents instance. */ +export function createWebContentsTimedFlag(defaultDurationMs = 10_000): { + mark: (webContentsId: number, durationMs?: number) => void + clear: (webContentsId?: number) => void + matches: (webContentsId: number, options?: { consume?: boolean }) => boolean +} { + let state: { webContentsId: number; until: number } | null = null + return { + mark(webContentsId, durationMs = defaultDurationMs) { + state = { webContentsId, until: Date.now() + durationMs } + }, + clear(webContentsId) { + if (webContentsId === undefined || state?.webContentsId === webContentsId) { + state = null + } + }, + matches(webContentsId, options) { + if (!state || Date.now() > state.until) { + state = null + return false + } + if (state.webContentsId !== webContentsId) { + return false + } + if (options?.consume) { + state = null + } + return true + } + } +} diff --git a/src/main/updater.ts b/src/main/updater.ts index 912c681cfad..e22e18e84ba 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,11 +1,7 @@ -/* eslint-disable max-lines */ -import { app, BrowserWindow, powerMonitor } from 'electron' -import { is } from '@electron-toolkit/utils' +import type { BrowserWindow } from 'electron' import type { LinuxPackageInstallInstructions, - LinuxPackageInstallRecovery, UpdateCheckOptions, - UpdateSource, UpdateStatus } from '../shared/update-status-types' import type { @@ -13,2341 +9,86 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { - isWindowsSignatureCheckUnavailableFailure, - isWindowsSignatureMismatchFailure -} from '../shared/updater-windows-signature-check' -import { killAllPty } from './ipc/pty' -import { withUpdaterSpan } from './observability/instrumentation' -import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' -import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' -import { runWithLaunchPath } from './startup/hydrate-shell-path' -import { - beginMacUpdateDownload, - deferMacQuitUntilInstallerReady, - isMacInstallerReady, - markMacQuitAndInstallInFlight, - resetMacInstallState -} from './updater-mac-install' -import { - armUpdateInstallExitWatchdog, - disarmUpdateInstallExitWatchdog -} from './update-install-exit-watchdog' -import { registerAutoUpdaterHandlers } from './updater-events' -import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics' -import { getLinuxRootPackageType } from './linux-update-package-type' -import { - beginLinuxPackageInstallDiagnosticCapture, - createUpdaterDiagnosticLogger, - endLinuxPackageInstallDiagnosticCapture, - getLinuxPackageInstallDiagnostic, - parseLinuxPackageInstallExitCode, - redactLinuxPackageInstallText, - type LinuxPackageInstallDiagnostic -} from './linux-package-install-diagnostic' -import { - clearTrackedLinuxPackageArtifact, - getTrackedLinuxPackageArtifact, - resolveLinuxPackageInstallInstructions, - revalidateLinuxPackageForInstall, - revealLinuxPackage, - type LinuxPackageArtifact, - type LinuxPackageRecoveryUnavailableReason -} from './linux-package-update-recovery' -import { - compareVersions, - isBenignCheckFailure, - isMissingUpdateManifestFailure, - isPrereleaseVersion, - statusesEqual -} from './updater-fallback' -import { - fetchNewerReleaseTagsWithReadiness, - getReleaseDownloadUrl -} from './updater-prerelease-feed' -import { fetchNudge, shouldApplyNudge } from './updater-nudge' -import { - failServeUpdateHandoff, - getServeUpdateHandoffFailure, - hasServeUpdateSupervisor, - requestServeUpdateHandoff -} from './serve-update-handoff' -import type { LocalBuildFeed } from './local-builds/local-build-feed-server' -import { listReleaseBuilds, resolveTargetBuild } from './updater-release-builds' -import { - DEV_CHANNEL_PLATFORM_LABEL, - getVersionChannel, - hasDedicatedReleaseRepo, - isChannelSupportedOnPlatform, - RELEASE_CHANNEL_LABELS, - requiresManualDevChannelInstall, - type ReleaseBuild, - type ReleaseChannel -} from '../shared/release-channel' +import type { ReleaseBuild, ReleaseChannel } from '../shared/release-channel' +import { UpdaterSetup, type UpdaterSetupOptions } from './updater/updater-setup' +import type { UpdateInstallMode } from './updater/updater-state' -type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' -type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } -type PrimaryEventSuppression = { failureKey: string; error: unknown } -type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' -type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' -// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. -class ReleaseFeedPreflightError extends Error { - constructor( - readonly reason: ReleaseFeedPreflightFailure, - readonly releaseChannel: UpdateCheckVariant, - message: string - ) { - super(message) - this.name = 'ReleaseFeedPreflightError' - } -} -type ReleaseFeedPreflightResult = 'ready' | 'not-available' -export type UpdateInstallMode = - | 'interactive' - | 'supervised-headless-serve' - | 'unsupported-headless-serve' +// Keep one service instance so all public API calls share updater state and event listeners. +const updater = new UpdaterSetup() -const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 -const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 -// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. -const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 -const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 -const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 -const QUIT_AND_INSTALL_DELAY_MS = 100 -const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 -const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 -const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 - -let mainWindowRef: BrowserWindow | null = null -let currentStatus: UpdateStatus = { state: 'idle' } -let userInitiatedCheck = false -let onBeforeQuitCleanup: (() => void | Promise) | null = null -let autoUpdaterInitialized = false -// Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. -let includePrereleaseActive = false -let availableVersion: string | null = null -let availableReleaseUrl: string | null = null -let pendingCheckFailureKey: string | null = null -let pendingCheckFailurePromise: Promise | null = null -let autoUpdateCheckTimer: ReturnType | null = null -let nudgeCheckTimer: ReturnType | null = null -let pendingQuitAndInstallTimer: ReturnType | null = null -let quitAndInstallInProgress = false -// Why: the pre-install digest re-proof streams the whole package, so a second install request can -// arrive while it runs — after the quit timer was cleared but before the handoff owns the process. -let linuxPackageRevalidationInFlight = false -let updateInstallMode: UpdateInstallMode = 'interactive' -let lastInstallDeferralVersion = { download: null as string | null, install: null as string | null } -// Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. -let updateInstallCommitted = false -// Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. -let quitAndInstallNativeInvoked = false -// Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. -let lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null -let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null -let _getLastUpdateCheckAt: (() => number | null) | null = null -let backgroundCheckLaunchPending = false -// Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. -let backgroundCheckPromotedToUserInitiated = false -let updateCheckStallTimer: ReturnType | null = null -let updateCheckSilentSettleTimer: ReturnType | null = null -let updateCheckAttemptSequence = 0 -let activeUpdateCheckAttemptId: number | null = null -let activeUpdateCheckLaunchAttemptId: number | null = null -let activeUpdateCheckEventAttemptId: number | null = null -let updateAvailableEventPendingAttemptId: number | null = null -let pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null -let activeUpdateNudgeId: string | null = null -let awaitingNudgeCheckOutcome = false -let nudgeCheckInFlight = false -let lastNudgeCheckAt = 0 -let publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null -let pendingPrereleaseFallback: { - primaryTag: string - fallbackTag: string - // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. - userInitiated: boolean - suppressedPrimaryPromiseFailureKey: string | null - suppressedPrimaryEventFailure: PrimaryEventSuppression | null - suppressedFallbackPromiseFailureKey: string | null - suppressedFallbackEventFailureKey: string | null - fallbackResultHandled: boolean - fallbackCheckingForUpdateSeen: boolean - retryLaunched: boolean -} | null = null - -let _getPendingUpdateNudgeId: (() => string | null) | null = null -let _getDismissedUpdateNudgeId: (() => string | null) | null = null -let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null -let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null -// Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. -let downloadInFlight = false -/** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ -let quittingForUpdate = false -let autoUpdater: ElectronAutoUpdater | null = null -let activeUpdateSource: 'release' | UpdateSource = 'release' -let activeLocalBuildFeed: LocalBuildFeed | null = null -let localBuildSelectionInProgress = false -// Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade -// like local builds — but off a real release feed, not a loopback server. -let pinnedBuildSelectionInProgress = false -// Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a -// deliberate downgrade, so newer-only gates must yield to it too. -let isPinnedBuildActive = false -let getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null - -function getAutoUpdater(): ElectronAutoUpdater { - if (!autoUpdater) { - autoUpdater = loadElectronAutoUpdater() - } - return autoUpdater -} - -function clearAvailableUpdateContext(): void { - availableVersion = null - availableReleaseUrl = null -} - -function closeLocalBuildFeed(): void { - const feed = activeLocalBuildFeed - activeLocalBuildFeed = null - if (feed) { - void feed.close() - } -} - -function restoreReleaseUpdateSource(): void { - closeLocalBuildFeed() - activeUpdateSource = 'release' - isPinnedBuildActive = false - if (autoUpdater) { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - // Why: a pinned jump forces allowPrerelease on; leaving it set would opt - // every later background check into the RC channel behind the user's back. - autoUpdater.allowPrerelease = includePrereleaseActive - } -} - -function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { - clearAvailableUpdateContext() - if ( - currentStatus.state !== 'error' || - currentStatus.message !== message || - currentStatus.userInitiated !== userInitiated || - currentStatus.source !== 'local' - ) { - sendStatus({ state: 'error', message, userInitiated, source: 'local' }) - } - restoreReleaseUpdateSource() -} - -function clearPrereleaseFallbackContext(): void { - pendingPrereleaseFallback = null -} - -function clearPendingUpdateNudge(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false - _setPendingUpdateNudgeId?.(null) -} - -function deferPendingUpdateNudgeUntilRetry(): void { - activeUpdateNudgeId = null - awaitingNudgeCheckOutcome = false -} - -function clearPublishingWindowLastGoodCheck(): void { - publishingWindowLastGoodCheck = null -} - -function getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { - return publishingWindowLastGoodCheck -} - -function getPersistedPendingUpdateNudgeId(): string | null { - return _getPendingUpdateNudgeId?.() ?? null -} - -function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { - // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. - if (!activeUpdateNudgeId) { - return status - } - if (status.state === 'idle' || status.state === 'checking' || status.state === 'not-available') { - return status - } - return { ...status, activeNudgeId: activeUpdateNudgeId } -} - -/** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ -function sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { - const pendingUserInitiatedCheckVariant = pendingUserInitiatedCheckAfterInFlight - const shouldLaunchPendingUserInitiatedCheck = - pendingUserInitiatedCheckVariant !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - const shouldPreserveNudgeForPublishingWindow = - publishingWindowLastGoodCheck !== null && - (status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error') - if (awaitingNudgeCheckOutcome) { - if (status.state === 'available') { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. - deferPendingUpdateNudgeUntilRetry() - } else { - awaitingNudgeCheckOutcome = false - } - } else if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'error' - ) { - if (shouldPreserveNudgeForPublishingWindow) { - // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. - deferPendingUpdateNudgeUntilRetry() - } else { - // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. - if (activeUpdateNudgeId) { - _setDismissedUpdateNudgeId?.(activeUpdateNudgeId) - } - clearPendingUpdateNudge() - } - } - } - - const sourcedStatus: UpdateStatus = - activeUpdateSource === 'release' ? status : { ...status, source: activeUpdateSource } - const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) - - if (isUpdateCheckResultState(status.state)) { - finishActiveUpdateCheckAttempt() - } - - if ( - status.state === 'idle' || - status.state === 'not-available' || - status.state === 'available' || - status.state === 'error' - ) { - clearPublishingWindowLastGoodCheck() - } - - // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. - if ( - decoratedStatus.state === 'downloading' || - decoratedStatus.state === 'error' || - decoratedStatus.state === 'idle' - ) { - downloadInFlight = false - } - if (shouldLaunchPendingUserInitiatedCheck) { - // Why: a forced status must still land before the queued check restarts the cycle. - if (options?.force) { - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) - } - launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) - return - } - if (!options?.force && statusesEqual(currentStatus, decoratedStatus)) { - return - } - currentStatus = decoratedStatus - mainWindowRef?.webContents.send('updater:status', decoratedStatus) -} - -function getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { - switch (variant) { - case 'perf': - return { includePrerelease: true, includePerfPrerelease: true } - case 'prerelease': - return { includePrerelease: true } - case 'default': - return { includePrerelease: false } - } -} - -function getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { - if (options?.includePerfPrerelease) { - return 'perf' - } - if (options?.includePrerelease) { - return 'prerelease' - } - // Why: a persisted 'rc' override makes every routine check follow the RC series - // without the user re-holding shift; the dev channels need an explicit tag, so - // neither is a routine-check variant. - if (getReleaseChannelOverride?.() === 'rc') { - return 'prerelease' - } - return 'default' -} - -function launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { - pendingUserInitiatedCheckAfterInFlight = null - setTimeout(() => { - // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. - if (currentStatus.state === 'checking') { - currentStatus = { state: 'idle' } - } - checkForUpdatesFromMenu(getOptionsForUpdateCheckVariant(variant)) - }, 0) -} - -function clearBackgroundCheckLaunchPending(): void { - backgroundCheckLaunchPending = false -} - -function clearUpdateCheckStallTimer(): void { - if (!updateCheckStallTimer) { - return - } - clearTimeout(updateCheckStallTimer) - updateCheckStallTimer = null -} - -function clearUpdateCheckSilentSettleTimer(): void { - if (!updateCheckSilentSettleTimer) { - return - } - clearTimeout(updateCheckSilentSettleTimer) - updateCheckSilentSettleTimer = null -} - -function clearUpdateCheckTimers(): void { - clearUpdateCheckStallTimer() - clearUpdateCheckSilentSettleTimer() -} - -function finishActiveUpdateCheckAttempt(): void { - activeUpdateCheckAttemptId = null - activeUpdateCheckLaunchAttemptId = null - activeUpdateCheckEventAttemptId = null - clearUpdateCheckTimers() -} - -function getActiveUpdateCheckEventAttemptId(): number | null { - if (activeUpdateCheckAttemptId === null) { - return null - } - if (activeUpdateCheckEventAttemptId !== activeUpdateCheckAttemptId) { - return null - } - return activeUpdateCheckAttemptId -} - -function isActiveUpdateCheckAttempt(attemptId: number): boolean { - return activeUpdateCheckAttemptId === attemptId -} - -function markUpdateCheckEventAttempt(): boolean { - if (activeUpdateCheckAttemptId === null) { - return false - } - if (activeUpdateCheckLaunchAttemptId !== activeUpdateCheckAttemptId) { - return false - } - activeUpdateCheckEventAttemptId = activeUpdateCheckAttemptId - return true -} - -function markUpdateCheckLaunched(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - activeUpdateCheckLaunchAttemptId = attemptId -} - -function markUpdateAvailableEventPending(attemptId: number | null): void { - updateAvailableEventPendingAttemptId = attemptId -} - -function clearUpdateAvailableEventPending(attemptId: number | null): void { - if (updateAvailableEventPendingAttemptId !== attemptId) { - return - } - updateAvailableEventPendingAttemptId = null -} - -function armUpdateCheckStallTimer(attemptId: number): void { - clearUpdateCheckStallTimer() - updateCheckStallTimer = setTimeout(() => { - updateCheckStallTimer = null - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - if (currentStatus.state === 'checking') { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - void sendCheckFailureStatus( - 'Update check timed out. Try again in a few minutes.', - wasUserInitiated, - 'promise' - ) - return - } - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - }, UPDATE_CHECK_STALL_TIMEOUT_MS) -} - -function beginUpdateCheckAttempt(): number { - finishActiveUpdateCheckAttempt() - updateAvailableEventPendingAttemptId = null - updateCheckAttemptSequence += 1 - activeUpdateCheckAttemptId = updateCheckAttemptSequence - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) - // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. - writeMainThreadDiagnosticMarker('updater-check-attempt') - return activeUpdateCheckAttemptId -} - -function rearmActiveUpdateCheckStallTimer(): void { - if (activeUpdateCheckAttemptId === null) { - return - } - armUpdateCheckStallTimer(activeUpdateCheckAttemptId) -} - -function getSettledCheckUserInitiated(): boolean | undefined { - return userInitiatedCheck || backgroundCheckPromotedToUserInitiated || undefined -} - -function isUpdateCheckResultState(state: UpdateStatus['state']): boolean { - return ( - state === 'idle' || - state === 'not-available' || - state === 'available' || - state === 'error' || - state === 'downloading' || - state === 'downloaded' - ) -} - -function consumeSilentCheckShortRetryReason(): boolean { - if (publishingWindowLastGoodCheck !== null) { - return true - } - return consumeMissingManifestPrereleaseFallbackResult() !== null -} - -function completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { - const shouldRetrySoon = consumeSilentCheckShortRetryReason() - clearAvailableUpdateContext() - if (shouldRetrySoon) { - // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - return true - } - recordCompletedUpdateCheck() - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - return false -} - -function settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - if (updateAvailableEventPendingAttemptId === attemptId) { - return - } - if (currentStatus.state !== 'checking') { - if (backgroundCheckLaunchPending) { - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - const shouldRetrySoon = completeSilentUpdateCheck(userInitiated) - if (awaitingNudgeCheckOutcome) { - if (shouldRetrySoon) { - deferPendingUpdateNudgeUntilRetry() - return - } - sendStatus({ state: 'not-available', userInitiated }) - } - } - return - } - finishActiveUpdateCheckAttempt() - clearBackgroundCheckLaunchPending() - backgroundCheckPromotedToUserInitiated = false - userInitiatedCheck = false - completeSilentUpdateCheck(userInitiated) - sendStatus({ state: 'not-available', userInitiated }) -} - -function handleSettledUpdateCheckPromise(attemptId: number): void { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - clearUpdateCheckSilentSettleTimer() - // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. - updateCheckSilentSettleTimer = setTimeout(() => { - updateCheckSilentSettleTimer = null - settleSilentUpdateCheck(attemptId, getSettledCheckUserInitiated()) - }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) -} - -function shouldHandleUpdaterErrorEvent(): boolean { - if (getActiveUpdateCheckEventAttemptId() !== null) { - return true - } - // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. - return ( - downloadInFlight || - currentStatus.state === 'downloading' || - currentStatus.state === 'downloaded' - ) -} - -function sendErrorStatus(message: string, userInitiated?: boolean): void { - if ( - currentStatus.state === 'error' && - currentStatus.message === message && - currentStatus.userInitiated === userInitiated - ) { - return - } - // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. - if (isWindowsSignatureCheckUnavailableFailure(message)) { - recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { - level: 'warn', - message: 'Windows update signature check could not run' - }) - } - sendStatus({ state: 'error', message, userInitiated }) -} - -function getKnownReleaseUrl(): string | undefined { - return availableReleaseUrl ?? undefined -} - -function hasInstallableDownloadedVersion(): boolean { - return ( - availableVersion !== null && - // Why: local builds and pinned dev jumps may intentionally move backwards. - (activeUpdateSource !== 'release' || - isPinnedBuildActive || - compareVersions(availableVersion, app.getVersion()) > 0) - ) -} - -function getPendingInstallVersion(): string { - if (availableVersion) { - return availableVersion - } - if (currentStatus.state === 'downloading' || currentStatus.state === 'downloaded') { - return currentStatus.version - } - return '' -} - -function deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { - if (updateInstallMode !== 'unsupported-headless-serve') { - return false - } - const diagnosticVersion = version || 'unknown' - if (lastInstallDeferralVersion[phase] !== diagnosticVersion) { - lastInstallDeferralVersion[phase] = diagnosticVersion - recordUpdaterLifecycle( - 'headless_serve_install_deferred', - { phase, version: version || null }, - { - level: 'warn', - message: 'Update install deferred while hosting orca serve' - } - ) - } - sendErrorStatus( - 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', - true - ) - return true -} +export type { UpdateInstallMode, UpdaterSetupOptions } export function resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { - if (!isServeMode) { - return 'interactive' - } - return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' -} - -function getCheckFailureKey(message: string, userInitiated?: boolean): string { - return `${userInitiated ? 'user' : 'auto'}:${message}` -} - -function clearPrereleaseFallbackContextIfSettled(): void { - if ( - pendingPrereleaseFallback?.fallbackResultHandled && - !pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && - !pendingPrereleaseFallback.suppressedPrimaryEventFailure && - !pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && - !pendingPrereleaseFallback.suppressedFallbackEventFailureKey - ) { - clearPrereleaseFallbackContext() - } -} - -async function performQuitAndInstall(): Promise { - if (quitAndInstallInProgress || linuxPackageRevalidationInFlight) { - recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) - return - } - - if (pendingQuitAndInstallTimer) { - clearTimeout(pendingQuitAndInstallTimer) - pendingQuitAndInstallTimer = null - } - - const pendingVersion = getPendingInstallVersion() - if (deferHeadlessServeInstall('install', pendingVersion)) { - return - } - // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about - // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a - // swapped or vanished package aborts instead of being installed as root. The synchronous guard - // keeps every non-Linux install on its existing timing. - if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) { - // Why: the renderer armed its restart before invoking, and it infers the abort from the error - // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where - // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. - mainWindowRef?.webContents.send('updater:quitAndInstallAborted') - return - } - quitAndInstallInProgress = true - - markMacQuitAndInstallInFlight() - - // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. - quittingForUpdate = true - - try { - await withUpdaterSpan({ stage: 'install' }, async (span) => { - span.setAttribute('updater.version', pendingVersion || 'unknown') - span.setAttribute('updater.platform', process.platform) - span.setAttribute( - 'updater.macosInstallerReady', - process.platform === 'darwin' ? isMacInstallerReady() : true - ) - recordUpdaterLifecycle('quit_and_install_started', { - version: pendingVersion || null, - macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true - }) - span.addEvent('pre_quit_cleanup_start') - await runBeforeUpdateQuitCleanup() - span.addEvent('pre_quit_cleanup_done') - - if ( - updateInstallMode === 'supervised-headless-serve' && - !requestServeUpdateHandoff(pendingVersion) - ) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { version: pendingVersion || null }, - { - level: 'warn', - message: 'Could not persist supervised serve update handoff' - } - ) - sendErrorStatus( - 'Could not prepare the supervised server restart. Orca remains running.', - true - ) - resetQuitForUpdateState() - // Why: a bare return would exit this span Success and hide the aborted install from tracing. - span.fail('Could not persist the supervised serve update handoff') - return - } - - recordUpdaterLifecycle('quit_and_install_invoking_native', { - version: pendingVersion || null - }) - // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. - if (!quitAndInstallInProgress) { - return - } - // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. - quitAndInstallNativeInvoked = true - // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. - const supervisorOwnsRelaunch = updateInstallMode === 'supervised-headless-serve' - // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. - beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) - try { - runWithLaunchPath(() => - getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) - ) - } finally { - const diagnostic = endLinuxPackageInstallDiagnosticCapture() - // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. - lastInstallAttemptDiagnostic = quitAndInstallInProgress ? diagnostic : null - } - span.addEvent('native_quit_and_install_invoked') - - // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. - if (!quitAndInstallInProgress) { - // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. - span.fail( - currentStatus.state === 'error' - ? currentStatus.message - : 'quitAndInstall returned without invoking the installer' - ) - return - } - - // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the - // package is installed. Commit here or a throw in the cleanup below is reported as an install - // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. - if (getLinuxRootPackageType() !== null) { - updateInstallCommitted = true - armUpdateInstallExitWatchdog() - } - - killAllPty() - span.addEvent('local_pty_kill_all') - - for (const win of BrowserWindow.getAllWindows()) { - win.removeAllListeners('close') - } - span.addEvent('window_close_listeners_removed', { - windowCount: BrowserWindow.getAllWindows().length - }) - - // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. - if (!updateInstallCommitted && (process.platform !== 'darwin' || isMacInstallerReady())) { - updateInstallCommitted = true - // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). - armUpdateInstallExitWatchdog() - } - }) - } catch (error) { - // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is - // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear - // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. - if (updateInstallCommitted) { - recordUpdaterLifecycle( - 'post_commit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install cleanup failed after commit; install already applied' - } - ) - return - } - // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. - const quitAndInstallNativeInvokedBeforeReset = quitAndInstallNativeInvoked - const recoveryStatus = - quitAndInstallNativeInvokedBeforeReset && !updateInstallCommitted - ? buildLinuxPackageInstallFailureStatus(error) - : null - failServeUpdateHandoff('Could not invoke the native updater.') - resetQuitForUpdateState() - recordUpdaterLifecycle( - 'quit_and_install_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Could not start update install' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. - // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. - message: quitAndInstallNativeInvokedBeforeReset - ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - : 'Could not restart to install the update. Quit and reopen Orca, then try again.' - } - ) - } -} - -function resetQuitForUpdateState(): void { - quitAndInstallInProgress = false - quittingForUpdate = false - updateInstallCommitted = false - quitAndInstallNativeInvoked = false - lastInstallAttemptDiagnostic = null - disarmUpdateInstallExitWatchdog() - resetMacInstallState() -} - -/** - * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage - * it — so keep that advice there. Everywhere else a restart is not known to help. - */ -function getPreCommitInstallFailureMessage(): string { - return process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' -} - -/** - * Sends an install-failure status even when it repeats the current one. "Try Automatic Install - * Again" usually fails identically, and a deduped status would never reach the preload abort relay, - * leaving the renderer stuck in its restart checkpoint. - */ -function sendInstallFailureStatus(status: UpdateStatus): void { - sendStatus(status, { force: true }) -} - -const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 - -/** - * Appends the updater's own text to the generic install-failure copy. Without it the only record of - * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` - * command the user has to run by hand, and remote clients get nothing but "it didn't come back". - */ -function withInstallFailureCause(baseMessage: string, error: unknown): string { - const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' - // Why: the retained-package card runs its text through this same sanitizer, so a home directory, - // user name, or terminal escape must not reach the card merely because no artifact was tracked. - const redacted = - redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' - const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) - if (!cause || cause === 'Unknown error') { - return baseMessage - } - // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. - if ( - isWindowsSignatureCheckUnavailableFailure(cause) || - isWindowsSignatureMismatchFailure(cause) - ) { - return cause - } - return `${baseMessage} (${cause})` -} - -/** - * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can - * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. - */ -function buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return null - } - const pendingVersion = getPendingInstallVersion() - if (pendingVersion && pendingVersion !== artifact.version) { - return null - } - const diagnostic = getLinuxPackageInstallDiagnostic() ?? lastInstallAttemptDiagnostic - // Why: the reason was classified from the original output, before redaction could rewrite a match. - const reason = diagnostic?.reason ?? 'package-install-failed' - // Durable data carries classification only — never the package path, home path, command, or stderr. - const exitCode = parseLinuxPackageInstallExitCode(error) - recordUpdaterLifecycle( - 'linux_package_install_failed', - { - packageType: artifact.packageType, - reason, - // Omitted rather than null when the child status could not be parsed. - ...(exitCode === null ? {} : { exitCode }), - version: artifact.version, - errorType: error instanceof Error ? error.name : typeof error - }, - { level: 'warn', message: 'Linux package install failed; cached package retained' } - ) - // Why: this text is shown in the card, so it gets the same redaction as retained stderr. - const message = - diagnostic?.message ?? - (error instanceof Error ? redactLinuxPackageInstallText(error.message, artifact.path) : null) ?? - 'The system package installer did not start.' - return { - state: 'error', - message, - recovery: { - kind: 'linux-package-install', - packageType: artifact.packageType, - reason, - version: artifact.version - } - } -} - -// Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. -function handleQuitAndInstallFailure(error?: unknown): boolean { - if (!quitAndInstallInProgress || !quitAndInstallNativeInvoked || updateInstallCommitted) { - return false - } - const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) - failServeUpdateHandoff('The native updater rejected the install request.') - resetQuitForUpdateState() - // Durable data carries classification only — the cause text stays on the status the user can read. - recordUpdaterLifecycle( - 'quit_and_install_failed_via_event', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Update install could not start; recovered app state' - } - ) - sendInstallFailureStatus( - recoveryStatus ?? { - state: 'error', - message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) - } - ) - return true -} - -// Why: while quit-and-install owns the process, general check/download error UI must not run. -function isQuitAndInstallHandoffActive(): boolean { - return quitAndInstallInProgress -} - -async function runBeforeUpdateQuitCleanup(): Promise { - if (!onBeforeQuitCleanup) { - return - } - - let timeout: ReturnType | null = null - const cleanup = Promise.resolve() - .then(() => onBeforeQuitCleanup?.()) - .catch((error) => { - recordUpdaterLifecycle( - 'pre_quit_cleanup_failed', - { errorType: error instanceof Error ? error.name : typeof error }, - { - level: 'warn', - message: 'Pre-quit cleanup failed; continuing update install' - } - ) - }) - const timeoutResult = new Promise<'timeout'>((resolve) => { - timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) - }) - - const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) - if (result === 'timeout') { - recordUpdaterLifecycle( - 'pre_quit_cleanup_timeout', - { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, - { - level: 'warn', - message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` - } - ) - return - } - - if (timeout) { - clearTimeout(timeout) - } -} - -async function sendCheckFailureStatus( - message: string, - userInitiated?: boolean, - source: CheckFailureSource = 'promise', - sourceError?: unknown -): Promise { - if (activeUpdateSource === 'local') { - sendLocalBuildErrorAndRestore(message, userInitiated) - return - } - if (isPinnedBuildActive) { - // Why: a failed pinned jump must hand the feed back before surfacing the - // error, or the pin blocks background checks for the process lifetime. - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ state: 'error', message, userInitiated }) - return - } - const failureKey = getCheckFailureKey(message, userInitiated) - if ( - source === 'promise' && - pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - if ( - source === 'fallback-promise' && - pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey - ) { - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return - } - - if ( - retryPrereleaseFallbackAfterMissingManifest( - message, - userInitiated, - source, - failureKey, - sourceError - ) - ) { - return - } - - if (pendingCheckFailureKey === failureKey && pendingCheckFailurePromise) { - return pendingCheckFailurePromise - } - - const handleFailure = async (): Promise => { - if (isBenignCheckFailure(message) || isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). - console.warn('[updater] benign check failure:', message) - clearAvailableUpdateContext() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - if (userInitiated) { - // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. - sendErrorStatus( - isStableReleaseNotReadyFailure(sourceError) - ? "A newer release isn't available for this device yet. Check again later." - : "Couldn't reach the update server. Try again in a few minutes.", - true - ) - } else { - if (isRetryableReleaseFeedPreflightFailure(sourceError)) { - // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. - deferPendingUpdateNudgeUntilRetry() - } - sendStatus({ state: 'idle' }) - } - return - } - - clearAvailableUpdateContext() - persistLastUpdateCheckAt?.(Date.now()) - if (!userInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } - sendErrorStatus(message, userInitiated) - } - - pendingCheckFailureKey = failureKey - pendingCheckFailurePromise = handleFailure().finally(() => { - if (pendingCheckFailureKey === failureKey) { - pendingCheckFailureKey = null - pendingCheckFailurePromise = null - } - }) - return pendingCheckFailurePromise -} - -function isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') - ) -} - -function isStableReleaseNotReadyFailure(sourceError: unknown): boolean { - return ( - sourceError instanceof ReleaseFeedPreflightError && - sourceError.reason === 'release-not-ready' && - sourceError.releaseChannel === 'default' - ) + return updater.resolveUpdateInstallMode(isServeMode) } export function getUpdateStatus(): UpdateStatus { - return currentStatus + return updater.getUpdateStatus() } export function getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { - if (!app.isPackaged || is.dev) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'unpackaged-build' - } - } - if (!autoUpdaterInitialized) { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'updater-unavailable' - } - } - if (updateInstallMode === 'unsupported-headless-serve') { - return { - installMode: updateInstallMode, - automatic: false, - reason: 'manual-service-update-required' - } - } - return { installMode: updateInstallMode, automatic: true, reason: 'available' } + return updater.getRemoteServerUpdateSupport() } export function getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { - return { - appVersion: app.getVersion(), - runtimeId, - support: getRemoteServerUpdateSupport(), - status: getUpdateStatus() - } -} - -function assertRemoteServerUpdateAvailable(): void { - if (!getRemoteServerUpdateSupport().automatic) { - throw new Error('remote_update_manual_required') - } + return updater.getRemoteServerUpdaterSnapshot(runtimeId) } export function checkForRemoteServerUpdate( runtimeId: string, options?: UpdateCheckOptions ): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - checkForUpdatesFromMenu(options) - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.checkForRemoteServerUpdate(runtimeId, options) } export function downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'available') { - throw new Error('remote_update_not_available') - } - downloadUpdate() - return getRemoteServerUpdaterSnapshot(runtimeId) + return updater.downloadRemoteServerUpdate(runtimeId) } export function installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { - assertRemoteServerUpdateAvailable() - if (currentStatus.state !== 'downloaded') { - throw new Error('remote_update_not_downloaded') - } - const targetVersion = currentStatus.version - const result: RemoteServerUpdateInstallResult = { - accepted: true, - fromVersion: app.getVersion(), - targetVersion, - runtimeId - } - quitAndInstall() - return result -} - -let consecutiveAutomaticRetrySchedules = 0 - -function scheduleAutomaticUpdateCheck(delayMs: number): void { - let effectiveDelayMs = delayMs - // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. - if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { - effectiveDelayMs = Math.min( - AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, - MAX_AUTO_UPDATE_RETRY_INTERVAL_MS - ) - consecutiveAutomaticRetrySchedules += 1 - } - if (autoUpdateCheckTimer) { - clearTimeout(autoUpdateCheckTimer) - } - autoUpdateCheckTimer = setTimeout(() => { - // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. - if (!runBackgroundUpdateCheck()) { - // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - }, effectiveDelayMs) -} - -function recordCompletedUpdateCheck(): void { - consecutiveAutomaticRetrySchedules = 0 - persistLastUpdateCheckAt?.(Date.now()) -} - -function getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - return pendingPrereleaseFallback.userInitiated -} - -function markMissingManifestPrereleaseFallbackChecking(): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true -} - -function consumeMissingManifestPrereleaseFallbackResult(): MissingManifestPrereleaseFallbackResult | null { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return null - } - const result = { userInitiated: pendingPrereleaseFallback.userInitiated } - pendingPrereleaseFallback.fallbackResultHandled = true - clearPrereleaseFallbackContextIfSettled() - return result -} - -function suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -function shouldSuppressMissingManifestPrereleaseFallbackEvent( - message: string, - error: unknown -): boolean { - if (!pendingPrereleaseFallback?.retryLaunched) { - return false - } - const failureKey = getCheckFailureKey(message, pendingPrereleaseFallback.userInitiated) - const primaryEventSuppression = pendingPrereleaseFallback.suppressedPrimaryEventFailure - if (primaryEventSuppression?.failureKey === failureKey) { - const isPrimaryPromisePair = primaryEventSuppression.error === error - // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. - if (isPrimaryPromisePair || !pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { - pendingPrereleaseFallback.suppressedPrimaryEventFailure = null - clearPrereleaseFallbackContextIfSettled() - return true - } - } - if (pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null - clearPrereleaseFallbackContextIfSettled() - return true - } - return false -} - -function markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { - if ( - !pendingPrereleaseFallback?.retryLaunched || - pendingPrereleaseFallback.fallbackResultHandled - ) { - return - } - pendingPrereleaseFallback.suppressedFallbackEventFailureKey = getCheckFailureKey( - message, - pendingPrereleaseFallback.userInitiated - ) -} - -async function pinDefaultReleaseFeed( - variant: UpdateCheckVariant = 'default' -): Promise { - const autoUpdater = getAutoUpdater() - // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). - const currentVersion = app.getVersion() - const isPerfCheck = variant === 'perf' - const includePrerelease = - isPerfCheck || includePrereleaseActive || isPrereleaseVersion(currentVersion) - const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( - currentVersion, - includePrerelease ? 2 : 1, - { - includePrerelease, - ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) - } - ) - const newerTag = releaseTagsResult.tags[0] ?? null - const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null - pendingPrereleaseFallback = - includePrerelease && newerTag && fallbackTag - ? { - primaryTag: newerTag, - fallbackTag, - userInitiated: false, - suppressedPrimaryPromiseFailureKey: null, - suppressedPrimaryEventFailure: null, - suppressedFallbackPromiseFailureKey: null, - suppressedFallbackEventFailureKey: null, - fallbackResultHandled: false, - fallbackCheckingForUpdateSeen: false, - retryLaunched: false - } - : null - // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. - if (newerTag) { - clearPublishingWindowLastGoodCheck() - const url = getReleaseDownloadUrl(newerTag) - console.info( - `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } else if (releaseTagsResult.state === 'not-ready') { - clearPrereleaseFallbackContext() - if (releaseTagsResult.lastGoodTag) { - // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. - const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) - console.info( - `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } - clearPublishingWindowLastGoodCheck() - console.info( - `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` - ) - throw new ReleaseFeedPreflightError( - 'release-not-ready', - isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', - 'Latest release artifacts are not ready' - ) - } else if ( - releaseTagsResult.state === 'unavailable' && - releaseTagsResult.unavailableReason === 'manifest' && - !includePrerelease - ) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - throw new ReleaseFeedPreflightError( - 'manifest-unavailable', - 'default', - 'Unable to find latest version on GitHub' - ) - } else if (isPerfCheck) { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - if (releaseTagsResult.state === 'no-newer') { - console.info( - `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` - ) - return 'not-available' - } - throw new Error('Could not resolve perf update feed') - } else { - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - const url = 'https://github.com/stablyai/orca/releases/latest/download' - console.info( - `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` - ) - autoUpdater.setFeedURL({ provider: 'generic', url }) - return 'ready' - } -} - -function retryPrereleaseFallbackAfterMissingManifest( - message: string, - userInitiated: boolean | undefined, - source: CheckFailureSource, - failureKey: string, - sourceError?: unknown -): boolean { - if ( - !pendingPrereleaseFallback || - pendingPrereleaseFallback.retryLaunched || - !isMissingUpdateManifestFailure(message) - ) { - return false - } - const attemptId = activeUpdateCheckAttemptId - if (attemptId === null) { - return false - } - - // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. - pendingPrereleaseFallback.retryLaunched = true - pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) - pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = - source === 'event' ? failureKey : null - pendingPrereleaseFallback.suppressedPrimaryEventFailure = - source === 'promise' ? { failureKey, error: sourceError } : null - pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false - const { primaryTag, fallbackTag } = pendingPrereleaseFallback - const url = getReleaseDownloadUrl(fallbackTag) - console.info( - `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` - ) - const autoUpdater = getAutoUpdater() - autoUpdater.setFeedURL({ provider: 'generic', url }) - userInitiatedCheck = Boolean(userInitiated) - backgroundCheckLaunchPending = !userInitiated - armUpdateCheckStallTimer(attemptId) - markUpdateCheckLaunched(attemptId) - void autoUpdater - .checkForUpdates() - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const message = String(err?.message ?? err) - if (userInitiated) { - userInitiatedCheck = false - } else { - backgroundCheckLaunchPending = false - } - markMissingManifestPrereleaseFallbackPromiseHandled(message) - consumeMissingManifestPrereleaseFallbackResult() - void sendCheckFailureStatus(message, userInitiated, 'fallback-promise', err) - }) - return true -} - -/** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ -function runBackgroundUpdateCheck( - nudgeId: string | null = getPersistedPendingUpdateNudgeId() -): boolean { - // Why: a pinned dev jump owns the feed until it settles; a background check - // would repoint it mid-flight and download the wrong build. - if ( - activeUpdateSource !== 'release' || - isPinnedBuildActive || - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress - ) { - return false - } - if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { - return false - } - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available' }) - return false - } - // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. - activeUpdateNudgeId = nudgeId - // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. - backgroundCheckLaunchPending = true - backgroundCheckPromotedToUserInitiated = false - const attemptId = beginUpdateCheckAttempt() - // Don't send 'checking' here — the 'checking-for-update' handler does; sending from both dupes notifications (issue #35). - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed().then(launch) - void Promise.resolve(run) - .then(() => handleSettledUpdateCheckPromise(attemptId)) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - const wasUserInitiated = getSettledCheckUserInitiated() - backgroundCheckLaunchPending = false - backgroundCheckPromotedToUserInitiated = false - if (wasUserInitiated) { - userInitiatedCheck = false - } - void sendCheckFailureStatus(String(err?.message ?? err), wasUserInitiated, 'promise', err) - }) - return true + return updater.installRemoteServerUpdate(runtimeId) } export function checkForUpdates(): void { - // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. - void withUpdaterSpan({ stage: 'check' }, async (span) => { - span.setAttribute('updater.outcome', 'launched') - runBackgroundUpdateCheck() - }) + updater.checkForUpdates() } -function enablePrereleaseManifestChecks(): void { - getAutoUpdater().allowPrerelease = true -} - -function enableIncludePrerelease(): void { - if (includePrereleaseActive) { - return - } - // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. - enablePrereleaseManifestChecks() - includePrereleaseActive = true -} - -/** Menu-triggered check — delegates feedback to renderer toasts via userInitiated flag */ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - if (options?.localBuild) { - void checkForLocalBuildFromMenu() - return - } - if (options?.targetTag && options.channel) { - void checkForPinnedBuild(options.channel, options.targetTag) - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - if ( - activeUpdateSource !== 'release' && - (currentStatus.state === 'checking' || currentStatus.state === 'downloading') - ) { - return - } - restoreReleaseUpdateSource() - - const checkVariant = getUpdateCheckVariant(options) - if (checkVariant === 'prerelease') { - clearPrereleaseFallbackContext() - enableIncludePrerelease() - } else if (checkVariant === 'perf') { - clearPrereleaseFallbackContext() - // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. - enablePrereleaseManifestChecks() - } - - const checkAlreadyInFlight = backgroundCheckLaunchPending || currentStatus.state === 'checking' - userInitiatedCheck = true - // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. - activeUpdateNudgeId = null - // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. - sendStatus({ state: 'checking', userInitiated: true }) - if (checkAlreadyInFlight) { - backgroundCheckPromotedToUserInitiated = true - rearmActiveUpdateCheckStallTimer() - if (checkVariant !== 'default') { - // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. - pendingUserInitiatedCheckAfterInFlight = checkVariant - } - return - } - - const attemptId = beginUpdateCheckAttempt() - const autoUpdater = getAutoUpdater() - const launch = (): Promise | undefined => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return undefined - } - markUpdateCheckLaunched(attemptId) - return autoUpdater.checkForUpdates() - } - const run = pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { - if (preflightResult === 'not-available') { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return false - } - userInitiatedCheck = false - finishActiveUpdateCheckAttempt() - recordCompletedUpdateCheck() - sendStatus({ state: 'not-available', userInitiated: true }) - return false - } - return launch() - }) - void Promise.resolve(run) - .then((launchResult) => { - if (launchResult === false) { - return - } - handleSettledUpdateCheckPromise(attemptId) - }) - .catch((err) => { - if (!isActiveUpdateCheckAttempt(attemptId)) { - return - } - userInitiatedCheck = false - void sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) - }) -} - -async function checkForLocalBuildFromMenu(): Promise { - if (process.platform !== 'darwin') { - sendLocalBuildErrorAndRestore( - 'Local build switching is currently available only on macOS.', - true - ) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress) { - return - } - localBuildSelectionInProgress = true - try { - const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ - import('./local-builds/local-build-switch'), - import('./local-builds/local-build-feed-server') - ]) - const candidate = await chooseLocalBuild(mainWindowRef) - if (!candidate) { - return - } - closeLocalBuildFeed() - const feed = await startLocalBuildFeed(candidate) - activeLocalBuildFeed = feed - activeUpdateSource = 'local' - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.setFeedURL({ provider: 'generic', url: feed.url }) - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) - } finally { - localBuildSelectionInProgress = false - } -} - -export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { - return listReleaseBuilds(channel) -} - -/** - * Pins the updater at one exact release tag and checks it, so a dev can move to - * any published build on any channel — including an older one. - * - * Unlike a routine check this sets `allowDowngrade`, because "jump to yesterday's - * hourly" is a downgrade by semver. The pin is torn down as soon as the attempt - * settles so ordinary background checks never inherit it. - */ -async function checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { - if (!app.isPackaged || is.dev) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - // Why here as well as in the picker: the renderer disables the option, but IPC - // is reachable regardless, and there is no artifact to install on a platform - // the dev workflows do not build for. - if (!isChannelSupportedOnPlatform(channel, process.platform)) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, - userInitiated: true - }) - return - } - // Why: electron-updater would otherwise take this all the way to a download - // and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead - // — the installer is run by hand once, and in-app updates work from there on. - if ( - requiresManualDevChannelInstall({ - platform: process.platform, - runningChannel: getVersionChannel(app.getVersion()), - targetChannel: channel - }) - ) { - sendStatus({ - state: 'error', - message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, - userInitiated: true - }) - return - } - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - pinnedBuildSelectionInProgress = true - try { - const target = resolveTargetBuild(channel, tag) - if (compareVersions(target.version, app.getVersion()) === 0) { - sendStatus({ state: 'not-available', userInitiated: true }) - return - } - closeLocalBuildFeed() - activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' - isPinnedBuildActive = true - clearPrereleaseFallbackContext() - clearPublishingWindowLastGoodCheck() - clearAvailableUpdateContext() - activeUpdateNudgeId = null - userInitiatedCheck = true - sendStatus({ state: 'checking', userInitiated: true }) - - const updater = getAutoUpdater() - // Why: an intentional jump to an older tag must not be filtered out as "not newer". - updater.allowDowngrade = true - updater.disableDifferentialDownload = true - updater.allowPrerelease = true - console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) - updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) - availableReleaseUrl = target.feedUrl - const attemptId = beginUpdateCheckAttempt() - markUpdateCheckLaunched(attemptId) - await updater.checkForUpdates() - handleSettledUpdateCheckPromise(attemptId) - } catch (error) { - userInitiatedCheck = false - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - sendStatus({ - state: 'error', - message: String((error as Error)?.message ?? error), - userInitiated: true - }) - } finally { - pinnedBuildSelectionInProgress = false - } -} - -export function isQuittingForUpdate(): boolean { - return quittingForUpdate -} - -function getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { - if (currentStatus.state !== 'error') { - return null - } - return currentStatus.recovery?.kind === 'linux-package-install' ? currentStatus.recovery : null -} - -const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { - missing: - 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', - // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. - 'not-regular': - 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', - 'hash-mismatch': - 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', - 'read-failed': - 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', - 'no-sudo': - 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - 'no-package-manager': - 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', - // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. - 'invalid-package-path': - 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' -} - -// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. -const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ - 'missing', - 'not-regular', - 'hash-mismatch' -] - -function recordLinuxPackageRecoveryUnavailable( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): void { - recordUpdaterLifecycle( - 'linux_package_recovery_unavailable', - { reason, packageType: recovery.packageType, version: recovery.version }, - { level: 'warn', message: 'Linux package recovery action unavailable' } - ) -} - -function failLinuxPackageRecovery( - recovery: LinuxPackageInstallRecovery, - reason: LinuxPackageRecoveryUnavailableReason -): never { - recordLinuxPackageRecoveryUnavailable(recovery, reason) - const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] - // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would - // destroy the newer artifact and clobber whatever card replaced this one. - const active = getActiveLinuxPackageRecovery() - const stillCurrent = - active?.version === recovery.version && active?.packageType === recovery.packageType - if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { - clearTrackedLinuxPackageArtifact() - sendStatus({ state: 'error', message }) - } - throw new Error(message) -} - -/** - * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash - * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. - */ -function getInstallCycleSignature(): string { - const recovery = getActiveLinuxPackageRecovery() - if (recovery) { - return `recovery:${recovery.packageType}:${recovery.version}` - } - return currentStatus.state === 'downloaded' - ? `downloaded:${currentStatus.version}` - : `state:${currentStatus.state}` -} - -/** - * Re-proves the retained package before the install starts. Returns false when the install must be - * abandoned; the artifact is only re-read here, so callers still own every teardown decision. - */ -async function proveRetainedLinuxPackage(pendingVersion: string): Promise { - const artifact = getTrackedLinuxPackageArtifact() - if (!artifact) { - return true - } - // Why: an artifact retained from another cycle says nothing about the file electron-updater is - // about to install, so proving it would block a legitimate install on an unrelated digest. - if (pendingVersion && pendingVersion !== artifact.version) { - return true - } - const recovery = getActiveLinuxPackageRecovery() - const cycle = getInstallCycleSignature() - const reason = await revalidateRetainedLinuxPackage(artifact) - if (!reason) { - return true - } - reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) - return false -} - -/** The failing reason, or null when the retained package still matches its release digest. */ -async function revalidateRetainedLinuxPackage( - artifact: LinuxPackageArtifact -): Promise { - linuxPackageRevalidationInFlight = true - try { - const verdict = await revalidateLinuxPackageForInstall(artifact) - return verdict.ok ? null : verdict.reason - } catch (error) { - recordUpdaterLifecycle( - 'linux_package_revalidation_errored', - { errorType: error instanceof Error ? error.name : typeof error }, - { level: 'warn', message: 'Could not re-verify the retained update package' } - ) - // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. - return 'read-failed' - } finally { - // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall - // early-return for the rest of the session. - linuxPackageRevalidationInFlight = false - } -} - -function reportLinuxPackageRevalidationFailure({ - artifact, - recovery, - reason, - cycle -}: { - artifact: LinuxPackageArtifact - recovery: LinuxPackageInstallRecovery | null - reason: LinuxPackageRecoveryUnavailableReason - cycle: string -}): void { - recordUpdaterLifecycle( - 'linux_package_revalidation_failed', - { - action: recovery ? 'retry-automatic' : 'restart-to-install', - packageType: artifact.packageType, - version: artifact.version, - reason - }, - { level: 'warn', message: 'Retained update package failed its pre-install digest check' } - ) - // Why: a package proven bad must not stay tracked, but a download that landed during the hash - // owns the slot now and destroying it would force a needless 160 MB redownload. - const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) - if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { - clearTrackedLinuxPackageArtifact() - } - // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been - // replaced must not clobber whatever card the user is looking at now. - if (getInstallCycleSignature() !== cycle) { - return - } - sendInstallFailureStatus({ - state: 'error', - message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], - // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its - // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. - ...(recovery && !clearsArtifact ? { recovery } : {}) - }) -} - -export async function getLinuxPackageInstallInstructions(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'copy-command', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await resolveLinuxPackageInstallInstructions(recovery) - if (!result.ok) { - // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote - // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). - if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { - recordLinuxPackageRecoveryUnavailable(recovery, result.reason) - return { - ok: false, - reason: result.reason, - message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] - } - } - failLinuxPackageRecovery(recovery, result.reason) - } - return { ok: true, command: result.command, packageFileName: result.packageFileName } -} - -export async function showLinuxPackage(): Promise { - const recovery = getActiveLinuxPackageRecovery() - if (!recovery) { - throw new Error('No package install recovery is available.') - } - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'show-package', - packageType: recovery.packageType, - version: recovery.version - }) - const result = await revealLinuxPackage(recovery) - if (!result.ok) { - failLinuxPackageRecovery(recovery, result.reason) - } -} - -export function quitAndInstall(): void { - if ( - localBuildSelectionInProgress || - pinnedBuildSelectionInProgress || - pendingQuitAndInstallTimer || - quitAndInstallInProgress || - // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so - // without this a second click would schedule a parallel install of the same package. - linuxPackageRevalidationInFlight - ) { - return - } - - const retriedRecovery = getActiveLinuxPackageRecovery() - if (retriedRecovery) { - recordUpdaterLifecycle('linux_package_recovery_requested', { - action: 'retry-automatic', - packageType: retriedRecovery.packageType, - version: retriedRecovery.version - }) - } - - if (deferHeadlessServeInstall('install', getPendingInstallVersion())) { - return - } - - if ( - deferMacQuitUntilInstallerReady( - currentStatus, - hasInstallableDownloadedVersion(), - getPendingInstallVersion, - sendStatus - ) - ) { - return - } - - // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. - pendingQuitAndInstallTimer = setTimeout(() => { - void performQuitAndInstall() - }, QUIT_AND_INSTALL_DELAY_MS) -} - -async function checkForUpdateNudge(): Promise { - if (!app.isPackaged || is.dev) { - return - } - if (nudgeCheckInFlight) { - return - } - - const now = Date.now() - if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { - return - } - lastNudgeCheckAt = now - - nudgeCheckInFlight = true - try { - const nudge = await fetchNudge() - if (!nudge) { - return - } - - if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { - return - } - - const appVersion = app.getVersion() - const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null - const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null - - if ( - shouldApplyNudge({ - nudge, - appVersion, - pendingUpdateNudgeId, - dismissedUpdateNudgeId - }) - ) { - awaitingNudgeCheckOutcome = true - _setPendingUpdateNudgeId?.(nudge.id) - mainWindowRef?.webContents.send('updater:clearDismissal') - runBackgroundUpdateCheck(nudge.id) - } - } finally { - nudgeCheckInFlight = false - } -} - -function scheduleUpdateNudgeCheck(): void { - if (nudgeCheckTimer) { - clearTimeout(nudgeCheckTimer) - } - nudgeCheckTimer = setTimeout(() => { - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - }, NUDGE_POLL_INTERVAL_MS) -} - -export function dismissNudge(): void { - const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null - if (pendingId) { - _setDismissedUpdateNudgeId?.(pendingId) - clearPendingUpdateNudge() - } -} - -/** - * The user closed an offered update without taking it. For a local build or a - * pinned dev jump that ends the session: nothing will consume that feed now, so - * release checks must stop being deferred. - */ -export function dismissAvailableUpdate(): void { - if (activeUpdateSource === 'release' && !isPinnedBuildActive) { - return - } - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress) { - return - } - // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. - if (currentStatus.state !== 'available') { - return - } - clearAvailableUpdateContext() - restoreReleaseUpdateSource() - // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. - sendStatus({ state: 'idle' }) -} - -export function setupAutoUpdater( - mainWindow: BrowserWindow, - opts?: { - getLastUpdateCheckAt?: () => number | null - onBeforeQuit?: () => void | Promise - setLastUpdateCheckAt?: (timestamp: number) => void - getPendingUpdateNudgeId?: () => string | null - getDismissedUpdateNudgeId?: () => string | null - setPendingUpdateNudgeId?: (id: string | null) => void - setDismissedUpdateNudgeId?: (id: string | null) => void - getReleaseChannelOverride?: () => ReleaseChannel | null - installMode?: UpdateInstallMode - } -): void { - mainWindowRef = mainWindow - onBeforeQuitCleanup = opts?.onBeforeQuit ?? null - persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null - _getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null - _getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null - _getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null - _setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null - _setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null - getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null - updateInstallMode = opts?.installMode ?? 'interactive' - lastInstallDeferralVersion = { download: null, install: null } - - const serveHandoffFailure = getServeUpdateHandoffFailure() - if (serveHandoffFailure) { - recordUpdaterLifecycle( - 'headless_serve_handoff_failed', - { reason: serveHandoffFailure }, - { level: 'warn', message: 'Supervised serve update did not complete' } - ) - sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) - } - - if (!app.isPackaged && !is.dev) { - return - } - if (is.dev) { - return - } - - const autoUpdater = getAutoUpdater() - autoUpdater.autoDownload = false - if (activeUpdateSource === 'release') { - autoUpdater.allowDowngrade = false - autoUpdater.disableDifferentialDownload = false - } - // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. - // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. - autoUpdater.autoInstallOnAppQuit = - updateInstallMode === 'interactive' && getLinuxRootPackageType() === null - // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. - autoUpdater.autoRunAppAfterInstall = updateInstallMode === 'interactive' - - // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. - // The adapter also retains the redacted child stderr that BaseUpdater logs but drops from the 'error' event. - autoUpdater.logger = createUpdaterDiagnosticLogger() as never - - // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. - - // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - if (activeUpdateSource === 'release') { - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) - } - - if (autoUpdaterInitialized) { - return - } - autoUpdaterInitialized = true - - registerAutoUpdaterHandlers({ - autoUpdater, - clearAvailableUpdateContext, - consumeMissingManifestPrereleaseFallbackResult, - getMissingManifestPrereleaseFallbackUserInitiated, - getPublishingWindowLastGoodCheck, - getActiveUpdateCheckEventAttemptId, - getCurrentStatus: () => currentStatus, - getKnownReleaseUrl, - getPendingInstallVersion, - getUserInitiatedCheck: () => userInitiatedCheck, - handleQuitAndInstallFailure, - isQuitAndInstallHandoffActive, - hasInstallableDownloadedVersion, - isLocalBuildCheck: () => activeUpdateSource === 'local', - // Why: pinned jumps are deliberate, so update-available/-downloaded must not - // reject them for being older than the running version. - isPinnedBuildCheck: () => isPinnedBuildActive, - shouldHandleUpdaterErrorEvent, - performQuitAndInstall, - clearUpdateAvailableEventPending, - isActiveUpdateCheckAttempt, - markUpdateCheckEventAttempt, - markUpdateAvailableEventPending, - sendCheckFailureStatus, - sendErrorStatus, - markMissingManifestPrereleaseFallbackChecking, - shouldDeferMacQuitForInstall: () => updateInstallMode === 'interactive', - shouldSuppressMissingManifestPrereleaseFallbackEvent, - suppressMissingManifestPrereleaseFallbackPromiseFailure, - recordCompletedUpdateCheck, - restoreReleaseUpdateSource, - sendStatus, - scheduleAutomaticUpdateCheck, - clearBackgroundCheckLaunchPending, - setAvailableReleaseUrl: (releaseUrl) => { - availableReleaseUrl = releaseUrl - }, - setAvailableVersion: (version) => { - availableVersion = version - }, - setUserInitiatedCheck: (value) => { - userInitiatedCheck = value - } - }) - - void checkForUpdateNudge() - scheduleUpdateNudgeCheck() - - const checkDailyOnWake = () => { - void checkForUpdateNudge() - if ( - backgroundCheckLaunchPending || - currentStatus.state === 'checking' || - currentStatus.state === 'downloading' - ) { - return - } - const lastCheck = _getLastUpdateCheckAt?.() ?? null - const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck - if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } - } - - powerMonitor.on('resume', checkDailyOnWake) - app.on('browser-window-focus', checkDailyOnWake) - - const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null - const msSinceLastCheck = - lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt - - if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { - runBackgroundUpdateCheck() - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) - } else { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) - } + updater.checkForUpdatesFromMenu(options) } export function downloadUpdate(): void { - if (localBuildSelectionInProgress || pinnedBuildSelectionInProgress || downloadInFlight) { - return - } - // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. - const canStart = - currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) - if (!canStart) { - return - } - const version = currentStatus.state === 'available' ? currentStatus.version : availableVersion - if (!version) { - return - } - if (deferHeadlessServeInstall('download', version)) { - return - } - downloadInFlight = true - const localBuildDownload = activeUpdateSource === 'local' - beginMacUpdateDownload() - // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. - sendStatus({ state: 'downloading', percent: 0, version }) - getAutoUpdater() - .downloadUpdate() - .catch((err) => { - downloadInFlight = false - const message = String(err?.message ?? err) - if (localBuildDownload) { - sendLocalBuildErrorAndRestore(message) - } else { - sendErrorStatus(message) - } - }) + updater.downloadUpdate() +} + +export function quitAndInstall(): void { + updater.quitAndInstall() +} + +export function isQuittingForUpdate(): boolean { + return updater.isQuittingForUpdate() +} + +export async function getLinuxPackageInstallInstructions(): Promise { + return updater.getLinuxPackageInstallInstructions() +} + +export async function showLinuxPackage(): Promise { + return updater.showLinuxPackage() +} + +export async function listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return updater.listAvailableReleaseBuilds(channel) +} + +export function dismissNudge(): void { + updater.dismissNudge() +} + +export function dismissAvailableUpdate(): void { + updater.dismissAvailableUpdate() +} + +export function setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + updater.setupAutoUpdater(mainWindow, opts) } diff --git a/src/main/updater/updater-build-selection.ts b/src/main/updater/updater-build-selection.ts new file mode 100644 index 00000000000..63222fb3101 --- /dev/null +++ b/src/main/updater/updater-build-selection.ts @@ -0,0 +1,152 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { + DEV_CHANNEL_PLATFORM_LABEL, + getVersionChannel, + hasDedicatedReleaseRepo, + isChannelSupportedOnPlatform, + RELEASE_CHANNEL_LABELS, + requiresManualDevChannelInstall, + type ReleaseBuild, + type ReleaseChannel +} from '../../shared/release-channel' +import { compareVersions } from '../updater-fallback' +import { listReleaseBuilds, resolveTargetBuild } from '../updater-release-builds' +import { UpdaterMenuChecks } from './updater-menu-checks' + +/** Handles local-build selection and exact release-channel/tag jumps. */ +export abstract class UpdaterBuildSelection extends UpdaterMenuChecks { + protected async checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + this.sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress) { + return + } + this.localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('../local-builds/local-build-switch'), + import('../local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(this.mainWindowRef) + if (!candidate) { + return + } + this.closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + this.activeLocalBuildFeed = feed + this.activeUpdateSource = 'local' + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + this.localBuildSelectionInProgress = false + } + } + + protected async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return listReleaseBuilds(channel) + } + + /** Pins the updater at one exact release tag and checks it, so a dev can move to any published build on any channel — including an older one. */ + protected async checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + // Why here as well as in the picker: the renderer disables the option, but IPC is reachable regardless, and there is no artifact to install on a platform the dev workflows do not build for. + if (!isChannelSupportedOnPlatform(channel, process.platform)) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are produced only for ${DEV_CHANNEL_PLATFORM_LABEL}.`, + userInitiated: true + }) + return + } + // Why: electron-updater would otherwise take this all the way to a download and fail it with a raw ERR_UPDATER_INVALID_SIGNATURE. Say what to do instead — the installer is run by hand once, and in-app updates work from there on. + if ( + requiresManualDevChannelInstall({ + platform: process.platform, + runningChannel: getVersionChannel(app.getVersion()), + targetChannel: channel + }) + ) { + this.sendStatus({ + state: 'error', + message: `${RELEASE_CHANNEL_LABELS[channel]} builds are unsigned, and this signed build only installs updates signed by Orca's publisher. Download the installer from the release page and run it once — updates work normally from there, including back to Stable.`, + userInitiated: true + }) + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + this.pinnedBuildSelectionInProgress = true + try { + const target = resolveTargetBuild(channel, tag) + if (compareVersions(target.version, app.getVersion()) === 0) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + this.closeLocalBuildFeed() + this.activeUpdateSource = hasDedicatedReleaseRepo(channel) ? channel : 'release' + this.isPinnedBuildActive = true + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + this.clearAvailableUpdateContext() + this.activeUpdateNudgeId = null + this.userInitiatedCheck = true + this.sendStatus({ state: 'checking', userInitiated: true }) + + const updater = this.getAutoUpdater() + // Why: an intentional jump to an older tag must not be filtered out as "not newer". + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.allowPrerelease = true + console.info(`[updater] pinned to ${channel} build ${target.tag} → ${target.feedUrl}`) + updater.setFeedURL({ provider: 'generic', url: target.feedUrl }) + this.availableReleaseUrl = target.feedUrl + const attemptId = this.beginUpdateCheckAttempt() + this.markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + this.handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + this.userInitiatedCheck = false + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ + state: 'error', + message: String((error as Error)?.message ?? error), + userInitiated: true + }) + } finally { + this.pinnedBuildSelectionInProgress = false + } + } +} diff --git a/src/main/updater/updater-check-failure.ts b/src/main/updater/updater-check-failure.ts new file mode 100644 index 00000000000..8ee2500c6a9 --- /dev/null +++ b/src/main/updater/updater-check-failure.ts @@ -0,0 +1,118 @@ +import { isBenignCheckFailure } from '../updater-fallback' +import { ReleaseFeedPreflightError } from './updater-state' +import type { CheckFailureSource } from './updater-state' +import { UpdaterReleaseFeed } from './updater-release-feed' + +/** Normalizes check failures, retry policy, and release-feed preflight diagnostics. */ +export abstract class UpdaterCheckFailure extends UpdaterReleaseFeed { + protected isRetryableReleaseFeedPreflightFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + (sourceError.reason === 'release-not-ready' || sourceError.reason === 'manifest-unavailable') + ) + } + + protected isStableReleaseNotReadyFailure(sourceError: unknown): boolean { + return ( + sourceError instanceof ReleaseFeedPreflightError && + sourceError.reason === 'release-not-ready' && + sourceError.releaseChannel === 'default' + ) + } + + protected async sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source: CheckFailureSource = 'promise', + sourceError?: unknown + ): Promise { + if (this.activeUpdateSource === 'local') { + this.sendLocalBuildErrorAndRestore(message, userInitiated) + return + } + if (this.isPinnedBuildActive) { + // Why: a failed pinned jump must hand the feed back before surfacing the error, or the pin blocks background checks for the process lifetime. + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + this.sendStatus({ state: 'error', message, userInitiated }) + return + } + const failureKey = this.getCheckFailureKey(message, userInitiated) + if ( + source === 'promise' && + this.pendingPrereleaseFallback?.suppressedPrimaryPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + source === 'fallback-promise' && + this.pendingPrereleaseFallback?.suppressedFallbackPromiseFailureKey === failureKey + ) { + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return + } + if ( + this.retryPrereleaseFallbackAfterMissingManifest( + message, + userInitiated, + source, + failureKey, + sourceError + ) + ) { + return + } + if (this.pendingCheckFailureKey === failureKey && this.pendingCheckFailurePromise) { + return this.pendingCheckFailurePromise + } + + const handleFailure = async (): Promise => { + if ( + isBenignCheckFailure(message) || + this.isRetryableReleaseFeedPreflightFailure(sourceError) + ) { + // Why: benign failures (incomplete latest.yml, network blips) are transient — retry, and skip persisting the timestamp (would suppress the next startup check). + console.warn('[updater] benign check failure:', message) + this.clearAvailableUpdateContext() + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + if (userInitiated) { + // Why: a user click needs visible feedback (idle looks broken); distinguish incomplete releases from transport failures. + this.sendErrorStatus( + this.isStableReleaseNotReadyFailure(sourceError) + ? "A newer release isn't available for this device yet. Check again later." + : "Couldn't reach the update server. Try again in a few minutes.", + true + ) + } else { + if (this.isRetryableReleaseFeedPreflightFailure(sourceError)) { + // Why: release probes can fail transiently; keep the campaign pending so the short retry can still show it. + this.deferPendingUpdateNudgeUntilRetry() + } + this.sendStatus({ state: 'idle' }) + } + return + } + this.clearAvailableUpdateContext() + this.persistLastUpdateCheckAt?.(Date.now()) + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(this.getAutomaticRetryInterval()) + } + this.sendErrorStatus(message, userInitiated) + } + + this.pendingCheckFailureKey = failureKey + this.pendingCheckFailurePromise = handleFailure().finally(() => { + if (this.pendingCheckFailureKey === failureKey) { + this.pendingCheckFailureKey = null + this.pendingCheckFailurePromise = null + } + }) + return this.pendingCheckFailurePromise + } + + /** Keeps retry interval access in one place for the scheduling layer. */ + protected abstract getAutomaticRetryInterval(): number +} diff --git a/src/main/updater/updater-check-state.ts b/src/main/updater/updater-check-state.ts new file mode 100644 index 00000000000..8905029c75d --- /dev/null +++ b/src/main/updater/updater-check-state.ts @@ -0,0 +1,298 @@ +import { writeMainThreadDiagnosticMarker } from '../diagnostics/main-thread-churn-probe' +import { isWindowsSignatureCheckUnavailableFailure } from '../../shared/updater-windows-signature-check' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterStatus } from './updater-status' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + UPDATE_CHECK_SILENT_SETTLE_DELAY_MS, + UPDATE_CHECK_STALL_TIMEOUT_MS +} from './updater-state' + +export abstract class UpdaterCheckState extends UpdaterStatus { + protected getOptionsForUpdateCheckVariant(variant: UpdateCheckVariant): UpdateCheckOptions { + switch (variant) { + case 'perf': + return { includePrerelease: true, includePerfPrerelease: true } + case 'prerelease': + return { includePrerelease: true } + case 'default': + return { includePrerelease: false } + } + } + + protected getUpdateCheckVariant(options?: UpdateCheckOptions): UpdateCheckVariant { + if (options?.includePerfPrerelease) { + return 'perf' + } + if (options?.includePrerelease) { + return 'prerelease' + } + // Why: a persisted 'rc' override makes every routine check follow the RC series + // without the user re-holding shift; the dev channels need an explicit tag, so + // neither is a routine-check variant. + if (this.getReleaseChannelOverride?.() === 'rc') { + return 'prerelease' + } + return 'default' + } + + protected launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void { + this.pendingUserInitiatedCheckAfterInFlight = null + setTimeout(() => { + // Why: defer one tick after electron-updater clears its in-flight promise so the queued modifier check starts fresh instead of deduping into the stable one. + if (this.currentStatus.state === 'checking') { + this.currentStatus = { state: 'idle' } + } + this.checkForUpdatesFromMenu(this.getOptionsForUpdateCheckVariant(variant)) + }, 0) + } + + protected clearBackgroundCheckLaunchPending(): void { + this.backgroundCheckLaunchPending = false + } + + protected clearUpdateCheckStallTimer(): void { + if (!this.updateCheckStallTimer) { + return + } + clearTimeout(this.updateCheckStallTimer) + this.updateCheckStallTimer = null + } + + protected clearUpdateCheckSilentSettleTimer(): void { + if (!this.updateCheckSilentSettleTimer) { + return + } + clearTimeout(this.updateCheckSilentSettleTimer) + this.updateCheckSilentSettleTimer = null + } + + protected clearUpdateCheckTimers(): void { + this.clearUpdateCheckStallTimer() + this.clearUpdateCheckSilentSettleTimer() + } + + protected finishActiveUpdateCheckAttempt(): void { + this.activeUpdateCheckAttemptId = null + this.activeUpdateCheckLaunchAttemptId = null + this.activeUpdateCheckEventAttemptId = null + this.clearUpdateCheckTimers() + } + + protected getActiveUpdateCheckEventAttemptId(): number | null { + if (this.activeUpdateCheckAttemptId === null) { + return null + } + if (this.activeUpdateCheckEventAttemptId !== this.activeUpdateCheckAttemptId) { + return null + } + return this.activeUpdateCheckAttemptId + } + + protected isActiveUpdateCheckAttempt(attemptId: number): boolean { + return this.activeUpdateCheckAttemptId === attemptId + } + + protected markUpdateCheckEventAttempt(): boolean { + if (this.activeUpdateCheckAttemptId === null) { + return false + } + if (this.activeUpdateCheckLaunchAttemptId !== this.activeUpdateCheckAttemptId) { + return false + } + this.activeUpdateCheckEventAttemptId = this.activeUpdateCheckAttemptId + return true + } + + protected markUpdateCheckLaunched(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.activeUpdateCheckLaunchAttemptId = attemptId + } + + protected markUpdateAvailableEventPending(attemptId: number | null): void { + this.updateAvailableEventPendingAttemptId = attemptId + } + + protected clearUpdateAvailableEventPending(attemptId: number | null): void { + if (this.updateAvailableEventPendingAttemptId !== attemptId) { + return + } + this.updateAvailableEventPendingAttemptId = null + } + + protected armUpdateCheckStallTimer(attemptId: number): void { + this.clearUpdateCheckStallTimer() + this.updateCheckStallTimer = setTimeout(() => { + this.updateCheckStallTimer = null + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + if (this.currentStatus.state === 'checking') { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + void this.sendCheckFailureStatus( + 'Update check timed out. Try again in a few minutes.', + wasUserInitiated, + 'promise' + ) + return + } + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } + }, UPDATE_CHECK_STALL_TIMEOUT_MS) + } + + protected beginUpdateCheckAttempt(): number { + this.finishActiveUpdateCheckAttempt() + this.updateAvailableEventPendingAttemptId = null + this.updateCheckAttemptSequence += 1 + this.activeUpdateCheckAttemptId = this.updateCheckAttemptSequence + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + // Why: issue #7576 warnings recurred at retry cadence; timestamp each attempt to confirm or rule out the updater. + writeMainThreadDiagnosticMarker('updater-check-attempt') + return this.activeUpdateCheckAttemptId + } + + protected rearmActiveUpdateCheckStallTimer(): void { + if (this.activeUpdateCheckAttemptId === null) { + return + } + this.armUpdateCheckStallTimer(this.activeUpdateCheckAttemptId) + } + + protected getSettledCheckUserInitiated(): boolean | undefined { + return this.userInitiatedCheck || this.backgroundCheckPromotedToUserInitiated || undefined + } + + protected isUpdateCheckResultState(state: UpdateStatus['state']): boolean { + return ( + state === 'idle' || + state === 'not-available' || + state === 'available' || + state === 'error' || + state === 'downloading' || + state === 'downloaded' + ) + } + + protected consumeSilentCheckShortRetryReason(): boolean { + if (this.publishingWindowLastGoodCheck !== null) { + return true + } + return this.consumeMissingManifestPrereleaseFallbackResult() !== null + } + + protected completeSilentUpdateCheck(userInitiated: boolean | undefined): boolean { + const shouldRetrySoon = this.consumeSilentCheckShortRetryReason() + this.clearAvailableUpdateContext() + if (shouldRetrySoon) { + // Why: a silent result against a temporary last-good feed is still a release transition, so it must not suppress the short publish retry. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + return true + } + this.recordCompletedUpdateCheck() + if (!userInitiated) { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + return false + } + + protected settleSilentUpdateCheck(attemptId: number, userInitiated: boolean | undefined): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + if (this.updateAvailableEventPendingAttemptId === attemptId) { + return + } + if (this.currentStatus.state !== 'checking') { + if (this.backgroundCheckLaunchPending) { + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + const shouldRetrySoon = this.completeSilentUpdateCheck(userInitiated) + if (this.awaitingNudgeCheckOutcome) { + if (shouldRetrySoon) { + this.deferPendingUpdateNudgeUntilRetry() + return + } + this.sendStatus({ state: 'not-available', userInitiated }) + } + } + return + } + this.finishActiveUpdateCheckAttempt() + this.clearBackgroundCheckLaunchPending() + this.backgroundCheckPromotedToUserInitiated = false + this.userInitiatedCheck = false + this.completeSilentUpdateCheck(userInitiated) + this.sendStatus({ state: 'not-available', userInitiated }) + } + + protected handleSettledUpdateCheckPromise(attemptId: number): void { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.clearUpdateCheckSilentSettleTimer() + // Why: electron-updater can resolve before the terminal event arrives; grace-period it, then unstick checks that resolved without one. + this.updateCheckSilentSettleTimer = setTimeout(() => { + this.updateCheckSilentSettleTimer = null + this.settleSilentUpdateCheck(attemptId, this.getSettledCheckUserInitiated()) + }, UPDATE_CHECK_SILENT_SETTLE_DELAY_MS) + } + + protected shouldHandleUpdaterErrorEvent(): boolean { + if (this.getActiveUpdateCheckEventAttemptId() !== null) { + return true + } + // Why: electron-updater emits check errors globally; once a check settles, only active download/install flows should consume them. + return ( + this.downloadInFlight || + this.currentStatus.state === 'downloading' || + this.currentStatus.state === 'downloaded' + ) + } + + protected sendErrorStatus(message: string, userInitiated?: boolean): void { + if ( + this.currentStatus.state === 'error' && + this.currentStatus.message === message && + this.currentStatus.userInitiated === userInitiated + ) { + return + } + // Why: count AV/EDR-blocked Windows signature checks in the field to size the affected cohort before bigger updater changes. + if (isWindowsSignatureCheckUnavailableFailure(message)) { + recordUpdaterLifecycle('windows_signature_check_blocked', undefined, { + level: 'warn', + message: 'Windows update signature check could not run' + }) + } + this.sendStatus({ state: 'error', message, userInitiated }) + } + + protected abstract consumeMissingManifestPrereleaseFallbackResult(): { + userInitiated: boolean + } | null + protected abstract recordCompletedUpdateCheck(): void + protected abstract sendCheckFailureStatus( + message: string, + userInitiated?: boolean, + source?: 'event' | 'promise' | 'fallback-promise', + sourceError?: unknown + ): Promise + protected abstract scheduleAutomaticUpdateCheck(delayMs: number): void +} diff --git a/src/main/updater/updater-download-install.ts b/src/main/updater/updater-download-install.ts new file mode 100644 index 00000000000..a1627d3895c --- /dev/null +++ b/src/main/updater/updater-download-install.ts @@ -0,0 +1,93 @@ +import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady } from '../updater-mac-install' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { QUIT_AND_INSTALL_DELAY_MS } from './updater-state' +import { UpdaterRemoteStatus } from './updater-remote-status' + +/** Coordinates renderer-facing download/install actions and their duplicate guards. */ +export abstract class UpdaterDownloadInstall extends UpdaterRemoteStatus { + protected quitAndInstall(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.pendingQuitAndInstallTimer || + this.quitAndInstallInProgress || + // Why: the quit timer is already cleared while the pre-install digest re-proof streams, so without this a second click would schedule a parallel install of the same package. + this.linuxPackageRevalidationInFlight + ) { + return + } + + const retriedRecovery = this.getActiveLinuxPackageRecovery() + if (retriedRecovery) { + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'retry-automatic', + packageType: retriedRecovery.packageType, + version: retriedRecovery.version + }) + } + + if (this.deferHeadlessServeInstall('install', this.getPendingInstallVersion())) { + return + } + if ( + deferMacQuitUntilInstallerReady( + this.currentStatus, + this.hasInstallableDownloadedVersion(), + () => this.getPendingInstallVersion(), + (status) => this.sendStatus(status) + ) + ) { + return + } + + // Why: defer the quit a tick so the renderer can flush dismissals/state before windows start closing. + this.pendingQuitAndInstallTimer = setTimeout(() => { + void this.performQuitAndInstall() + }, QUIT_AND_INSTALL_DELAY_MS) + } + + protected downloadUpdate(): void { + if ( + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress || + this.downloadInFlight + ) { + return + } + // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. + const canStart = + this.currentStatus.state === 'available' || + (this.currentStatus.state === 'error' && this.hasInstallableDownloadedVersion()) + if (!canStart) { + return + } + const version = + this.currentStatus.state === 'available' ? this.currentStatus.version : this.availableVersion + if (!version) { + return + } + if (this.deferHeadlessServeInstall('download', version)) { + return + } + this.downloadInFlight = true + const localBuildDownload = this.activeUpdateSource === 'local' + beginMacUpdateDownload() + // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. + this.sendStatus({ state: 'downloading', percent: 0, version }) + this.getAutoUpdater() + .downloadUpdate() + .catch((err) => { + this.downloadInFlight = false + const message = String(err?.message ?? err) + if (localBuildDownload) { + this.sendLocalBuildErrorAndRestore(message) + } else { + this.sendErrorStatus(message) + } + }) + } + + protected isQuittingForUpdate(): boolean { + return this.quittingForUpdate + } +} diff --git a/src/main/updater/updater-install-execution.ts b/src/main/updater/updater-install-execution.ts new file mode 100644 index 00000000000..4522e155865 --- /dev/null +++ b/src/main/updater/updater-install-execution.ts @@ -0,0 +1,228 @@ +import { BrowserWindow } from 'electron' +import { killAllPty } from '../ipc/pty' +import { withUpdaterSpan } from '../observability/instrumentation' +import { runWithLaunchPath } from '../startup/hydrate-shell-path' +import { markMacQuitAndInstallInFlight, isMacInstallerReady } from '../updater-mac-install' +import { armUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { + beginLinuxPackageInstallDiagnosticCapture, + endLinuxPackageInstallDiagnosticCapture +} from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { requestServeUpdateHandoff, failServeUpdateHandoff } from '../serve-update-handoff' +import { UpdaterPackageRecovery } from './updater-package-recovery' + +export abstract class UpdaterInstallExecution extends UpdaterPackageRecovery { + protected async performQuitAndInstall(): Promise { + if (this.quitAndInstallInProgress || this.linuxPackageRevalidationInFlight) { + recordUpdaterLifecycle('quit_and_install_ignored', { reason: 'already-in-progress' }) + return + } + + if (this.pendingQuitAndInstallTimer) { + clearTimeout(this.pendingQuitAndInstallTimer) + this.pendingQuitAndInstallTimer = null + } + + const pendingVersion = this.getPendingInstallVersion() + if (this.deferHeadlessServeInstall('install', pendingVersion)) { + return + } + // Why: the retained .deb/.rpm sits on a user-writable path that a root package manager is about + // to read, and nothing re-checks it after download. Re-prove it here — before any teardown — so a + // swapped or vanished package aborts instead of being installed as root. The synchronous guard + // keeps every non-Linux install on its existing timing. + if ( + getTrackedLinuxPackageArtifact() && + !(await this.proveRetainedLinuxPackage(pendingVersion)) + ) { + // Why: the renderer armed its restart before invoking, and it infers the abort from the error + // status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where + // it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt. + this.mainWindowRef?.webContents.send('updater:quitAndInstallAborted') + return + } + this.quitAndInstallInProgress = true + + markMacQuitAndInstallInFlight() + + // Set BEFORE anything else so the `activate` handler doesn't reopen the old version while ShipIt replaces the .app bundle. + this.quittingForUpdate = true + + try { + await withUpdaterSpan({ stage: 'install' }, async (span) => { + span.setAttribute('updater.version', pendingVersion || 'unknown') + span.setAttribute('updater.platform', process.platform) + span.setAttribute( + 'updater.macosInstallerReady', + process.platform === 'darwin' ? isMacInstallerReady() : true + ) + recordUpdaterLifecycle('quit_and_install_started', { + version: pendingVersion || null, + macInstallerReady: process.platform === 'darwin' ? isMacInstallerReady() : true + }) + span.addEvent('pre_quit_cleanup_start') + await this.runBeforeUpdateQuitCleanup() + span.addEvent('pre_quit_cleanup_done') + + if ( + this.updateInstallMode === 'supervised-headless-serve' && + !requestServeUpdateHandoff(pendingVersion) + ) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { version: pendingVersion || null }, + { + level: 'warn', + message: 'Could not persist supervised serve update handoff' + } + ) + this.sendErrorStatus( + 'Could not prepare the supervised server restart. Orca remains running.', + true + ) + this.resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') + return + } + + recordUpdaterLifecycle('quit_and_install_invoking_native', { + version: pendingVersion || null + }) + // Why: defensive — never call quitAndInstall if recovery/reset already cleared the handoff. + if (!this.quitAndInstallInProgress) { + return + } + // Why: mark before the call so a sync 'error' during quitAndInstall can recover; pre-native errors must not look like install failure. + this.quitAndInstallNativeInvoked = true + // Why: invoke before killAllPty/removing close listeners so a sync 'error' (the "no filepath" path) can recover while windows and PTYs are intact. + const supervisorOwnsRelaunch = this.updateInstallMode === 'supervised-headless-serve' + // Why: BaseUpdater logs child stderr but drops it from the 'error' event, so retain it for the span of this call. + beginLinuxPackageInstallDiagnosticCapture(getTrackedLinuxPackageArtifact()?.path ?? null) + try { + runWithLaunchPath(() => + this.getAutoUpdater().quitAndInstall(supervisorOwnsRelaunch, !supervisorOwnsRelaunch) + ) + } finally { + const diagnostic = endLinuxPackageInstallDiagnosticCapture() + // Why: a synchronous 'error' already consumed and reset this attempt; re-stashing would leak it into the next one. + this.lastInstallAttemptDiagnostic = this.quitAndInstallInProgress ? diagnostic : null + } + span.addEvent('native_quit_and_install_invoked') + + // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. + if (!this.quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + this.currentStatus.state === 'error' + ? this.currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) + return + } + + // Why: DebUpdater/RpmUpdater install through spawnSync, so a normal return already means the + // package is installed. Commit here or a throw in the cleanup below is reported as an install + // failure — offering a recovery card, and stale stderr, for an update that actually succeeded. + if (getLinuxRootPackageType() !== null) { + this.updateInstallCommitted = true + armUpdateInstallExitWatchdog() + } + + killAllPty() + span.addEvent('local_pty_kill_all') + + for (const win of BrowserWindow.getAllWindows()) { + win.removeAllListeners('close') + } + span.addEvent('window_close_listeners_removed', { + windowCount: BrowserWindow.getAllWindows().length + }) + + // Why: committed installs keep quittingForUpdate so dock activate can't reopen the old process; macOS without Squirrel stays uncommitted so late native errors can still recover. + if ( + !this.updateInstallCommitted && + (process.platform !== 'darwin' || isMacInstallerReady()) + ) { + this.updateInstallCommitted = true + // Why: past commit the installer waits for this process to exit; a wedged async shutdown would strand the user with no app and no update (#4438). + armUpdateInstallExitWatchdog() + } + }) + } catch (error) { + // Why: on Linux the package is already installed once quitAndInstall returns, and the installer is + // waiting for this process to exit. Tearing down here would disarm the exit watchdog (#4438), clear + // quittingForUpdate mid-quit, and tell the user an install failed that actually succeeded. + if (this.updateInstallCommitted) { + recordUpdaterLifecycle( + 'post_commit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install cleanup failed after commit; install already applied' + } + ) + return + } + // Why: a pre-native cleanup/tracing exception is not a package install failure and must not be labelled as one. + const quitAndInstallNativeInvokedBeforeReset = this.quitAndInstallNativeInvoked + const recoveryStatus = + quitAndInstallNativeInvokedBeforeReset && !this.updateInstallCommitted + ? this.buildLinuxPackageInstallFailureStatus(error) + : null + failServeUpdateHandoff('Could not invoke the native updater.') + this.resetQuitForUpdateState() + recordUpdaterLifecycle( + 'quit_and_install_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Could not start update install' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. + message: quitAndInstallNativeInvokedBeforeReset + ? this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + : 'Could not restart to install the update. Quit and reopen Orca, then try again.' + } + ) + } + } + + // Why: quitAndInstall failures arrive via 'error'; recover only after native invoke and before commit, else clearing quittingForUpdate lets dock activate reopen the old process mid-installer. + protected handleQuitAndInstallFailure(error?: unknown): boolean { + if ( + !this.quitAndInstallInProgress || + !this.quitAndInstallNativeInvoked || + this.updateInstallCommitted + ) { + return false + } + const recoveryStatus = this.buildLinuxPackageInstallFailureStatus(error) + failServeUpdateHandoff('The native updater rejected the install request.') + this.resetQuitForUpdateState() + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) + this.sendInstallFailureStatus( + recoveryStatus ?? { + state: 'error', + message: this.withInstallFailureCause(this.getPreCommitInstallFailureMessage(), error) + } + ) + return true + } +} diff --git a/src/main/updater/updater-install-support.ts b/src/main/updater/updater-install-support.ts new file mode 100644 index 00000000000..ce6b4d3f398 --- /dev/null +++ b/src/main/updater/updater-install-support.ts @@ -0,0 +1,174 @@ +import { app } from 'electron' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../../shared/updater-windows-signature-check' +import { redactLinuxPackageInstallText } from '../linux-package-install-diagnostic' +import { getTrackedLinuxPackageArtifact } from '../linux-package-update-recovery' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { disarmUpdateInstallExitWatchdog } from '../update-install-exit-watchdog' +import { resetMacInstallState } from '../updater-mac-install' +import type { + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { compareVersions } from '../updater-fallback' +import { PRE_QUIT_CLEANUP_TIMEOUT_MS } from './updater-state' +import { UpdaterCheckState } from './updater-check-state' + +export abstract class UpdaterInstallSupport extends UpdaterCheckState { + protected getKnownReleaseUrl(): string | undefined { + return this.availableReleaseUrl ?? undefined + } + + protected hasInstallableDownloadedVersion(): boolean { + return ( + this.availableVersion !== null && + // Why: local builds and pinned dev jumps may intentionally move backwards. + (this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + compareVersions(this.availableVersion, app.getVersion()) > 0) + ) + } + + protected getPendingInstallVersion(): string { + if (this.availableVersion) { + return this.availableVersion + } + if (this.currentStatus.state === 'downloading' || this.currentStatus.state === 'downloaded') { + return this.currentStatus.version + } + return '' + } + + protected deferHeadlessServeInstall(phase: 'download' | 'install', version: string): boolean { + if (this.updateInstallMode !== 'unsupported-headless-serve') { + return false + } + const diagnosticVersion = version || 'unknown' + if (this.lastInstallDeferralVersion[phase] !== diagnosticVersion) { + this.lastInstallDeferralVersion[phase] = diagnosticVersion + recordUpdaterLifecycle( + 'headless_serve_install_deferred', + { phase, version: version || null }, + { + level: 'warn', + message: 'Update install deferred while hosting orca serve' + } + ) + } + this.sendErrorStatus( + 'This orca serve process was not started by an update-capable supervisor. Keep it running and update Orca through its service manager.', + true + ) + return true + } + + protected getCheckFailureKey(message: string, userInitiated?: boolean): string { + return `${userInitiated ? 'user' : 'auto'}:${message}` + } + + protected resetQuitForUpdateState(): void { + this.quitAndInstallInProgress = false + this.quittingForUpdate = false + this.updateInstallCommitted = false + this.quitAndInstallNativeInvoked = false + this.lastInstallAttemptDiagnostic = null + disarmUpdateInstallExitWatchdog() + resetMacInstallState() + } + + /** + * On macOS a pre-commit failure means Squirrel rejected the staged update, and quitting does re-stage + * it — so keep that advice there. Everywhere else a restart is not known to help. + */ + protected getPreCommitInstallFailureMessage(): string { + return process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + } + + /** + * Sends an install-failure status even when it repeats the current one. "Try Automatic Install + * Again" usually fails identically, and a deduped status would never reach the preload abort relay, + * leaving the renderer stuck in its restart checkpoint. + */ + protected sendInstallFailureStatus(status: UpdateStatus): void { + this.sendStatus(status, { force: true }) + } + + /** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ + protected withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, this.installFailureCauseMaxLength) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` + } + + /** + * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can + * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. + */ + protected isQuitAndInstallHandoffActive(): boolean { + return this.quitAndInstallInProgress + } + + protected async runBeforeUpdateQuitCleanup(): Promise { + if (!this.onBeforeQuitCleanup) { + return + } + + let timeout: ReturnType | null = null + const cleanup = Promise.resolve() + .then(() => this.onBeforeQuitCleanup?.()) + .catch((error) => { + recordUpdaterLifecycle( + 'pre_quit_cleanup_failed', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Pre-quit cleanup failed; continuing update install' + } + ) + }) + const timeoutResult = new Promise<'timeout'>((resolve) => { + timeout = setTimeout(() => resolve('timeout'), PRE_QUIT_CLEANUP_TIMEOUT_MS) + }) + + const result = await Promise.race([cleanup.then(() => 'done' as const), timeoutResult]) + if (result === 'timeout') { + recordUpdaterLifecycle( + 'pre_quit_cleanup_timeout', + { timeoutMs: PRE_QUIT_CLEANUP_TIMEOUT_MS }, + { + level: 'warn', + message: `Pre-quit cleanup exceeded ${PRE_QUIT_CLEANUP_TIMEOUT_MS}ms; continuing update install` + } + ) + return + } + + if (timeout) { + clearTimeout(timeout) + } + } + + protected abstract getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null +} diff --git a/src/main/updater/updater-menu-checks.ts b/src/main/updater/updater-menu-checks.ts new file mode 100644 index 00000000000..b76d5c19710 --- /dev/null +++ b/src/main/updater/updater-menu-checks.ts @@ -0,0 +1,100 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import { UpdaterScheduling } from './updater-scheduling' + +/** Handles checks initiated from the desktop menu and modifier-key variants. */ +export abstract class UpdaterMenuChecks extends UpdaterScheduling { + protected checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available', userInitiated: true }) + return + } + if (options?.localBuild) { + void this.checkForLocalBuildFromMenu() + return + } + if (options?.targetTag && options.channel) { + void this.checkForPinnedBuild(options.channel, options.targetTag) + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + if ( + this.activeUpdateSource !== 'release' && + (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') + ) { + return + } + this.restoreReleaseUpdateSource() + + const checkVariant = this.getUpdateCheckVariant(options) + if (checkVariant === 'prerelease') { + this.clearPrereleaseFallbackContext() + this.enableIncludePrerelease() + } else if (checkVariant === 'perf') { + this.clearPrereleaseFallbackContext() + // Why: perf checks need prerelease manifests now, but must not opt future default/background checks into the RC channel. + this.enablePrereleaseManifestChecks() + } + + const checkAlreadyInFlight = + this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking' + this.userInitiatedCheck = true + // Why: manual checks are nudge-independent; clear the marker so a later dismiss can't consume the campaign by accident. + this.activeUpdateNudgeId = null + // Why: respond visibly before feed pinning/updater events; duplicate broadcasts are suppressed by status equality below. + this.sendStatus({ state: 'checking', userInitiated: true }) + if (checkAlreadyInFlight) { + this.backgroundCheckPromotedToUserInitiated = true + this.rearmActiveUpdateCheckStallTimer() + if (checkVariant !== 'default') { + // Why: in-flight check may have pinned the stable feed; queue a fresh modifier check to avoid a stale-channel result. + this.pendingUserInitiatedCheckAfterInFlight = checkVariant + } + return + } + + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed(checkVariant).then((preflightResult) => { + if (preflightResult === 'not-available') { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return false + } + this.userInitiatedCheck = false + this.finishActiveUpdateCheckAttempt() + this.recordCompletedUpdateCheck() + this.sendStatus({ state: 'not-available', userInitiated: true }) + return false + } + return launch() + }) + void Promise.resolve(run) + .then((launchResult) => { + if (launchResult === false) { + return + } + this.handleSettledUpdateCheckPromise(attemptId) + }) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + this.userInitiatedCheck = false + void this.sendCheckFailureStatus(String(err?.message ?? err), true, 'promise', err) + }) + } + + protected abstract checkForLocalBuildFromMenu(): Promise + protected abstract checkForPinnedBuild(channel: ReleaseChannel, tag: string): Promise +} diff --git a/src/main/updater/updater-nudge.ts b/src/main/updater/updater-nudge.ts new file mode 100644 index 00000000000..765af67cd53 --- /dev/null +++ b/src/main/updater/updater-nudge.ts @@ -0,0 +1,86 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { fetchNudge, shouldApplyNudge } from '../updater-nudge' +import { NUDGE_ACTIVATION_COOLDOWN_MS, NUDGE_POLL_INTERVAL_MS } from './updater-state' +import { UpdaterBuildSelection } from './updater-build-selection' + +/** Polls update campaigns and exposes their dismissal actions. */ +export abstract class UpdaterNudge extends UpdaterBuildSelection { + protected async checkForUpdateNudge(): Promise { + if (!app.isPackaged || is.dev) { + return + } + if (this.nudgeCheckInFlight) { + return + } + const now = Date.now() + if (now - this.lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) { + return + } + this.lastNudgeCheckAt = now + this.nudgeCheckInFlight = true + try { + const nudge = await fetchNudge() + if (!nudge) { + return + } + if (this.currentStatus.state === 'checking' || this.currentStatus.state === 'downloading') { + return + } + const appVersion = app.getVersion() + const pendingUpdateNudgeId = this._getPendingUpdateNudgeId?.() ?? null + const dismissedUpdateNudgeId = this._getDismissedUpdateNudgeId?.() ?? null + if ( + shouldApplyNudge({ + nudge, + appVersion, + pendingUpdateNudgeId, + dismissedUpdateNudgeId + }) + ) { + this.awaitingNudgeCheckOutcome = true + this._setPendingUpdateNudgeId?.(nudge.id) + this.mainWindowRef?.webContents.send('updater:clearDismissal') + this.runBackgroundUpdateCheck(nudge.id) + } + } finally { + this.nudgeCheckInFlight = false + } + } + + protected scheduleUpdateNudgeCheck(): void { + if (this.nudgeCheckTimer) { + clearTimeout(this.nudgeCheckTimer) + } + this.nudgeCheckTimer = setTimeout(() => { + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + }, NUDGE_POLL_INTERVAL_MS) + } + + protected dismissNudge(): void { + const pendingId = this.activeUpdateNudgeId ?? this._getPendingUpdateNudgeId?.() ?? null + if (pendingId) { + this._setDismissedUpdateNudgeId?.(pendingId) + this.clearPendingUpdateNudge() + } + } + + /** Abandons an un-acted local or pinned update and restores the release feed. */ + protected dismissAvailableUpdate(): void { + if (this.activeUpdateSource === 'release' && !this.isPinnedBuildActive) { + return + } + if (this.localBuildSelectionInProgress || this.pinnedBuildSelectionInProgress) { + return + } + // Why: only an un-acted 'available' card is abandoned — 'downloading'/'downloaded' still need the pinned feed and allowDowngrade. + if (this.currentStatus.state !== 'available') { + return + } + this.clearAvailableUpdateContext() + this.restoreReleaseUpdateSource() + // Why: leaving the card's 'available' status behind would let a retry download the local version off the restored release feed. + this.sendStatus({ state: 'idle' }) + } +} diff --git a/src/main/updater/updater-package-recovery.ts b/src/main/updater/updater-package-recovery.ts new file mode 100644 index 00000000000..870d601a5f3 --- /dev/null +++ b/src/main/updater/updater-package-recovery.ts @@ -0,0 +1,274 @@ +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { + getTrackedLinuxPackageArtifact, + clearTrackedLinuxPackageArtifact, + revalidateLinuxPackageForInstall, + resolveLinuxPackageInstallInstructions, + revealLinuxPackage, + type LinuxPackageArtifact, + type LinuxPackageRecoveryUnavailableReason +} from '../linux-package-update-recovery' +import { + getLinuxPackageInstallDiagnostic, + parseLinuxPackageInstallExitCode, + redactLinuxPackageInstallText +} from '../linux-package-install-diagnostic' +import type { + LinuxPackageInstallInstructions, + LinuxPackageInstallRecovery, + UpdateStatus +} from '../../shared/update-status-types' +import { UpdaterInstallSupport } from './updater-install-support' + +const LINUX_PACKAGE_RECOVERY_MESSAGES: Record = { + missing: + 'The downloaded package is no longer in the update cache. Download the update again, or get it from the official release page.', + // Why: this reason also covers a path that left the cache (traversal or symlinked parent), so the copy must not promise the file merely changed type. + 'not-regular': + 'The downloaded package is no longer a valid file in the update cache. Download the update again, or get it from the official release page.', + 'hash-mismatch': + 'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.', + 'read-failed': + 'Orca could not read the downloaded package. Download the update again, or get it from the official release page.', + 'no-sudo': + 'No sudo command was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + 'no-package-manager': + 'No supported package manager was found in the system directories, so Orca cannot build a safe install command. Show the package and install it with your package manager.', + // Defensive: capture only ever tracks absolute cache paths, so this reports a bug rather than a machine state. + 'invalid-package-path': + 'The downloaded package is not at a usable path, so Orca cannot build a safe install command. Show the package and install it with your package manager.' +} + +// Why: clearing the artifact alone would leave the renderer's actions enabled; the status must lose its recovery too. +const RECOVERY_CLEARING_REASONS: LinuxPackageRecoveryUnavailableReason[] = [ + 'missing', + 'not-regular', + 'hash-mismatch' +] + +export abstract class UpdaterPackageRecovery extends UpdaterInstallSupport { + protected getActiveLinuxPackageRecovery(): LinuxPackageInstallRecovery | null { + if (this.currentStatus.state !== 'error') { + return null + } + return this.currentStatus.recovery?.kind === 'linux-package-install' + ? this.currentStatus.recovery + : null + } + + protected recordLinuxPackageRecoveryUnavailable( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): void { + recordUpdaterLifecycle( + 'linux_package_recovery_unavailable', + { reason, packageType: recovery.packageType, version: recovery.version }, + { level: 'warn', message: 'Linux package recovery action unavailable' } + ) + } + + protected failLinuxPackageRecovery( + recovery: LinuxPackageInstallRecovery, + reason: LinuxPackageRecoveryUnavailableReason + ): never { + this.recordLinuxPackageRecoveryUnavailable(recovery, reason) + const message = LINUX_PACKAGE_RECOVERY_MESSAGES[reason] + // Why: hashing 160 MB takes long enough for a new cycle to land. Acting on a stale verdict would + // destroy the newer artifact and clobber whatever card replaced this one. + const active = this.getActiveLinuxPackageRecovery() + const stillCurrent = + active?.version === recovery.version && active?.packageType === recovery.packageType + if (stillCurrent && RECOVERY_CLEARING_REASONS.includes(reason)) { + clearTrackedLinuxPackageArtifact() + this.sendStatus({ state: 'error', message }) + } + throw new Error(message) + } + + /** + * Identifies the update cycle an install belongs to, so a verdict produced by a multi-second hash + * can be dropped when a newer cycle already replaced the card it would otherwise overwrite. + */ + protected getInstallCycleSignature(): string { + const recovery = this.getActiveLinuxPackageRecovery() + if (recovery) { + return `recovery:${recovery.packageType}:${recovery.version}` + } + return this.currentStatus.state === 'downloaded' + ? `downloaded:${this.currentStatus.version}` + : `state:${this.currentStatus.state}` + } + + /** + * Re-proves the retained package before the install starts. Returns false when the install must be + * abandoned; the artifact is only re-read here, so callers still own every teardown decision. + */ + protected async proveRetainedLinuxPackage(pendingVersion: string): Promise { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return true + } + // Why: an artifact retained from another cycle says nothing about the file electron-updater is + // about to install, so proving it would block a legitimate install on an unrelated digest. + if (pendingVersion && pendingVersion !== artifact.version) { + return true + } + const recovery = this.getActiveLinuxPackageRecovery() + const cycle = this.getInstallCycleSignature() + const reason = await this.revalidateRetainedLinuxPackage(artifact) + if (!reason) { + return true + } + this.reportLinuxPackageRevalidationFailure({ artifact, recovery, reason, cycle }) + return false + } + + /** The failing reason, or null when the retained package still matches its release digest. */ + protected async revalidateRetainedLinuxPackage( + artifact: LinuxPackageArtifact + ): Promise { + this.linuxPackageRevalidationInFlight = true + try { + const verdict = await revalidateLinuxPackageForInstall(artifact) + return verdict.ok ? null : verdict.reason + } catch (error) { + recordUpdaterLifecycle( + 'linux_package_revalidation_errored', + { errorType: error instanceof Error ? error.name : typeof error }, + { level: 'warn', message: 'Could not re-verify the retained update package' } + ) + // Why: fail closed — bytes we could not read are bytes we cannot hand to a root installer. + return 'read-failed' + } finally { + // Why: the invariant every install path depends on — a wedged flag would make quitAndInstall + // early-return for the rest of the session. + this.linuxPackageRevalidationInFlight = false + } + } + + protected reportLinuxPackageRevalidationFailure({ + artifact, + recovery, + reason, + cycle + }: { + artifact: LinuxPackageArtifact + recovery: LinuxPackageInstallRecovery | null + reason: LinuxPackageRecoveryUnavailableReason + cycle: string + }): void { + recordUpdaterLifecycle( + 'linux_package_revalidation_failed', + { + action: recovery ? 'retry-automatic' : 'restart-to-install', + packageType: artifact.packageType, + version: artifact.version, + reason + }, + { level: 'warn', message: 'Retained update package failed its pre-install digest check' } + ) + // Why: a package proven bad must not stay tracked, but a download that landed during the hash + // owns the slot now and destroying it would force a needless 160 MB redownload. + const clearsArtifact = RECOVERY_CLEARING_REASONS.includes(reason) + if (clearsArtifact && getTrackedLinuxPackageArtifact() === artifact) { + clearTrackedLinuxPackageArtifact() + } + // Why: same reasoning as failLinuxPackageRecovery — a verdict from a cycle that has since been + // replaced must not clobber whatever card the user is looking at now. + if (this.getInstallCycleSignature() !== cycle) { + return + } + this.sendInstallFailureStatus({ + state: 'error', + message: LINUX_PACKAGE_RECOVERY_MESSAGES[reason], + // Why: an unreadable file is not evidence the bytes changed, so the recovery card and its + // Copy/Show actions survive a transient I/O failure exactly as they do elsewhere. + ...(recovery && !clearsArtifact ? { recovery } : {}) + }) + } + + protected async getLinuxPackageInstallInstructions(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'copy-command', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await resolveLinuxPackageInstallInstructions(recovery) + if (!result.ok) { + // Why: the renderer must distinguish "this machine has no package manager" (keep the card, promote + // Show Package) from "the artifact is gone" (recovery is cleared and the card unmounts). + if (result.reason === 'no-sudo' || result.reason === 'no-package-manager') { + this.recordLinuxPackageRecoveryUnavailable(recovery, result.reason) + return { + ok: false, + reason: result.reason, + message: LINUX_PACKAGE_RECOVERY_MESSAGES[result.reason] + } + } + this.failLinuxPackageRecovery(recovery, result.reason) + } + return { ok: true, command: result.command, packageFileName: result.packageFileName } + } + + protected async showLinuxPackage(): Promise { + const recovery = this.getActiveLinuxPackageRecovery() + if (!recovery) { + throw new Error('No package install recovery is available.') + } + recordUpdaterLifecycle('linux_package_recovery_requested', { + action: 'show-package', + packageType: recovery.packageType, + version: recovery.version + }) + const result = await revealLinuxPackage(recovery) + if (!result.ok) { + this.failLinuxPackageRecovery(recovery, result.reason) + } + } + + /** Builds a recoverable status when the native Linux package installer rejects a retained artifact. */ + protected buildLinuxPackageInstallFailureStatus(error: unknown): UpdateStatus | null { + const artifact = getTrackedLinuxPackageArtifact() + if (!artifact) { + return null + } + const pendingVersion = this.getPendingInstallVersion() + if (pendingVersion && pendingVersion !== artifact.version) { + return null + } + const diagnostic = getLinuxPackageInstallDiagnostic() ?? this.lastInstallAttemptDiagnostic + const reason = diagnostic?.reason ?? 'package-install-failed' + const exitCode = parseLinuxPackageInstallExitCode(error) + recordUpdaterLifecycle( + 'linux_package_install_failed', + { + packageType: artifact.packageType, + reason, + ...(exitCode === null ? {} : { exitCode }), + version: artifact.version, + errorType: error instanceof Error ? error.name : typeof error + }, + { level: 'warn', message: 'Linux package install failed; cached package retained' } + ) + const message = + diagnostic?.message ?? + (error instanceof Error + ? redactLinuxPackageInstallText(error.message, artifact.path) + : null) ?? + 'The system package installer did not start.' + return { + state: 'error', + message, + recovery: { + kind: 'linux-package-install', + packageType: artifact.packageType, + reason, + version: artifact.version + } + } + } +} diff --git a/src/main/updater/updater-release-feed.ts b/src/main/updater/updater-release-feed.ts new file mode 100644 index 00000000000..46a34eb49ed --- /dev/null +++ b/src/main/updater/updater-release-feed.ts @@ -0,0 +1,270 @@ +import { app } from 'electron' +import { + fetchNewerReleaseTagsWithReadiness, + getReleaseDownloadUrl +} from '../updater-prerelease-feed' +import { isMissingUpdateManifestFailure, isPrereleaseVersion } from '../updater-fallback' +import type { CheckFailureSource } from './updater-state' +import type { UpdateCheckVariant } from './updater-types' +import { ReleaseFeedPreflightError } from './updater-state' +import { UpdaterInstallExecution } from './updater-install-execution' + +/** Owns concrete release-feed pinning and the one-shot prerelease fallback. */ +export abstract class UpdaterReleaseFeed extends UpdaterInstallExecution { + protected clearPrereleaseFallbackContextIfSettled(): void { + if ( + this.pendingPrereleaseFallback?.fallbackResultHandled && + !this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedPrimaryEventFailure && + !this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey && + !this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey + ) { + this.clearPrereleaseFallbackContext() + } + } + + protected getMissingManifestPrereleaseFallbackUserInitiated(): boolean | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + return this.pendingPrereleaseFallback.userInitiated + } + + protected markMissingManifestPrereleaseFallbackChecking(): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = true + } + + protected consumeMissingManifestPrereleaseFallbackResult(): { userInitiated: boolean } | null { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return null + } + const result = { userInitiated: this.pendingPrereleaseFallback.userInitiated } + this.pendingPrereleaseFallback.fallbackResultHandled = true + this.clearPrereleaseFallbackContextIfSettled() + return result + } + + protected suppressMissingManifestPrereleaseFallbackPromiseFailure(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackPromiseFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected shouldSuppressMissingManifestPrereleaseFallbackEvent( + message: string, + error: unknown + ): boolean { + if (!this.pendingPrereleaseFallback?.retryLaunched) { + return false + } + const failureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + const primaryEventSuppression = this.pendingPrereleaseFallback.suppressedPrimaryEventFailure + if (primaryEventSuppression?.failureKey === failureKey) { + const isPrimaryPromisePair = primaryEventSuppression.error === error + // Why: after fallback checking starts, same-message errors may be the fallback's, so message matching alone isn't safe. + if (isPrimaryPromisePair || !this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen) { + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + } + if (this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey === failureKey) { + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = null + this.clearPrereleaseFallbackContextIfSettled() + return true + } + return false + } + + protected markMissingManifestPrereleaseFallbackPromiseHandled(message: string): void { + if ( + !this.pendingPrereleaseFallback?.retryLaunched || + this.pendingPrereleaseFallback.fallbackResultHandled + ) { + return + } + this.pendingPrereleaseFallback.suppressedFallbackEventFailureKey = this.getCheckFailureKey( + message, + this.pendingPrereleaseFallback.userInitiated + ) + } + + protected async pinDefaultReleaseFeed( + variant: UpdateCheckVariant = 'default' + ): Promise<'ready' | 'not-available'> { + const autoUpdater = this.getAutoUpdater() + // Why: the latest/download redirect can move between check and download, so pin the concrete tag (prerelease users resolve any channel, stable only stable). + const currentVersion = app.getVersion() + const isPerfCheck = variant === 'perf' + const includePrerelease = + isPerfCheck || this.includePrereleaseActive || isPrereleaseVersion(currentVersion) + const releaseTagsResult = await fetchNewerReleaseTagsWithReadiness( + currentVersion, + includePrerelease ? 2 : 1, + { + includePrerelease, + ...(isPerfCheck ? { releaseFilter: 'perf' as const } : {}) + } + ) + const newerTag = releaseTagsResult.tags[0] ?? null + const fallbackTag = includePrerelease ? (releaseTagsResult.tags[1] ?? null) : null + this.pendingPrereleaseFallback = + includePrerelease && newerTag && fallbackTag + ? { + primaryTag: newerTag, + fallbackTag, + userInitiated: false, + suppressedPrimaryPromiseFailureKey: null, + suppressedPrimaryEventFailure: null, + suppressedFallbackPromiseFailureKey: null, + suppressedFallbackEventFailureKey: null, + fallbackResultHandled: false, + fallbackCheckingForUpdateSeen: false, + retryLaunched: false + } + : null + // Why: console.info is captured by Console.app/--enable-logging — our only field visibility into the updater. + if (newerTag) { + this.clearPublishingWindowLastGoodCheck() + const url = getReleaseDownloadUrl(newerTag) + console.info( + `[updater] release feed pinned: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + if (releaseTagsResult.state === 'not-ready') { + this.clearPrereleaseFallbackContext() + if (releaseTagsResult.lastGoodTag) { + // Why: during a publish window the newest tag is unsafe; a verified last-good concrete feed lets electron-updater emit a real result. + const url = getReleaseDownloadUrl(releaseTagsResult.lastGoodTag) + console.info( + `[updater] release feed pinned to last-good: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + this.publishingWindowLastGoodCheck = { lastGoodTag: releaseTagsResult.lastGoodTag } + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + this.clearPublishingWindowLastGoodCheck() + console.info( + `[updater] release feed deferred: current=${currentVersion} includePrerelease=${includePrerelease}; newest release assets are not ready` + ) + throw new ReleaseFeedPreflightError( + 'release-not-ready', + isPerfCheck ? 'perf' : includePrerelease ? 'prerelease' : 'default', + 'Latest release artifacts are not ready' + ) + } + if ( + releaseTagsResult.state === 'unavailable' && + releaseTagsResult.unavailableReason === 'manifest' && + !includePrerelease + ) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + throw new ReleaseFeedPreflightError( + 'manifest-unavailable', + 'default', + 'Unable to find latest version on GitHub' + ) + } + if (isPerfCheck) { + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + if (releaseTagsResult.state === 'no-newer') { + console.info( + `[updater] perf release not found: current=${currentVersion} includePrerelease=${includePrerelease}` + ) + return 'not-available' + } + throw new Error('Could not resolve perf update feed') + } + this.clearPrereleaseFallbackContext() + this.clearPublishingWindowLastGoodCheck() + const url = 'https://github.com/stablyai/orca/releases/latest/download' + console.info( + `[updater] release feed fallback: current=${currentVersion} includePrerelease=${includePrerelease} → ${url}` + ) + autoUpdater.setFeedURL({ provider: 'generic', url }) + return 'ready' + } + + protected retryPrereleaseFallbackAfterMissingManifest( + message: string, + userInitiated: boolean | undefined, + source: CheckFailureSource, + failureKey: string, + sourceError?: unknown + ): boolean { + if ( + !this.pendingPrereleaseFallback || + this.pendingPrereleaseFallback.retryLaunched || + !isMissingUpdateManifestFailure(message) + ) { + return false + } + const attemptId = this.activeUpdateCheckAttemptId + if (attemptId === null) { + return false + } + // Why: a published tag can briefly lack its platform manifest mid-release; walk back once to the previous feed for a normal not-available result. + this.pendingPrereleaseFallback.retryLaunched = true + this.pendingPrereleaseFallback.userInitiated = Boolean(userInitiated) + this.pendingPrereleaseFallback.suppressedPrimaryPromiseFailureKey = + source === 'event' ? failureKey : null + this.pendingPrereleaseFallback.suppressedPrimaryEventFailure = + source === 'promise' ? { failureKey, error: sourceError } : null + this.pendingPrereleaseFallback.fallbackCheckingForUpdateSeen = false + const { primaryTag, fallbackTag } = this.pendingPrereleaseFallback + const url = getReleaseDownloadUrl(fallbackTag) + console.info( + `[updater] prerelease manifest missing for ${primaryTag}; retrying once against ${url}` + ) + const autoUpdater = this.getAutoUpdater() + autoUpdater.setFeedURL({ provider: 'generic', url }) + this.userInitiatedCheck = Boolean(userInitiated) + this.backgroundCheckLaunchPending = !userInitiated + this.armUpdateCheckStallTimer(attemptId) + this.markUpdateCheckLaunched(attemptId) + void autoUpdater + .checkForUpdates() + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const fallbackMessage = String(err?.message ?? err) + if (userInitiated) { + this.userInitiatedCheck = false + } else { + this.backgroundCheckLaunchPending = false + } + this.markMissingManifestPrereleaseFallbackPromiseHandled(fallbackMessage) + this.consumeMissingManifestPrereleaseFallbackResult() + void this.sendCheckFailureStatus(fallbackMessage, userInitiated, 'fallback-promise', err) + }) + return true + } +} diff --git a/src/main/updater/updater-remote-status.ts b/src/main/updater/updater-remote-status.ts new file mode 100644 index 00000000000..6e3db76b0c8 --- /dev/null +++ b/src/main/updater/updater-remote-status.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { hasServeUpdateSupervisor } from '../serve-update-handoff' +import { UpdaterNudge } from './updater-nudge' +import type { UpdateInstallMode } from './updater-state' + +/** Exposes updater state to runtime RPC callers without leaking internal mutators. */ +export abstract class UpdaterRemoteStatus extends UpdaterNudge { + protected getUpdateStatus(): UpdateStatus { + return this.currentStatus + } + + protected getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + if (!app.isPackaged || is.dev) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'unpackaged-build' + } + } + if (!this.autoUpdaterInitialized) { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'updater-unavailable' + } + } + if (this.updateInstallMode === 'unsupported-headless-serve') { + return { + installMode: this.updateInstallMode, + automatic: false, + reason: 'manual-service-update-required' + } + } + return { installMode: this.updateInstallMode, automatic: true, reason: 'available' } + } + + protected getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return { + appVersion: app.getVersion(), + runtimeId, + support: this.getRemoteServerUpdateSupport(), + status: this.getUpdateStatus() + } + } + + protected assertRemoteServerUpdateAvailable(): void { + if (!this.getRemoteServerUpdateSupport().automatic) { + throw new Error('remote_update_manual_required') + } + } + + protected checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + this.checkForUpdatesFromMenu(options) + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'available') { + throw new Error('remote_update_not_available') + } + this.downloadUpdate() + return this.getRemoteServerUpdaterSnapshot(runtimeId) + } + + protected installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + this.assertRemoteServerUpdateAvailable() + if (this.currentStatus.state !== 'downloaded') { + throw new Error('remote_update_not_downloaded') + } + const targetVersion = this.currentStatus.version + const result: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: app.getVersion(), + targetVersion, + runtimeId + } + this.quitAndInstall() + return result + } + + protected resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + if (!isServeMode) { + return 'interactive' + } + return hasServeUpdateSupervisor() ? 'supervised-headless-serve' : 'unsupported-headless-serve' + } + + protected abstract downloadUpdate(): void + protected abstract quitAndInstall(): void +} diff --git a/src/main/updater/updater-scheduling.ts b/src/main/updater/updater-scheduling.ts new file mode 100644 index 00000000000..954431385d0 --- /dev/null +++ b/src/main/updater/updater-scheduling.ts @@ -0,0 +1,121 @@ +import { app } from 'electron' +import { is } from '@electron-toolkit/utils' +import { withUpdaterSpan } from '../observability/instrumentation' +import { + AUTO_UPDATE_CHECK_INTERVAL_MS, + AUTO_UPDATE_RETRY_INTERVAL_MS, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS +} from './updater-state' +import { UpdaterCheckFailure } from './updater-check-failure' + +/** Owns timer-driven checks and the shared check-launch bookkeeping. */ +export abstract class UpdaterScheduling extends UpdaterCheckFailure { + protected getAutomaticRetryInterval(): number { + return AUTO_UPDATE_RETRY_INTERVAL_MS + } + + protected scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers pass exactly this constant, so keying backoff on it keeps one choke point instead of threading a flag through every schedule site. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** this.consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + this.consecutiveAutomaticRetrySchedules += 1 + } + if (this.autoUpdateCheckTimer) { + clearTimeout(this.autoUpdateCheckTimer) + } + this.autoUpdateCheckTimer = setTimeout(() => { + // Why: Orca runs for days, so keep the next background check scheduled in the main process rather than tying it to relaunches or renderer lifetime. + if (!this.runBackgroundUpdateCheck()) { + // Why: a deferred check reaches no outcome handler, so re-arm here or one deferral ends automatic checks for the process lifetime. + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + }, effectiveDelayMs) + } + + protected recordCompletedUpdateCheck(): void { + this.consecutiveAutomaticRetrySchedules = 0 + this.persistLastUpdateCheckAt?.(Date.now()) + } + + /** Returns false when the check was deferred instead of launched, so timer-driven callers can re-arm. */ + protected runBackgroundUpdateCheck( + nudgeId: string | null = this.getPersistedPendingUpdateNudgeId() + ): boolean { + // Why: a pinned dev jump owns the feed until it settles; a background check would repoint it mid-flight and download the wrong build. + if ( + this.activeUpdateSource !== 'release' || + this.isPinnedBuildActive || + this.localBuildSelectionInProgress || + this.pinnedBuildSelectionInProgress + ) { + return false + } + if (this.backgroundCheckLaunchPending || this.currentStatus.state === 'checking') { + return false + } + if (!app.isPackaged || is.dev) { + this.sendStatus({ state: 'not-available' }) + return false + } + // Why: set the nudge marker before any events arrive so later checks can't inherit a stale campaign id; persisted id keeps a nudge card dismissable after relaunch. + this.activeUpdateNudgeId = nudgeId + // Why: 'checking-for-update' arrives a tick later, so a second focus/resume can slip in before status flips; track launch in memory to dedupe that gap. + this.backgroundCheckLaunchPending = true + this.backgroundCheckPromotedToUserInitiated = false + const attemptId = this.beginUpdateCheckAttempt() + const autoUpdater = this.getAutoUpdater() + const launch = (): Promise | undefined => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return undefined + } + this.markUpdateCheckLaunched(attemptId) + return autoUpdater.checkForUpdates() + } + const run = this.pinDefaultReleaseFeed().then(launch) + void Promise.resolve(run) + .then(() => this.handleSettledUpdateCheckPromise(attemptId)) + .catch((err) => { + if (!this.isActiveUpdateCheckAttempt(attemptId)) { + return + } + const wasUserInitiated = this.getSettledCheckUserInitiated() + this.backgroundCheckLaunchPending = false + this.backgroundCheckPromotedToUserInitiated = false + if (wasUserInitiated) { + this.userInitiatedCheck = false + } + void this.sendCheckFailureStatus( + String(err?.message ?? err), + wasUserInitiated, + 'promise', + err + ) + }) + return true + } + + protected checkForUpdatesInBackground(): void { + // Why: span records only check launch (always Success), not outcome; dashboards must filter `updater.outcome === 'launched'`, not this span's success rate. + void withUpdaterSpan({ stage: 'check' }, async (span) => { + span.setAttribute('updater.outcome', 'launched') + this.runBackgroundUpdateCheck() + }) + } + + protected enablePrereleaseManifestChecks(): void { + this.getAutoUpdater().allowPrerelease = true + } + + protected enableIncludePrerelease(): void { + if (this.includePrereleaseActive) { + return + } + // Why: this flag makes electron-updater accept prerelease manifests; we keep the manifest-probed generic feed over the native GitHub provider because cancelled RCs can appear without assets. + this.enablePrereleaseManifestChecks() + this.includePrereleaseActive = true + } +} diff --git a/src/main/updater/updater-setup.ts b/src/main/updater/updater-setup.ts new file mode 100644 index 00000000000..21354f97af3 --- /dev/null +++ b/src/main/updater/updater-setup.ts @@ -0,0 +1,251 @@ +import { app, powerMonitor } from 'electron' +import type { BrowserWindow } from 'electron' +import { is } from '@electron-toolkit/utils' +import type { ReleaseBuild, ReleaseChannel } from '../../shared/release-channel' +import type { + LinuxPackageInstallInstructions, + UpdateCheckOptions, + UpdateStatus +} from '../../shared/update-status-types' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot, + RemoteServerUpdateSupport +} from '../../shared/remote-server-update' +import { getLinuxRootPackageType } from '../linux-update-package-type' +import { createUpdaterDiagnosticLogger } from '../linux-package-install-diagnostic' +import { registerAutoUpdaterHandlers } from '../updater-events' +import { getServeUpdateHandoffFailure } from '../serve-update-handoff' +import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' +import { AUTO_UPDATE_CHECK_INTERVAL_MS } from './updater-state' +import { UpdaterDownloadInstall } from './updater-download-install' +import type { UpdateInstallMode } from './updater-state' + +export type UpdaterSetupOptions = { + getLastUpdateCheckAt?: () => number | null + onBeforeQuit?: () => void | Promise + setLastUpdateCheckAt?: (timestamp: number) => void + getPendingUpdateNudgeId?: () => string | null + getDismissedUpdateNudgeId?: () => string | null + setPendingUpdateNudgeId?: (id: string | null) => void + setDismissedUpdateNudgeId?: (id: string | null) => void + getReleaseChannelOverride?: () => ReleaseChannel | null + installMode?: UpdateInstallMode +} + +/** Initializes electron-updater and attaches lifecycle/event bridges. */ +export class UpdaterSetup extends UpdaterDownloadInstall { + checkForUpdates(): void { + this.checkForUpdatesInBackground() + } + + checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { + super.checkForUpdatesFromMenu(options) + } + + downloadUpdate(): void { + super.downloadUpdate() + } + + quitAndInstall(): void { + super.quitAndInstall() + } + + isQuittingForUpdate(): boolean { + return super.isQuittingForUpdate() + } + + getUpdateStatus(): UpdateStatus { + return super.getUpdateStatus() + } + + getRemoteServerUpdateSupport(): RemoteServerUpdateSupport { + return super.getRemoteServerUpdateSupport() + } + + getRemoteServerUpdaterSnapshot(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.getRemoteServerUpdaterSnapshot(runtimeId) + } + + checkForRemoteServerUpdate( + runtimeId: string, + options?: UpdateCheckOptions + ): RemoteServerUpdaterSnapshot { + return super.checkForRemoteServerUpdate(runtimeId, options) + } + + downloadRemoteServerUpdate(runtimeId: string): RemoteServerUpdaterSnapshot { + return super.downloadRemoteServerUpdate(runtimeId) + } + + installRemoteServerUpdate(runtimeId: string): RemoteServerUpdateInstallResult { + return super.installRemoteServerUpdate(runtimeId) + } + + resolveUpdateInstallMode(isServeMode: boolean): UpdateInstallMode { + return super.resolveUpdateInstallMode(isServeMode) + } + + async getLinuxPackageInstallInstructions(): Promise { + return super.getLinuxPackageInstallInstructions() + } + + async showLinuxPackage(): Promise { + return super.showLinuxPackage() + } + + async listAvailableReleaseBuilds(channel: ReleaseChannel): Promise { + return super.listAvailableReleaseBuilds(channel) + } + + dismissNudge(): void { + super.dismissNudge() + } + + dismissAvailableUpdate(): void { + super.dismissAvailableUpdate() + } + + setupAutoUpdater(mainWindow: BrowserWindow, opts?: UpdaterSetupOptions): void { + this.mainWindowRef = mainWindow + this.onBeforeQuitCleanup = opts?.onBeforeQuit ?? null + this.persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null + this._getLastUpdateCheckAt = opts?.getLastUpdateCheckAt ?? null + this._getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null + this._getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null + this._setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null + this._setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null + this.getReleaseChannelOverride = opts?.getReleaseChannelOverride ?? null + this.updateInstallMode = opts?.installMode ?? 'interactive' + this.lastInstallDeferralVersion = { download: null, install: null } + + const serveHandoffFailure = getServeUpdateHandoffFailure() + if (serveHandoffFailure) { + recordUpdaterLifecycle( + 'headless_serve_handoff_failed', + { reason: serveHandoffFailure }, + { level: 'warn', message: 'Supervised serve update did not complete' } + ) + this.sendErrorStatus(`The server update did not complete: ${serveHandoffFailure}`, true) + } + + if (!app.isPackaged && !is.dev) { + return + } + if (is.dev) { + return + } + + const autoUpdater = this.getAutoUpdater() + autoUpdater.autoDownload = false + if (this.activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } + // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. + // Root Linux packages also opt out: an implicit quit-time escalation would fail after the UI is gone, leaving no recovery surface. + autoUpdater.autoInstallOnAppQuit = + this.updateInstallMode === 'interactive' && getLinuxRootPackageType() === null + // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. + autoUpdater.autoRunAppAfterInstall = this.updateInstallMode === 'interactive' + // Why: our only on-machine window into electron-updater; otherwise an unexpected update-not-available or failed fetch is invisible. + autoUpdater.logger = createUpdaterDiagnosticLogger() as never + + // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. + if (this.activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + if (this.autoUpdaterInitialized) { + return + } + this.autoUpdaterInitialized = true + + registerAutoUpdaterHandlers({ + autoUpdater, + clearBackgroundCheckLaunchPending: () => this.clearBackgroundCheckLaunchPending(), + clearAvailableUpdateContext: () => this.clearAvailableUpdateContext(), + consumeMissingManifestPrereleaseFallbackResult: () => + this.consumeMissingManifestPrereleaseFallbackResult(), + getPublishingWindowLastGoodCheck: () => this.getPublishingWindowLastGoodCheck(), + getMissingManifestPrereleaseFallbackUserInitiated: () => + this.getMissingManifestPrereleaseFallbackUserInitiated(), + getCurrentStatus: () => this.currentStatus, + getActiveUpdateCheckEventAttemptId: () => this.getActiveUpdateCheckEventAttemptId(), + getKnownReleaseUrl: () => this.getKnownReleaseUrl(), + getPendingInstallVersion: () => this.getPendingInstallVersion(), + getUserInitiatedCheck: () => this.userInitiatedCheck, + handleQuitAndInstallFailure: (error) => this.handleQuitAndInstallFailure(error), + isQuitAndInstallHandoffActive: () => this.isQuitAndInstallHandoffActive(), + hasInstallableDownloadedVersion: () => this.hasInstallableDownloadedVersion(), + isLocalBuildCheck: () => this.activeUpdateSource === 'local', + // Why: pinned jumps are deliberate, so update-available/-downloaded must not reject them for being older than the running version. + isPinnedBuildCheck: () => this.isPinnedBuildActive, + shouldHandleUpdaterErrorEvent: () => this.shouldHandleUpdaterErrorEvent(), + clearUpdateAvailableEventPending: (attemptId) => + this.clearUpdateAvailableEventPending(attemptId), + isActiveUpdateCheckAttempt: (attemptId) => this.isActiveUpdateCheckAttempt(attemptId), + markUpdateCheckEventAttempt: () => this.markUpdateCheckEventAttempt(), + markUpdateAvailableEventPending: (attemptId) => + this.markUpdateAvailableEventPending(attemptId), + markMissingManifestPrereleaseFallbackChecking: () => + this.markMissingManifestPrereleaseFallbackChecking(), + performQuitAndInstall: () => this.performQuitAndInstall(), + shouldDeferMacQuitForInstall: () => this.updateInstallMode === 'interactive', + recordCompletedUpdateCheck: () => this.recordCompletedUpdateCheck(), + restoreReleaseUpdateSource: () => this.restoreReleaseUpdateSource(), + sendCheckFailureStatus: (message, userInitiated, source, sourceError) => + this.sendCheckFailureStatus(message, userInitiated, source, sourceError), + sendErrorStatus: (message, userInitiated) => this.sendErrorStatus(message, userInitiated), + sendStatus: (status) => this.sendStatus(status), + scheduleAutomaticUpdateCheck: (delayMs) => this.scheduleAutomaticUpdateCheck(delayMs), + shouldSuppressMissingManifestPrereleaseFallbackEvent: (message, error) => + this.shouldSuppressMissingManifestPrereleaseFallbackEvent(message, error), + suppressMissingManifestPrereleaseFallbackPromiseFailure: (message) => + this.suppressMissingManifestPrereleaseFallbackPromiseFailure(message), + setAvailableReleaseUrl: (releaseUrl) => { + this.availableReleaseUrl = releaseUrl + }, + setAvailableVersion: (version) => { + this.availableVersion = version + }, + setUserInitiatedCheck: (value) => { + this.userInitiatedCheck = value + } + }) + + void this.checkForUpdateNudge() + this.scheduleUpdateNudgeCheck() + + const checkDailyOnWake = () => { + void this.checkForUpdateNudge() + if ( + this.backgroundCheckLaunchPending || + this.currentStatus.state === 'checking' || + this.currentStatus.state === 'downloading' + ) { + return + } + const lastCheck = this._getLastUpdateCheckAt?.() ?? null + const msSince = lastCheck === null ? Number.POSITIVE_INFINITY : Date.now() - lastCheck + if (msSince >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } + } + powerMonitor.on('resume', checkDailyOnWake) + app.on('browser-window-focus', checkDailyOnWake) + + const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null + const msSinceLastCheck = + lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt + if (msSinceLastCheck >= AUTO_UPDATE_CHECK_INTERVAL_MS) { + this.runBackgroundUpdateCheck() + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } else { + this.scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS - msSinceLastCheck) + } + } +} diff --git a/src/main/updater/updater-state.ts b/src/main/updater/updater-state.ts new file mode 100644 index 00000000000..a410c5e10b8 --- /dev/null +++ b/src/main/updater/updater-state.ts @@ -0,0 +1,127 @@ +import type { BrowserWindow } from 'electron' +import type { ElectronAutoUpdater } from '../electron-updater-loader' +import type { LinuxPackageInstallDiagnostic } from '../linux-package-install-diagnostic' +import type { LocalBuildFeed } from '../local-builds/local-build-feed-server' +import type { UpdateSource, UpdateStatus } from '../../shared/update-status-types' +import type { ReleaseChannel } from '../../shared/release-channel' +import type { PrimaryEventSuppression, UpdateCheckVariant } from './updater-types' + +export const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +export const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed used to re-arm the retry at a fixed 1h cadence forever (issue #7576); backoff doubles per failure up to this cap, any completed check resets. +export const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 +export const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 +export const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 +export const QUIT_AND_INSTALL_DELAY_MS = 100 +export const PRE_QUIT_CLEANUP_TIMEOUT_MS = 2_500 +export const UPDATE_CHECK_SILENT_SETTLE_DELAY_MS = 1_000 +export const UPDATE_CHECK_STALL_TIMEOUT_MS = 45_000 + +export type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' +export type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } +export type ReleaseFeedPreflightFailure = 'manifest-unavailable' | 'release-not-ready' +export type ReleaseFeedPreflightResult = 'ready' | 'not-available' +export type UpdateInstallMode = + | 'interactive' + | 'supervised-headless-serve' + | 'unsupported-headless-serve' + +// Why: expected preflight outcomes need typed context so UI routing never depends on matching error text. +export class ReleaseFeedPreflightError extends Error { + constructor( + readonly reason: ReleaseFeedPreflightFailure, + readonly releaseChannel: UpdateCheckVariant, + message: string + ) { + super(message) + this.name = 'ReleaseFeedPreflightError' + } +} + +export abstract class UpdaterState { + protected mainWindowRef: BrowserWindow | null = null + protected currentStatus: UpdateStatus = { state: 'idle' } + protected userInitiatedCheck = false + protected onBeforeQuitCleanup: (() => void | Promise) | null = null + protected autoUpdaterInitialized = false + // Why: modifier-clicking "Check for Updates" targets prerelease manifests; the feed still pins a concrete tag so cancelled prereleases without manifests are skipped. + protected includePrereleaseActive = false + protected availableVersion: string | null = null + protected availableReleaseUrl: string | null = null + protected pendingCheckFailureKey: string | null = null + protected pendingCheckFailurePromise: Promise | null = null + protected autoUpdateCheckTimer: ReturnType | null = null + protected nudgeCheckTimer: ReturnType | null = null + protected pendingQuitAndInstallTimer: ReturnType | null = null + protected quitAndInstallInProgress = false + // Why: the pre-install digest re-proof streams the whole package, so a second install request can + // arrive while it runs — after the quit timer was cleared but before the handoff owns the process. + protected linuxPackageRevalidationInFlight = false + protected updateInstallMode: UpdateInstallMode = 'interactive' + protected lastInstallDeferralVersion = { + download: null as string | null, + install: null as string | null + } + // Why: once install has committed, late 'error' events must not clear quittingForUpdate — that would re-enable dock activate mid-installer. + protected updateInstallCommitted = false + // Why: recovery must only run after the native quitAndInstall call; pre-native errors must not clear quittingForUpdate or look like install recovery. + protected quitAndInstallNativeInvoked = false + // Why: a synchronous throw out of quitAndInstall ends diagnostic capture before the catch runs, so stash the redacted text for it. + protected lastInstallAttemptDiagnostic: LinuxPackageInstallDiagnostic | null = null + protected persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null + protected _getLastUpdateCheckAt: (() => number | null) | null = null + protected backgroundCheckLaunchPending = false + // Why: a promoted background check can emit an error event before its promise catch runs; keep the promotion attached to that launch. + protected backgroundCheckPromotedToUserInitiated = false + protected updateCheckStallTimer: ReturnType | null = null + protected updateCheckSilentSettleTimer: ReturnType | null = null + protected updateCheckAttemptSequence = 0 + protected activeUpdateCheckAttemptId: number | null = null + protected activeUpdateCheckLaunchAttemptId: number | null = null + protected activeUpdateCheckEventAttemptId: number | null = null + protected updateAvailableEventPendingAttemptId: number | null = null + protected pendingUserInitiatedCheckAfterInFlight: UpdateCheckVariant | null = null + protected activeUpdateNudgeId: string | null = null + protected awaitingNudgeCheckOutcome = false + protected nudgeCheckInFlight = false + protected lastNudgeCheckAt = 0 + protected publishingWindowLastGoodCheck: { lastGoodTag: string } | null = null + protected pendingPrereleaseFallback: { + primaryTag: string + fallbackTag: string + // Why: primary promise cleanup can run after fallback starts; fallback events need this attempt-scoped state, not the mutable global. + userInitiated: boolean + suppressedPrimaryPromiseFailureKey: string | null + suppressedPrimaryEventFailure: PrimaryEventSuppression | null + suppressedFallbackPromiseFailureKey: string | null + suppressedFallbackEventFailureKey: string | null + fallbackResultHandled: boolean + fallbackCheckingForUpdateSeen: boolean + retryLaunched: boolean + } | null = null + + protected _getPendingUpdateNudgeId: (() => string | null) | null = null + protected _getDismissedUpdateNudgeId: (() => string | null) | null = null + protected _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null + protected _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null + // Why: guards against duplicate download() calls while an accepted request transitions status to 'downloading'. + protected downloadInFlight = false + /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ + protected quittingForUpdate = false + protected autoUpdater: ElectronAutoUpdater | null = null + protected activeUpdateSource: 'release' | UpdateSource = 'release' + protected activeLocalBuildFeed: LocalBuildFeed | null = null + protected localBuildSelectionInProgress = false + // Why: a dev channel/tag jump may target an older build, so it needs allowDowngrade + // like local builds — but off a real release feed, not a loopback server. + protected pinnedBuildSelectionInProgress = false + // Why: a pinned jump to a stable/rc tag keeps the 'release' source but is still a + // deliberate downgrade, so newer-only gates must yield to it too. + protected isPinnedBuildActive = false + protected getReleaseChannelOverride: (() => ReleaseChannel | null) | null = null + + protected consecutiveAutomaticRetrySchedules = 0 + protected readonly installFailureCauseMaxLength = 200 + + constructor() {} +} diff --git a/src/main/updater/updater-status.ts b/src/main/updater/updater-status.ts new file mode 100644 index 00000000000..26b8f605084 --- /dev/null +++ b/src/main/updater/updater-status.ts @@ -0,0 +1,184 @@ +import { loadElectronAutoUpdater, type ElectronAutoUpdater } from '../electron-updater-loader' +import { statusesEqual } from '../updater-fallback' +import type { UpdateCheckOptions, UpdateStatus } from '../../shared/update-status-types' +import type { UpdateCheckVariant } from './updater-types' +import { UpdaterState as BaseUpdaterState } from './updater-state' + +export abstract class UpdaterStatus extends BaseUpdaterState { + protected getAutoUpdater(): ElectronAutoUpdater { + if (!this.autoUpdater) { + this.autoUpdater = loadElectronAutoUpdater() + } + return this.autoUpdater + } + + protected clearAvailableUpdateContext(): void { + this.availableVersion = null + this.availableReleaseUrl = null + } + + protected closeLocalBuildFeed(): void { + const feed = this.activeLocalBuildFeed + this.activeLocalBuildFeed = null + if (feed) { + void feed.close() + } + } + + protected restoreReleaseUpdateSource(): void { + this.closeLocalBuildFeed() + this.activeUpdateSource = 'release' + this.isPinnedBuildActive = false + if (this.autoUpdater) { + this.autoUpdater.allowDowngrade = false + this.autoUpdater.disableDifferentialDownload = false + // Why: a pinned jump forces allowPrerelease on; leaving it set would opt + // every later background check into the RC channel behind the user's back. + this.autoUpdater.allowPrerelease = this.includePrereleaseActive + } + } + + protected sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + this.clearAvailableUpdateContext() + if ( + this.currentStatus.state !== 'error' || + this.currentStatus.message !== message || + this.currentStatus.userInitiated !== userInitiated || + this.currentStatus.source !== 'local' + ) { + this.sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + this.restoreReleaseUpdateSource() + } + + protected clearPrereleaseFallbackContext(): void { + this.pendingPrereleaseFallback = null + } + + protected clearPendingUpdateNudge(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + this._setPendingUpdateNudgeId?.(null) + } + + protected deferPendingUpdateNudgeUntilRetry(): void { + this.activeUpdateNudgeId = null + this.awaitingNudgeCheckOutcome = false + } + + protected clearPublishingWindowLastGoodCheck(): void { + this.publishingWindowLastGoodCheck = null + } + + protected getPublishingWindowLastGoodCheck(): { lastGoodTag: string } | null { + return this.publishingWindowLastGoodCheck + } + + protected getPersistedPendingUpdateNudgeId(): string | null { + return this._getPendingUpdateNudgeId?.() ?? null + } + + protected decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus { + // Why: only actionable/error states carry the nudge marker so the renderer knows a dismiss should ack the campaign; cycle-boundary states never need it. + if (!this.activeUpdateNudgeId) { + return status + } + if ( + status.state === 'idle' || + status.state === 'checking' || + status.state === 'not-available' + ) { + return status + } + return { ...status, activeNudgeId: this.activeUpdateNudgeId } + } + + /** `force` re-delivers a status the renderer must not miss even when it repeats the current one. */ + protected sendStatus(status: UpdateStatus, options?: { force?: boolean }): void { + const pendingUserInitiatedCheckVariant = this.pendingUserInitiatedCheckAfterInFlight + const shouldLaunchPendingUserInitiatedCheck = + pendingUserInitiatedCheckVariant !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + const shouldPreserveNudgeForPublishingWindow = + this.publishingWindowLastGoodCheck !== null && + (status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error') + if (this.awaitingNudgeCheckOutcome) { + if (status.state === 'available') { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: a last-good available update is only a temporary fallback; dismissing it must not consume the newest-release nudge campaign. + this.deferPendingUpdateNudgeUntilRetry() + } else { + this.awaitingNudgeCheckOutcome = false + } + } else if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'error' + ) { + if (shouldPreserveNudgeForPublishingWindow) { + // Why: last-good checks can say "not available" while the campaign's newest release is still publishing. + this.deferPendingUpdateNudgeUntilRetry() + } else { + // Why: on no-update, mark the campaign dismissed so a nudge covering already-up-to-date users doesn't re-fire every 30-min poll. + if (this.activeUpdateNudgeId) { + this._setDismissedUpdateNudgeId?.(this.activeUpdateNudgeId) + } + this.clearPendingUpdateNudge() + } + } + } + + const sourcedStatus: UpdateStatus = + this.activeUpdateSource === 'release' + ? status + : { ...status, source: this.activeUpdateSource } + const decoratedStatus = this.decorateStatusWithActiveNudge(sourcedStatus) + + if (this.isUpdateCheckResultState(status.state)) { + this.finishActiveUpdateCheckAttempt() + } + + if ( + status.state === 'idle' || + status.state === 'not-available' || + status.state === 'available' || + status.state === 'error' + ) { + this.clearPublishingWindowLastGoodCheck() + } + + // Why: reset the in-flight guard once status moves past the window where duplicate download() calls are possible. + if ( + decoratedStatus.state === 'downloading' || + decoratedStatus.state === 'error' || + decoratedStatus.state === 'idle' + ) { + this.downloadInFlight = false + } + if (shouldLaunchPendingUserInitiatedCheck) { + // Why: a forced status must still land before the queued check restarts the cycle. + if (options?.force) { + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + this.launchPendingUserInitiatedCheckAfterInFlight(pendingUserInitiatedCheckVariant) + return + } + if (!options?.force && statusesEqual(this.currentStatus, decoratedStatus)) { + return + } + this.currentStatus = decoratedStatus + this.mainWindowRef?.webContents.send('updater:status', decoratedStatus) + } + + protected abstract finishActiveUpdateCheckAttempt(): void + protected abstract isUpdateCheckResultState(state: UpdateStatus['state']): boolean + protected abstract launchPendingUserInitiatedCheckAfterInFlight(variant: UpdateCheckVariant): void + protected abstract checkForUpdatesFromMenu(options?: UpdateCheckOptions): void +} diff --git a/src/main/updater/updater-types.ts b/src/main/updater/updater-types.ts new file mode 100644 index 00000000000..aeb91bfdbd1 --- /dev/null +++ b/src/main/updater/updater-types.ts @@ -0,0 +1,2 @@ +export type UpdateCheckVariant = 'default' | 'prerelease' | 'perf' +export type PrimaryEventSuppression = { failureKey: string; error: unknown } diff --git a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt index fc1478330d9..dd49c88e6f8 100644 --- a/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt +++ b/src/main/wsl/__fixtures__/wsl-invocation-allowlist.txt @@ -17,7 +17,7 @@ main/agent-hooks/wsl-hook-relay-launch.ts main/browser/wsl-browser-network-relay-launch.ts main/claude-accounts/claude-command-process.ts -main/codex-accounts/runtime-home-service.ts +main/codex-accounts/runtime-home-service-wsl.ts main/codex-accounts/service.ts main/codex/codex-state-db-backfill-recovery.ts main/codex/codex-trust-grant-host.ts diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 42cfe12add2..4b0bfee16f1 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -41,12 +41,14 @@ src/main/ai-vault/session-scanner-service-spawn.ts src/main/ai-vault/session-scanner-service-test-child.ts src/main/app-icon.ts src/main/automations/precheck-runner.ts -src/main/browser/agent-browser-bridge.ts -src/main/browser/browser-cookie-import.ts +src/main/browser/agent-browser-bridge-execution.ts +src/main/browser/agent-browser-bridge-raw-process.ts +src/main/browser/agent-browser-bridge-state.ts +src/main/browser/agent-browser-bridge-types.ts src/main/browser/browser-route-egress-electron-launch.ts src/main/browser/browser-route-persisted-worker-electron-process.ts src/main/claude-accounts/keychain.ts -src/main/codex-accounts/runtime-home-service.ts +src/main/codex-accounts/runtime-home-service-wsl.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts @@ -86,7 +88,8 @@ src/main/ipc/developer-permissions.ts src/main/ipc/filesystem-list-files-git-fallback.ts src/main/ipc/filesystem-list-files.ts src/main/ipc/filesystem-watcher-wsl.ts -src/main/ipc/filesystem.ts +src/main/ipc/filesystem/filesystem-handler-context.ts +src/main/ipc/filesystem/filesystem-search-handlers.ts src/main/ipc/local-network-connection-test.ts src/main/ipc/macos-keyboard-layout-snapshot.ts src/main/ipc/notebook.ts @@ -128,7 +131,8 @@ src/main/pty/posix-pty-process-groups.ts src/main/pty/windows-environment-path.ts src/main/rate-limits/codex-fetcher.ts src/main/rate-limits/gemini-cli-oauth-extractor.ts -src/main/runtime/orca-runtime-files.ts +src/main/runtime/runtime-file-commands-active-runtime-text-searches.ts +src/main/runtime/runtime-file-commands-search-local-runtime-files.ts src/main/runtime/tls-certificate.ts src/main/runtime/windows-default-route-interfaces.ts src/main/runtime/windows-mobile-firewall.ts diff --git a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt index 116fe6b16e3..752a762370a 100644 --- a/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt +++ b/src/shared/child-process/__fixtures__/windows-console-visibility-allowlist.txt @@ -9,7 +9,6 @@ main/agent-hooks/managed-hook-owner-identity.ts main/agent-hooks/managed-hook-runtime.ts main/app-icon.ts main/automations/precheck-runner.ts -main/browser/browser-cookie-import.ts main/claude-accounts/keychain.ts main/computer/macos-computer-use-permission-status.ts main/computer/macos-computer-use-permissions.ts @@ -27,7 +26,6 @@ main/external-editor-launch.ts main/hooks.ts main/ipc/app.ts main/ipc/developer-permissions.ts -main/ipc/filesystem.ts main/ipc/macos-keyboard-layout-snapshot.ts main/ipc/notification-authorization-status.ts main/ipc/worktree-apfs-clone.ts @@ -43,7 +41,6 @@ main/pty/posix-pty-foreground-group.ts main/pty/posix-pty-process-groups.ts main/pty/windows-environment-path.ts main/rate-limits/codex-fetcher.ts -main/runtime/orca-runtime-files.ts main/runtime/tls-certificate.ts main/ssh/ssh-connection.ts main/startup/appimage-cli-redirect.ts