refactor(main): split backend services and startup

(cherry picked from commit a33573328b)
This commit is contained in:
Neil
2026-09-01 02:07:33 -07:00
parent c475d23d9b
commit 5b4e7edb50
210 changed files with 29470 additions and 26543 deletions
-10
View File
@@ -2,21 +2,11 @@
# 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/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
+5 -2
View File
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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()
}
}
}
@@ -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()
})
}
}
@@ -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<string>()
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<string>): 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<string, unknown>
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 }
}
}
@@ -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
}
}
@@ -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<string>,
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
}
}
@@ -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<AgentKind>(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
@@ -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<LastStatusFile>
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<string, unknown>), 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<string, unknown>).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
}
}
}
@@ -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<string, unknown>).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))
}
}
}
@@ -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
)
}
}
@@ -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'
)
}
}
@@ -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<void> {
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<void> => {
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<void>((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()
}
}
@@ -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
}
}
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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
)
}
@@ -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<string, PersistedAgentHookEventPayload> = {}
const authorityCommitments: Record<string, PersistedAgentHookAuthorityCommitment> = {}
const conflictedCommitments = new Set<string>()
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()
}
}
@@ -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<boolean>,
isLocalPaneLivenessEvidenceCurrent: (paneKey: string) => boolean
): Promise<number> {
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
}
}
@@ -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<string, string> {
if (this.port <= 0 || !this.token) {
return {}
}
const env: Record<string, string> = {
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')
}
}
+217
View File
@@ -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<typeof createServer> | 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<PaneStatusClearListener>()
protected statusDropListeners = new Set<StatusDropListener>()
protected statusChangeListeners = new Set<StatusChangeListener>()
protected providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
// 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<string>()
protected hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
protected hydratedLaunchTokenHashByPaneKey = new Map<string, string>()
protected persistedAuthorityCommitmentsByPaneKey = new Map<string, AgentHookAuthorityEvidence>()
protected revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
protected currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
protected legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
// 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<string, RetiredPaneFence>()
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<typeof setTimeout> | null = null
protected assistantMessageRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
protected promptSentDedupeByPaneKey = new Map<string, AgentPromptSentDedupeEntry>()
protected activeHookTurnCompletedAtByPaneKey = new Map<string, number>()
protected promptSentHashSalt = randomBytes(16).toString('hex')
protected closedAgentStatusTabIds = new Set<string>()
protected closedAgentStatusPaneKeys = new Set<string>()
protected restartedStatusLaunchTokenHashByPaneKey = new Map<string, string>()
protected connectionTimestampWatermarkById = new Map<string, number>()
// 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<string>,
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<string>): 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
}
@@ -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'
})
}
}
@@ -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<string>,
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)
}
}
}
@@ -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')
}
@@ -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
}
}
@@ -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<CodexSubagentPoll>(
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)
}
}
@@ -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)
}
}
}
}
@@ -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<string>()
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 })
}
}
}
@@ -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<string, unknown>).paneKey
if (typeof paneKey === 'string' && paneKey.trim().length > 0) {
return
}
track('agent_hook_unattributed', { reason: 'empty_pane_key' })
}
+113
View File
@@ -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<string, PersistedAgentHookEventPayload>
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
}
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
@@ -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<BrowserScreenshotResult> {
// 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<BrowserScreenshotResult> {
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<BrowserScreenshotResult> {
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<BrowserScreenshotResult> {
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<T>(execute: () => Promise<T>): Promise<T> {
const previousTurn = this.screenshotTurn.catch(() => {})
let releaseTurn!: () => void
this.screenshotTurn = new Promise<void>((resolve) => {
releaseTurn = resolve
})
await previousTurn
try {
return await execute()
} finally {
releaseTurn()
}
}
async evaluate(
expression: string,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserEvalResult> {
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 }
)
}
}
@@ -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<BrowserSnapshotResult> {
// 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<BrowserClickResult> {
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<BrowserClickResult> {
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<BrowserGotoResult> {
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<typeof setTimeout> | null = null
try {
await Promise.race([
wc.loadURL(navigationUrl),
new Promise<never>((_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<BrowserFillResult> {
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 }
)
}
}
@@ -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<void>
protected abstract runAgentBrowserRaw(
sessionName: string,
args: string[],
execOptions?: AgentBrowserExecOptions
): Promise<string>
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<unknown> {
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<boolean> {
const result = await this.execAgentBrowser(sessionName, [
'get',
'attr',
element,
'contenteditable'
])
return isExplicitContentEditableResult(result)
}
protected async fillExplicitContentEditable(
sessionName: string,
element: string,
value: string
): Promise<void> {
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<void> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof execFile> | 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)
}
}
@@ -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<BrowserTypeResult> {
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<BrowserSelectResult> {
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<BrowserScrollResult> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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 }
)
}
}
@@ -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)
}
@@ -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<BrowserHoverResult> {
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<BrowserDragResult> {
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<BrowserUploadResult> {
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<BrowserWaitResult> {
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<BrowserCheckResult> {
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<BrowserFocusResult> {
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<BrowserClearResult> {
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<BrowserSelectAllResult> {
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<BrowserKeypressResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['press', key])) as BrowserKeypressResult
})
}
async pdf(worktreeId?: string, browserPageId?: string): Promise<BrowserPdfResult> {
// 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') }
})
}
}
@@ -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<void> {
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<void> {
const sessionName = `${ORCA_TAB_SESSION_PREFIX}${browserPageId}`
await this.destroySession(sessionName)
this.pendingInterceptRestore.delete(sessionName)
}
async onProcessSwap(
browserPageId: string,
newWebContentsId: number,
previousWebContentsId?: number
): Promise<void> {
// 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<void> {
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<void> => {
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<void> {
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<void> => {
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<void> {
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<void> => {
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
}
}
}
@@ -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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['set', 'device', name])
})
}
async setOffline(state?: string, worktreeId?: string, browserPageId?: string): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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)
})
}
}
@@ -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<string, unknown>) : 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<BrowserClickPoint> {
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<string, unknown>) : null
const evaluated = raw?.result && typeof raw.result === 'object' ? raw.result : null
return readClickPoint((evaluated as Record<string, unknown> | null)?.value, fallback)
} catch {
return fallback
}
}
@@ -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<void> {
if (!isWebContentsLoading(wc)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
let settled = false
let timeout: ReturnType<typeof setTimeout> | 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'
}
@@ -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<BrowserTabSwitchResult> {
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<T>(
worktreeId: string | undefined,
execute: (sessionName: string) => Promise<T>
): Promise<T> {
return this.enqueueTargetedCommand(
worktreeId,
undefined,
async (sessionName) => execute(sessionName),
{ ensureVisible: false }
)
}
protected async enqueueTargetedCommand<T>(
worktreeId: string | undefined,
browserPageId: string | undefined,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: EnqueueTargetedCommandOptions = {}
): Promise<T> {
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<T>((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<unknown>,
resolve: resolve as (value: unknown) => void,
reject
})
this.processQueue(sessionName)
})
}
protected async executeWithVisibleTarget<T>(
sessionName: string,
worktreeId: string | undefined,
target: ResolvedBrowserCommandTarget,
execute: (sessionName: string, target: ResolvedBrowserCommandTarget) => Promise<T>,
options: EnqueueTargetedCommandOptions
): Promise<T> {
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<ResolvedBrowserCommandTarget> {
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<void> {
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)
}
}
@@ -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<void>
protected runAgentBrowserRaw(
sessionName: string,
args: string[],
execOptions?: AgentBrowserExecOptions
): Promise<string> {
return new Promise<string>((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)
}
})
}
}
@@ -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
}
}
}
@@ -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<string[]> {
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<void> {
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()
}
}
@@ -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<BrowserCookieGetResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, [
'cookies',
'get'
])) as BrowserCookieGetResult
})
}
async cookieSet(
cookie: Partial<BrowserCookie>,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserCookieSetResult> {
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<BrowserCookieDeleteResult> {
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<BrowserViewportResult> {
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<BrowserGeolocationResult> {
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<BrowserInterceptEnableResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
// Why: agent-browser uses "network route <url>" 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<BrowserInterceptDisableResult> {
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<BrowserCaptureStartResult> {
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<BrowserCaptureStopResult> {
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<BrowserConsoleResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['console'])) as BrowserConsoleResult
})
}
async networkLog(
_limit?: number,
worktreeId?: string,
browserPageId?: string
): Promise<BrowserNetworkLogResult> {
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<unknown> {
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)
})
}
}
@@ -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<string, number>()
protected activeWebContentsId: number | null = null
protected readonly sessions = new Map<string, SessionState>()
protected readonly commandQueues = new Map<string, QueuedCommand[]>()
protected readonly processingQueues = new Set<string>()
// Why: screenshot prep mutates shared paintability across tabs; serialize globally so concurrent captures don't blank each other.
protected screenshotTurn: Promise<void> = 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<string, string[]>()
// Why: promise-lock so two concurrent ensureSession calls don't both create the session entry.
protected readonly pendingSessionCreation = new Map<string, Promise<void>>()
// 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<string, Promise<void>>()
protected readonly cancelledProcesses = new WeakSet<ChildProcess>()
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
}
}
}
@@ -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<string, number> {
const all = this.browserManager.getWebContentsIdByTabId()
if (!worktreeId) {
return all
}
const filtered = new Map<string, number>()
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<string | undefined>()
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)
}
}
@@ -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<unknown>
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
}
@@ -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<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['clipboard', 'read'])
})
}
async clipboardWrite(
text: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
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<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['storage', 'local', 'set', key, value])
})
}
async storageLocalClear(worktreeId?: string, browserPageId?: string): Promise<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['storage', 'local', 'clear'])
})
}
async storageSessionGet(
key: string,
worktreeId?: string,
browserPageId?: string
): Promise<unknown> {
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<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['storage', 'session', 'set', key, value])
})
}
async storageSessionClear(worktreeId?: string, browserPageId?: string): Promise<unknown> {
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<unknown> {
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<unknown> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return await this.execAgentBrowser(sessionName, ['highlight', selector])
})
}
async back(worktreeId?: string, browserPageId?: string): Promise<BrowserBackResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['back'])) as BrowserBackResult
})
}
async forward(worktreeId?: string, browserPageId?: string): Promise<BrowserBackResult> {
return this.enqueueTargetedCommand(worktreeId, browserPageId, async (sessionName) => {
return (await this.execAgentBrowser(sessionName, ['forward'])) as BrowserBackResult
})
}
async reload(worktreeId?: string, browserPageId?: string): Promise<BrowserReloadResult> {
// 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<void>((resolve) => {
let settled = false
let fallbackTimer: ReturnType<typeof setTimeout> | 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() }
})
}
}
File diff suppressed because it is too large Load Diff
@@ -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<BrowserCookieImportResult> {
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 }
}
@@ -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<BrowserCookieImportResult> {
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)}`)
}
}
}
})
}
@@ -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<ChromiumImportPreparation> {
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<typeof createChromiumCookieSnapshot>
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<typeof DatabaseSync> | null = null
let stagingDb: InstanceType<typeof DatabaseSync> | 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<string>(),
decryptedCookies: [],
scanned: [],
sourceDomainValidity: new Map<string, boolean>(),
insertStmt,
importScope: {
exact: new Set<string>(),
ancestors: new Set<string>(),
descendantRoots: new Set<string>()
},
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 }
}
@@ -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
}
@@ -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<string, unknown>
export type DecryptedCookie = Omit<ImportedCookieFields, 'url'> & {
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<string>
hasUnrepresentableSkip: boolean
}
export type ChromiumImportContext = {
browser: DetectedBrowser
targetPartition: string
options: CookieImportOptions
targetSession: Session
stagingCookiesPath: string
stagingAvailable: boolean
sourceSnapshot: ChromiumCookieSnapshot
sourceDb: InstanceType<typeof DatabaseSync> | null
stagingDb: InstanceType<typeof DatabaseSync> | null
targetColumnInfo: ChromiumCookieColumnInfo[] | null
colList: string | null
placeholders: string | null
sourceColumns: Set<string>
sourceRows: ChromiumSourceRow[]
nativePlan: ChromiumImportPlan
plannedSourceRows: Set<ChromiumSourceRow>
partitionBySourceRow: Map<ChromiumSourceRow, SourcePartitionRead>
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<string>
decryptedCookies: DecryptedCookie[]
scanned: ScannedChromiumCookie[]
sourceDomainValidity: Map<string, boolean>
insertStmt: ReturnType<InstanceType<typeof DatabaseSync>['prepare']> | null
importScope: ImportedDomainScope
closeStagingDb: () => void
discardStagingFile: () => void
disableStaging: (reason: string) => void
undecryptableWarning?: BrowserCookieImportSummary['warning']
warning?: BrowserCookieImportSummary['warning']
writePhase?: ImportWritePhase
writable?: SourceCookieToWrite[]
result?: BrowserCookieImportResult
}
@@ -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
}
}
@@ -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 '<Browser> 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 <random>.<name>; 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
}
@@ -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
}
}
@@ -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<BrowserCookieImportResult> {
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<string, unknown> & {
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.'
}
}
}
@@ -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)
}
@@ -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<BrowserCookieImportResult> {
const targetPartition = target.partition
const importDomainCache = new Map<string, boolean>()
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<string | null> {
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<BrowserCookieImportResult> {
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'
)
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import { execFileSync } from 'node:child_process'
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'
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 = 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
}
}
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 = 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 } }
}
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
}
}
@@ -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<BrowserCookieImportResult> {
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.' }
}
}
@@ -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)
}
+147
View File
@@ -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<Record<'v10' | 'v11', Buffer>>
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, unknown>
): 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<string, unknown>,
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)
})
}
@@ -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)
}
}
@@ -0,0 +1,89 @@
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),
isViewportPresetActive: () => {
const state = this.viewportPresetActiveByTabId.get(browserTabId)
return state?.guestWebContentsId === guest.id && state.active
},
canViewportScroll: (mouse) => this.canViewportScroll(browserTabId, mouse),
onViewportWheelConsumed: (deltaX, deltaY) =>
this.recordViewportScrollDelta(browserTabId, deltaX, deltaY)
})
)
}
}
@@ -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
}
}
@@ -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<void> {
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
}
}
}
@@ -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)
}
})
}
}
+22
View File
@@ -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
}
}
+126
View File
@@ -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<boolean> {
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<BrowserGrabResult> {
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<BrowserGrabScreenshot | null> {
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<BrowserGrabPayload | null> {
try {
const rawPayload = await guest.executeJavaScript(buildGuestOverlayScript('extractHover'))
if (!rawPayload || typeof rawPayload !== 'object') {
return null
}
return clampGrabPayload(rawPayload)
} catch {
return null
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
@@ -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
}
})
}
}
@@ -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<Electron.WebFrameMain, () => void>()
const iframeFrameNameByFrame = new Map<Electron.WebFrameMain, string>()
const iframeFrameByFrameName = new Map<string, Electron.WebFrameMain>()
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
}
}
}
}
@@ -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<boolean> {
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<void> {
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
}
}
+136
View File
@@ -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<string, number> {
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)
})
}
}
@@ -0,0 +1,230 @@
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.viewportPresetActiveByTabId.delete(browserTabId)
this.viewportScrollStateByTabId.delete(browserTabId)
}
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)
this.viewportPresetActiveByTabId.delete(browserTabId)
this.viewportScrollStateByTabId.delete(browserTabId)
if (wcId !== undefined) {
this.pendingNavigationByGuestId.delete(wcId)
}
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
// Why: headless orca serve has no <webview> 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.viewportPresetActiveByTabId.delete(browserPageId)
this.viewportScrollStateByTabId.delete(browserPageId)
}
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.viewportPresetActiveByTabId.clear()
this.viewportScrollStateByTabId.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()
}
}
+289
View File
@@ -0,0 +1,289 @@
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'
import { BrowserManagerViewportScrollState } from './browser-manager-viewport-scroll-state'
export abstract class BrowserManagerState extends BrowserManagerViewportScrollState {
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<void>
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<string, number>()
// Why: reverse map gives O(1) guest→tab lookups on every mouse/load/permission/popup event.
protected readonly tabIdByWebContentsId = new Map<number, string>()
protected readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
// 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<number, PageInitiatedTabBudget>()
// 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<string, string>()
protected readonly sessionProfileIdByPageId = new Map<string, string | null>()
protected readonly userAgentModeByPageId = new Map<string, BrowserSessionUserAgentMode>()
// 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<string, Promise<unknown>>()
// 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<string, boolean>()
// 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<number, PendingMainFrameNavigation>()
protected readonly contextMenuCleanupByTabId = new Map<string, () => void>()
protected readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
protected readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
protected readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
protected readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
protected readonly worktreeIdByTabId = new Map<string, string>()
protected readonly policyAttachedGuestIds = new Set<number>()
protected readonly offscreenGuestIds = new Set<number>()
protected readonly policyCleanupByGuestId = new Map<number, () => void>()
protected readonly clickedLinkFrameNameByGuestId = new Map<number, string>()
protected readonly loadErrorsByGuestId = new Map<number, BrowserLoadError>()
// 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<number, BrowserLoadError>()
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<number, PendingPermissionEvent[]>()
protected readonly pendingPopupEventsByGuestId = new Map<number, PendingPopupEvent[]>()
protected readonly pendingDownloadIdsByGuestId = new Map<number, string[]>()
protected readonly downloadsById = new Map<string, ActiveDownload>()
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<typeof setTimeout> | 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())
}
}
+238
View File
@@ -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<T>(
promise: Promise<T>,
timeoutMs: number,
fallbackValue: T
): Promise<{ value: T; timedOut: boolean }> {
let timeoutId: ReturnType<typeof setTimeout> | 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<unknown>
): 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<unknown>,
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<BrowserPermissionDeniedEvent, 'browserPageId'>
export type PendingPopupEvent = Omit<BrowserPopupEvent, 'browserPageId'>
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
}
@@ -0,0 +1,81 @@
import { webContents } from 'electron'
import type { BrowserViewportScrollState } from '../../shared/browser-workspace-types'
/**
* Renderer routing plus the host-side viewport-preset geometry the wheel path needs to decide
* whether a scroll belongs to the emulated viewport or to the guest page.
*/
export abstract class BrowserManagerViewportScrollState {
protected readonly rendererWebContentsIdByTabId = new Map<string, number>()
// Why: host-side wheel panning follows the requested local viewport on the owning guest;
// replacement guests must not inherit a retired guest's state.
protected readonly viewportPresetActiveByTabId = new Map<
string,
{ guestWebContentsId: number; active: boolean }
>()
protected readonly viewportScrollStateByTabId = new Map<string, BrowserViewportScrollState>()
setViewportScrollState(
browserTabId: string,
rendererWebContentsId: number,
state: BrowserViewportScrollState
): void {
if (this.rendererWebContentsIdByTabId.get(browserTabId) !== rendererWebContentsId) {
return
}
if (
![state.scrollLeft, state.scrollTop, state.maxScrollLeft, state.maxScrollTop].every(
(value) => typeof value === 'number' && Number.isFinite(value) && value >= 0
)
) {
return
}
this.viewportScrollStateByTabId.set(browserTabId, state)
}
recordViewportScrollDelta(browserTabId: string, deltaX: number, deltaY: number): void {
const state = this.viewportScrollStateByTabId.get(browserTabId)
if (!state) {
return
}
this.viewportScrollStateByTabId.set(browserTabId, {
...state,
scrollLeft: Math.min(state.maxScrollLeft, Math.max(0, state.scrollLeft + deltaX)),
scrollTop: Math.min(state.maxScrollTop, Math.max(0, state.scrollTop + deltaY))
})
}
protected canViewportScroll(browserTabId: string, mouse: Electron.MouseWheelInputEvent): boolean {
const state = this.viewportScrollStateByTabId.get(browserTabId)
if (!state) {
return false
}
const deltaX = typeof mouse.deltaX === 'number' ? mouse.deltaX : 0
const deltaY = typeof mouse.deltaY === 'number' ? mouse.deltaY : 0
const canScrollAxis = (delta: number, position: number, maximum: number): boolean => {
if (delta < 0) {
return position > 0
}
if (delta > 0) {
return position < maximum
}
return false
}
return (
canScrollAxis(deltaX, state.scrollLeft, state.maxScrollLeft) ||
canScrollAxis(deltaY, state.scrollTop, state.maxScrollTop)
)
}
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
}
}
@@ -0,0 +1,219 @@
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<boolean> {
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
}
// Offscreen guests have no visible window on this desktop; detaching DevTools would open it
// on the host display with no route back to the remote client.
if (this.offscreenGuestIds.has(webContentsId)) {
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<boolean> {
// Why: chain per-tab so rapid toggles don't interleave CDP commands and the last-requested override wins.
const expectedWebContentsId = this.webContentsIdByTabId.get(browserTabId)
if (expectedWebContentsId !== undefined) {
// Keep host panning available while CDP applies the requested dimensions. The guest id fence
// prevents this intent from leaking to a replacement guest; clearing the preset removes it.
this.viewportPresetActiveByTabId.set(browserTabId, {
guestWebContentsId: expectedWebContentsId,
active: override !== null
})
}
// The renderer resizes the host before CDP completes; discard the old geometry until it
// reports the new pane bounds so a pending preset cannot route wheel input using stale limits.
this.viewportScrollStateByTabId.delete(browserTabId)
const prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve()
const next = prev
.catch(() => {})
.then(() => this.doSetViewportOverrideImpl(browserTabId, override, expectedWebContentsId))
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<boolean> {
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<boolean> {
// 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,
expectedWebContentsId: number | undefined
): Promise<boolean> {
const webContentsId = this.webContentsIdByTabId.get(browserTabId)
if (!webContentsId || webContentsId !== expectedWebContentsId) {
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
})
if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) {
this.viewportPresetActiveByTabId.set(browserTabId, {
guestWebContentsId: webContentsId,
active: true
})
}
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', {})
if (this.webContentsIdByTabId.get(browserTabId) === webContentsId) {
this.viewportPresetActiveByTabId.set(browserTabId, {
guestWebContentsId: webContentsId,
active: false
})
}
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
}
}
if (this.webContentsIdByTabId.get(browserTabId) !== webContentsId) {
return false
}
return true
} catch {
return false
}
}
}
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
import { existsSync, chmodSync, readFileSync, rmSync } from 'node:fs'
import { 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
}
/**
* `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 })
}
}
@@ -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<CodexSharedRuntimeAuthProvenance, { owner: 'managed' }>
): Extract<CodexSharedRuntimeAuthProvenance, { owner: 'system-default' }> | 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<string, unknown>
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<string, unknown>
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<string, unknown>
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 })
}
}
@@ -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<string, unknown>).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)
}
@@ -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
})
}
}
@@ -0,0 +1,297 @@
import { posix as pathPosix } from 'node:path'
import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } 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 {
getWslSelectionKey,
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<string, string>()
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()
}
/** Preserve refreshed auth from retained legacy WSL panes before restart. */
async syncActiveWslSelectionsBeforeRestart(): Promise<void> {
if (process.platform !== 'win32') {
return
}
const settings = this.store.getSettings()
const drains: Promise<void>[] = []
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
}
const distro =
selectedDistroKey === getWslSelectionKey(null)
? account.wslDistro?.trim() || null
: selectedDistroKey.trim() || null
if (distro) {
drains.push(this.startLegacyWslAuthDrain({ runtime: 'wsl', wslDistro: distro }))
}
}
await Promise.all(drains)
}
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)
if (home && /^[A-Za-z]:[\\/]/.test(home)) {
const linuxHome = toLinuxPath(home).trim()
return linuxHome.startsWith('/')
? toWindowsWslUncPath(pathPosix.join(linuxHome, '.codex'), distro)
: null
}
return home ? this.joinWslPath(home, '.codex') : null
}
protected finishWslLaunchPreparation(
target: CodexAccountSelectionTarget,
homePath: string | null
): void {
this.syncWslConfigAndGlobalInstructionsForLaunch(target, homePath)
this.startWslSessionBridgeForLaunch(target, homePath)
}
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)
return {
kind: 'ready',
codexHomePath: this.getPreparedWslRateLimitHomePath(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() }
}
}
@@ -0,0 +1,154 @@
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 homePath = this.getWslCodexHomePathForSelection(wslTarget)
this.startLegacyWslAuthDrain(wslTarget)
this.finishWslLaunchPreparation(wslTarget, homePath)
return homePath
}
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()
}
async prepareForCodexLaunchAsync(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
options?: { unavailableManagedHomePath?: string }
): Promise<string | null> {
if (target?.runtime !== 'wsl') {
return this.prepareForCodexLaunch(target, launchEnv, options)
}
const wslTarget = this.resolveWslDefaultTarget(target)
const homePath = this.getWslCodexHomePathForSelection(wslTarget)
// Why: the retired home may hold the freshest credential, so the first
// direct-home Codex spawn must wait for its bounded guest transaction.
await this.startLegacyWslAuthDrain(wslTarget, { throwOnFailure: true })
this.finishWslLaunchPreparation(wslTarget, homePath)
return homePath
}
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
}
}
@@ -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<string, string>): 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)
}
}
}
@@ -0,0 +1,276 @@
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 } 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 { resolveWslCodexSessionSourceHome } from '../codex/codex-session-source-home'
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/<id>/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[] {
const settings = this.store.getSettings()
const homes: string[] = []
for (const account of settings.codexManagedAccounts) {
if (this.getWslManagedHomePath(account)) {
continue
}
const trustedHome = this.getTrustedSelfContainedManagedHomePath(account)
if (trustedHome) {
homes.push(trustedHome)
}
}
return homes
}
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 <wslHome>/.codex.
const systemCodexHomePath =
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
if (systemCodexHomePath && systemCodexHomePath !== runtimeHomePath) {
// 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
})
}
}
}
@@ -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
}
}
}
@@ -0,0 +1,264 @@
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,
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'
import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain'
import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
/** 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: 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 prepareForCodexLaunchAsync(
target?: CodexAccountSelectionTarget,
launchEnv?: NodeJS.ProcessEnv,
options?: { unavailableManagedHomePath?: string }
): Promise<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(): Promise<void>
protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null
protected abstract finishWslLaunchPreparation(
target: CodexAccountSelectionTarget,
homePath: string | null
): void
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 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 getWslManagedHomeIdentity(
account: CodexManagedAccount | null
): { distro: string; linuxHomePath: string } | null
protected abstract getPreparedWslRateLimitHomePath(
target: CodexAccountSelectionTarget
): string | null
protected abstract getWslCodexHomePathForSelection(
target: CodexAccountSelectionTarget
): string | null
protected abstract getWslLaunchCodexHomePath(
account: CodexManagedAccount,
targetDistro: string | undefined
): string | null
protected abstract startLegacyWslAuthDrain(
target: CodexAccountSelectionTarget,
options?: { throwOnFailure?: boolean }
): Promise<void>
protected abstract resolveLegacyWslAuthDestination(
distro: string,
runtimeAuthContents: string
): Promise<LegacyWslRuntimeAuthDestination | null>
protected abstract joinWslPath(basePath: string, ...segments: string[]): string
protected abstract resolveWslDefaultTarget(
target: CodexAccountSelectionTarget
): CodexAccountSelectionTarget
protected abstract findManagedAccountForRuntimeAuth(
runtimeAuthContents: string,
expectedAccountId?: string,
options?: {
accounts: readonly CodexManagedAccount[]
authReads: ReadonlyMap<string, WslCodexAuthRead>
}
): CodexReadBackMatch
protected abstract runtimeAuthMatchesSystemDefaultIdentity(
runtimeAuthContents: string,
systemDefaultAuthContents: 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<string, string>): 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 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<CodexSharedRuntimeAuthProvenance, { owner: 'managed' }>
): Extract<CodexSharedRuntimeAuthProvenance, { owner: 'system-default' }> | 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
}
@@ -0,0 +1,154 @@
import { existsSync } from 'node:fs'
import {
normalizeCodexRuntimeSelection,
type CodexAccountSelectionTarget
} from './runtime-selection'
import { recoverInterruptedGuardedFileOperation } from './fs-utils'
import type { CodexSharedRuntimeAuthProvenanceStatus } 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.startLegacyWslAuthDrain(this.resolveWslDefaultTarget(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
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 writes fresh host tokens, invalidating the shared mirror baseline.
clearLastWrittenAuthJson(
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
): void {
if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) {
this.lastWrittenAuthJson = null
}
}
// 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)
}
}
}
@@ -0,0 +1,62 @@
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 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' }
@@ -0,0 +1,119 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
codexAuthCouldBelongToManagedAccount,
codexAuthMatchesManagedAccount,
codexAuthMatchesSystemDefaultIdentity
} 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 type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
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 {
return this.getWslManagedHomeIdentity(account) ? (account?.managedHomePath ?? null) : null
}
protected getWslManagedHomeIdentity(
account: CodexManagedAccount | null
): { distro: string; linuxHomePath: string } | null {
if (!account) {
return null
}
const distro = account.wslDistro?.trim()
const linuxHomePath = account.wslLinuxHomePath?.trim()
if (account.managedHomeRuntime === 'wsl' && distro && linuxHomePath?.startsWith('/')) {
return { distro, linuxHomePath }
}
const legacyHome = parseWslUncPath(account.managedHomePath)
return legacyHome ? { distro: legacyHome.distro, linuxHomePath: legacyHome.linuxPath } : null
}
protected findManagedAccountForRuntimeAuth(
runtimeAuthContents: string,
expectedAccountId?: string,
options?: {
accounts: readonly CodexManagedAccount[]
authReads: ReadonlyMap<string, WslCodexAuthRead>
}
): CodexReadBackMatch {
const matches: {
account: CodexManagedAccount
managedAuthPath: string
managedAuthContents: string
}[] = []
let unreadableHomeCouldOwnRuntimeAuth = false
for (const account of options?.accounts ?? this.store.getSettings().codexManagedAccounts) {
if (expectedAccountId && account.id !== expectedAccountId) {
continue
}
const managedAuthPath = join(account.managedHomePath, 'auth.json')
let managedAuthContents: string
const suppliedRead = options?.authReads.get(account.id)
if (suppliedRead?.kind === 'missing') {
continue
}
if (suppliedRead?.kind === 'unreadable') {
// 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 (suppliedRead?.kind === 'present') {
managedAuthContents = suppliedRead.contents
} else {
if (!existsSync(managedAuthPath)) {
continue
}
try {
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
} catch {
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)
}
}
@@ -0,0 +1,167 @@
import { join } from 'node:path'
import { win32 as pathWin32 } from 'node:path'
import { parseWslUncPath, toLinuxPath, toWindowsWslUncPath } from '../../shared/wsl-paths'
import {
getCodexSelectionLaneKey,
getSelectedCodexAccountIdForTarget,
type CodexAccountSelectionTarget
} from './runtime-selection'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { hasRecordedLegacyWslCodexPane } from '../codex/codex-pane-account-registry'
import {
startLegacyWslRuntimeAuthDrain,
type LegacyWslRuntimeAuthDestination
} from './legacy-wsl-runtime-auth-drain'
import { readWslCodexAuths, type WslCodexAuthRead } from './wsl-codex-auth-batch-reader'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core'
export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore {
protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
return this.getWslCodexHomePathForSelection(target)
}
protected getWslCodexHomePathForSelection(target: CodexAccountSelectionTarget): string | null {
const settings = this.store.getSettings()
const account = this.getActiveAccount(
settings.codexManagedAccounts,
getSelectedCodexAccountIdForTarget(settings, target)
)
if (account) {
const targetDistro = this.resolveWslDefaultTarget(target).wslDistro?.trim()
const accountHome = this.getWslLaunchCodexHomePath(account, targetDistro)
if (accountHome) {
return accountHome
}
}
return this.getWslSystemCodexHomePath(target)
}
protected getWslLaunchCodexHomePath(
account: CodexManagedAccount,
targetDistro: string | undefined
): string | null {
const wslHome = this.getWslManagedHomeIdentity(account)
if (!wslHome) {
return null
}
const accountDistro = wslHome.distro
if (targetDistro && accountDistro.toLowerCase() !== targetDistro.toLowerCase()) {
return null
}
if (/^[A-Za-z]:[\\/]/.test(account.managedHomePath)) {
return toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro)
}
return account.managedHomePath || toWindowsWslUncPath(wslHome.linuxHomePath, accountDistro)
}
protected startLegacyWslAuthDrain(
target: CodexAccountSelectionTarget,
options: { throwOnFailure?: boolean } = {}
): Promise<void> {
if (process.platform !== 'win32') {
return Promise.resolve()
}
const distro = target.wslDistro?.trim() || getDefaultWslDistro()
if (!distro) {
return Promise.resolve()
}
const guestHome = getWslHome(distro)
const guestHomeLinuxPath = guestHome ? toLinuxPath(guestHome).trim() : ''
if (!guestHomeLinuxPath.startsWith('/')) {
return Promise.resolve()
}
let legacyPanePresent = true
try {
legacyPanePresent = hasRecordedLegacyWslCodexPane(getCodexSelectionLaneKey(target))
} catch (error) {
// Why: unknown pane liveness must preserve the source, but promotion can
// still keep the direct home from launching stale auth.
console.warn('[codex-wsl-auth-drain] Pane registry unavailable; preserving source:', error)
}
return startLegacyWslRuntimeAuthDrain(
{
distro,
guestHomeLinuxPath,
legacyPanePresent,
resolveDestination: (runtimeAuthContents) =>
this.resolveLegacyWslAuthDestination(distro, runtimeAuthContents)
},
options
)
}
protected async resolveLegacyWslAuthDestination(
distro: string,
runtimeAuthContents: string
): Promise<LegacyWslRuntimeAuthDestination | null> {
const accountHomes = this.store.getSettings().codexManagedAccounts.flatMap((account) => {
const wslHome = this.getWslManagedHomeIdentity(account)
return wslHome?.distro.toLowerCase() === distro.toLowerCase()
? [{ account, linuxPath: wslHome.linuxHomePath }]
: []
})
const accounts = accountHomes.map(({ account }) => account)
const systemHome = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
const parsedSystemHome = systemHome ? parseWslUncPath(systemHome) : null
let reads: WslCodexAuthRead[]
try {
reads = await readWslCodexAuths(distro, [
...accountHomes.map(({ linuxPath }) => linuxPath),
...(parsedSystemHome ? [parsedSystemHome.linuxPath] : [])
])
} catch {
reads = accountHomes.map(() => ({ kind: 'unreadable' }))
if (parsedSystemHome) {
reads.push({ kind: 'unreadable' })
}
}
const authReads = new Map<string, WslCodexAuthRead>(
accountHomes.map(({ account }, index) => [account.id, reads[index] ?? { kind: 'unreadable' }])
)
const match = this.findManagedAccountForRuntimeAuth(runtimeAuthContents, undefined, {
accounts,
authReads
})
if (match.kind === 'ambiguous') {
return null
}
if (match.kind === 'matched') {
const accountHome = accountHomes.find(({ account }) => account.id === match.account.id)
if (!accountHome) {
return null
}
return {
authContents: match.managedAuthContents,
linuxHomePath: accountHome.linuxPath
}
}
if (!systemHome || !parsedSystemHome) {
return null
}
const systemAuth = reads[accountHomes.length] ?? { kind: 'unreadable' }
if (systemAuth.kind !== 'present') {
return null
}
return this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemAuth.contents)
? { authContents: systemAuth.contents, linuxHomePath: parsedSystemHome.linuxPath }
: null
}
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
}
}
File diff suppressed because it is too large Load Diff

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