mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
refactor(main): split backend services and startup
(cherry picked from commit a33573328b)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+25
-3519
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')
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user