mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
chore(stack): restack backend on renderer parity fixes
This commit is contained in:
@@ -2,25 +2,13 @@
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type * as NodeFs from 'node:fs'
|
||||
const {
|
||||
appGetPathMock,
|
||||
copyFileSyncMock,
|
||||
execFileSyncMock,
|
||||
runProcessSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock,
|
||||
setPendingCookieImportMock,
|
||||
@@ -13,7 +13,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
runProcessSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
@@ -28,7 +28,11 @@ vi.mock('./browser-session-registry', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
// Why mock the chokepoint: command timeouts and hidden-console handling belong to
|
||||
// runProcessSync, while this suite only needs to control the credential output.
|
||||
vi.mock('../../shared/child-process/run-process', () => ({
|
||||
runProcessSync: runProcessSyncMock
|
||||
}))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
@@ -478,8 +482,8 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
copyFileSyncMock.mockClear()
|
||||
setPendingCookieImportMock.mockClear()
|
||||
clearPendingCookieImportMock.mockClear()
|
||||
execFileSyncMock.mockReset()
|
||||
execFileSyncMock.mockImplementation(() => {
|
||||
runProcessSyncMock.mockReset()
|
||||
runProcessSyncMock.mockImplementation(() => {
|
||||
throw new Error('OS credential commands are unavailable in this test')
|
||||
})
|
||||
sessionFromPartitionMock.mockReset()
|
||||
@@ -514,12 +518,6 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
]).close()
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
execFileSyncMock.mockImplementation((command: string) => {
|
||||
if (command === 'defaults') {
|
||||
return '120.0.6099.71\n'
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
})
|
||||
try {
|
||||
expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true)
|
||||
const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) =>
|
||||
@@ -539,8 +537,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
value: 'source-value'
|
||||
})
|
||||
)
|
||||
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false)
|
||||
expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false)
|
||||
expect(runProcessSyncMock).not.toHaveBeenCalled()
|
||||
expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe(
|
||||
true
|
||||
)
|
||||
@@ -573,11 +570,12 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
}
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
execFileSyncMock.mockImplementation((command: string) => {
|
||||
if (command === 'security') {
|
||||
return `${password}\n`
|
||||
}
|
||||
throw new Error(`Unexpected command: ${command}`)
|
||||
runProcessSyncMock.mockReturnValue({
|
||||
code: 0,
|
||||
signal: null,
|
||||
stdout: `${password}\n`,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
@@ -588,11 +586,11 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(execFileSyncMock).toHaveBeenCalledWith(
|
||||
'security',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(runProcessSyncMock).toHaveBeenCalledWith({
|
||||
program: 'security',
|
||||
args: ['find-generic-password', '-s', 'Chrome Safe Storage', '-a', 'Chrome', '-w'],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
import { pbkdf2Sync } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { runProcessSync } from '../../shared/child-process/run-process'
|
||||
import { windowsPowerShellPath } from '../../shared/child-process/windows-system-binary'
|
||||
import { diag } from './browser-cookie-import-diagnostics'
|
||||
import {
|
||||
CHROMIUM_BROWSERS,
|
||||
browserRootPath,
|
||||
type DetectedBrowser
|
||||
} from './browser-cookie-detection-types'
|
||||
import type { EncryptionKeyResult } from './browser-cookie-sqlite'
|
||||
|
||||
const PBKDF2_ITERATIONS = 1003
|
||||
const PBKDF2_KEY_LENGTH = 16
|
||||
const PBKDF2_SALT = 'saltysalt'
|
||||
|
||||
function runKeychainCommand(program: string, args: readonly string[], timeoutMs: number): string {
|
||||
const result = runProcessSync({ program, args, timeoutMs })
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
throw new Error(`${program} exited with code ${result.code ?? 'unknown'}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
export function getEncryptionKey(
|
||||
keychainService: string,
|
||||
keychainAccount: string,
|
||||
browser?: DetectedBrowser
|
||||
): EncryptionKeyResult | null {
|
||||
if (process.platform === 'darwin') {
|
||||
return getMacEncryptionKey(keychainService, keychainAccount)
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return getLinuxEncryptionKey(keychainService, keychainAccount)
|
||||
}
|
||||
if (process.platform === 'win32' && browser) {
|
||||
return getWindowsEncryptionKey(browser)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getMacEncryptionKey(
|
||||
keychainService: string,
|
||||
keychainAccount: string
|
||||
): EncryptionKeyResult | null {
|
||||
try {
|
||||
const raw = runKeychainCommand(
|
||||
'security',
|
||||
['find-generic-password', '-s', keychainService, '-a', keychainAccount, '-w'],
|
||||
30_000
|
||||
)
|
||||
return {
|
||||
mode: 'aes-128-cbc',
|
||||
keysByVersion: {
|
||||
v10: pbkdf2Sync(raw, PBKDF2_SALT, PBKDF2_ITERATIONS, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinuxEncryptionKey(
|
||||
keychainService: string,
|
||||
keychainAccount: string
|
||||
): EncryptionKeyResult | null {
|
||||
// Chromium uses v11 only with OS key storage; without it, Linux writes v10 with hardcoded
|
||||
// "peanuts". Keep eligibility explicit because CBC cannot authenticate a wrong-key result.
|
||||
const v10Key = pbkdf2Sync('peanuts', PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
|
||||
let keyringPassword = ''
|
||||
try {
|
||||
// Why: GNOME keyring stores the Chrome Safe Storage password via secret-tool.
|
||||
keyringPassword = runKeychainCommand(
|
||||
'secret-tool',
|
||||
['lookup', 'service', keychainService, 'account', keychainAccount],
|
||||
5_000
|
||||
)
|
||||
} catch {
|
||||
// Why: fall back to application-based lookup used by newer Chromium versions.
|
||||
try {
|
||||
const app = keychainAccount.toLowerCase().replaceAll(' ', '')
|
||||
keyringPassword = runKeychainCommand('secret-tool', ['lookup', 'application', app], 5_000)
|
||||
} catch {
|
||||
diag(' Linux keyring unavailable — v11 cookies cannot be decrypted')
|
||||
}
|
||||
}
|
||||
|
||||
if (!keyringPassword) {
|
||||
return {
|
||||
mode: 'aes-128-cbc',
|
||||
keysByVersion: { v10: v10Key },
|
||||
keyringUnavailable: true
|
||||
}
|
||||
}
|
||||
|
||||
const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1')
|
||||
return { mode: 'aes-128-cbc', keysByVersion: { v10: v10Key, v11: v11Key } }
|
||||
}
|
||||
|
||||
export function getWindowsEncryptionKey(browser: DetectedBrowser): EncryptionKeyResult | null {
|
||||
const browserDef = CHROMIUM_BROWSERS.find((b) => b.family === browser.family)
|
||||
if (!browserDef) {
|
||||
return null
|
||||
}
|
||||
const root = browserRootPath(browserDef)
|
||||
if (!root) {
|
||||
return null
|
||||
}
|
||||
|
||||
const localStatePath = join(root, 'Local State')
|
||||
if (!existsSync(localStatePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(localStatePath, 'utf-8')
|
||||
const localState = JSON.parse(raw)
|
||||
const encryptedKeyB64 = localState?.os_crypt?.encrypted_key
|
||||
if (typeof encryptedKeyB64 !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const encryptedKey = Buffer.from(encryptedKeyB64, 'base64')
|
||||
const dpapiPrefix = Buffer.from('DPAPI', 'utf-8')
|
||||
if (!encryptedKey.subarray(0, dpapiPrefix.length).equals(dpapiPrefix)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: PowerShell DPAPI decrypt is the only native-addon-free path to the master key; pass via stdin to avoid injection.
|
||||
const dpapiData = encryptedKey.subarray(dpapiPrefix.length).toString('base64')
|
||||
const script = [
|
||||
'try { Add-Type -AssemblyName System.Security.Cryptography.ProtectedData -ErrorAction Stop }',
|
||||
'catch { try { Add-Type -AssemblyName System.Security -ErrorAction Stop } catch {} };',
|
||||
'$in=[Convert]::FromBase64String([Console]::In.ReadLine());',
|
||||
'$out=[System.Security.Cryptography.ProtectedData]::Unprotect($in,$null,',
|
||||
'[System.Security.Cryptography.DataProtectionScope]::CurrentUser);',
|
||||
'[Convert]::ToBase64String($out)'
|
||||
].join('')
|
||||
|
||||
// Why runProcessSync and an absolute path: a bare `powershell` spawn from a
|
||||
// GUI-subsystem process opens a visible conhost that takes foreground, so
|
||||
// keystrokes typed into an Orca terminal during a cookie import land in the
|
||||
// black box (#14543), and PATH under Electron is not the user's (#11771).
|
||||
const result = runProcessSync({
|
||||
program: windowsPowerShellPath(),
|
||||
args: ['-NoProfile', '-NonInteractive', '-Command', script],
|
||||
timeoutMs: 10_000,
|
||||
input: dpapiData
|
||||
})
|
||||
if (result.code !== 0 || result.timedOut) {
|
||||
diag(' Windows DPAPI key extraction failed: PowerShell exited non-zero')
|
||||
return null
|
||||
}
|
||||
|
||||
return { key: Buffer.from(result.stdout.trim(), 'base64'), mode: 'aes-256-gcm' }
|
||||
} catch (err) {
|
||||
diag(` Windows DPAPI key extraction failed: ${String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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,82 @@
|
||||
import { resolveRendererWebContents } from './browser-guest-renderer-target'
|
||||
import { setupGuestContextMenu } from './browser-guest-context-menu'
|
||||
import { setupGrabShortcutForwarding } from './browser-guest-grab-shortcuts'
|
||||
import { setupGuestMouseWheelZoomForwarding } from './browser-guest-wheel-zoom'
|
||||
import { setupGuestShortcutForwarding } from './browser-guest-shortcut-forwarding'
|
||||
import { BrowserManagerGrab } from './browser-manager-grab'
|
||||
|
||||
export abstract class BrowserManagerBindings extends BrowserManagerGrab {
|
||||
protected setupContextMenu(browserTabId: string, guest: Electron.WebContents): void {
|
||||
this.contextMenuCleanupByTabId.set(
|
||||
browserTabId,
|
||||
setupGuestContextMenu({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: (tabId) => this.resolveRendererForBrowserTab(tabId)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: forward grab's Cmd/Ctrl+C from a focused guest only when no edit field/selection is active, so native copy still works.
|
||||
protected setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void {
|
||||
const previousCleanup = this.grabShortcutCleanupByTabId.get(browserTabId)
|
||||
if (previousCleanup) {
|
||||
previousCleanup()
|
||||
this.grabShortcutCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
this.grabShortcutCleanupByTabId.set(
|
||||
browserTabId,
|
||||
setupGrabShortcutForwarding({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: (tabId) =>
|
||||
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
|
||||
hasActiveGrabOp: (tabId) => this.hasActiveGrabOp(tabId),
|
||||
getKeybindings: () => this.settingsResolver?.().keybindings
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a focused webview guest is a separate process, so its key events never reach the renderer; intercept and forward app shortcuts.
|
||||
protected setupShortcutForwarding(browserTabId: string, guest: Electron.WebContents): void {
|
||||
const previousCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId)
|
||||
if (previousCleanup) {
|
||||
previousCleanup()
|
||||
this.shortcutForwardingCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
this.shortcutForwardingCleanupByTabId.set(
|
||||
browserTabId,
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: (tabId) =>
|
||||
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId),
|
||||
shouldForwardDictationShortcut: () => this.shouldForwardDictationShortcut?.() ?? false,
|
||||
isMobileEmulatorEnabled: () => this.settingsResolver?.().mobileEmulatorEnabled !== false,
|
||||
getKeybindings: () => this.settingsResolver?.().keybindings,
|
||||
resolveWorktreeId: (tabId) => this.worktreeIdByTabId.get(tabId) ?? null,
|
||||
resolveWorkspaceId: (tabId) => this.workspaceIdByPageId.get(tabId) ?? null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
protected setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void {
|
||||
const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
|
||||
if (previousCleanup) {
|
||||
previousCleanup()
|
||||
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
this.mouseWheelZoomCleanupByTabId.set(
|
||||
browserTabId,
|
||||
setupGuestMouseWheelZoomForwarding({
|
||||
browserTabId,
|
||||
guest,
|
||||
resolveRenderer: (tabId) =>
|
||||
resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,222 @@
|
||||
import { webContents } from 'electron'
|
||||
import { browserDownloadDestinationReservations } from './browser-download-destination'
|
||||
import { isWorkspaceDocPageId } from './doc-preview-guest-policy'
|
||||
import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types'
|
||||
import type { BrowserGuestRegistration } from './browser-manager-types'
|
||||
import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy'
|
||||
|
||||
export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy {
|
||||
registerGuest({
|
||||
browserPageId,
|
||||
browserTabId: legacyBrowserTabId,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
userAgentMode,
|
||||
webContentsId,
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
if (!browserTabId || isWorkspaceDocPageId(browserTabId)) {
|
||||
return false
|
||||
}
|
||||
// Why: on guest-surface swap, cancel any grab bound to the old guest's listeners so it doesn't strand on a stale webContents.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
const previousCleanup = this.contextMenuCleanupByTabId.get(browserTabId)
|
||||
if (previousCleanup) {
|
||||
previousCleanup()
|
||||
this.contextMenuCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
const guest = webContents.fromId(webContentsId)
|
||||
if (!guest || guest.isDestroyed()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: don't trust the renderer-sent id blindly — a compromised renderer could pass the main window's id; only accept webview guests.
|
||||
if (guest.getType() !== 'webview') {
|
||||
return false
|
||||
}
|
||||
if (!this.policyAttachedGuestIds.has(webContentsId)) {
|
||||
// Why: only trust guests that passed attach-time policy install, or a renderer could point us at an arbitrary webview.
|
||||
return false
|
||||
}
|
||||
|
||||
const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) {
|
||||
this.retireStaleGuestWebContents(previousWebContentsId)
|
||||
}
|
||||
this.webContentsIdByTabId.set(browserTabId, webContentsId)
|
||||
this.tabIdByWebContentsId.set(webContentsId, browserTabId)
|
||||
if (workspaceId) {
|
||||
this.workspaceIdByPageId.set(browserTabId, workspaceId)
|
||||
}
|
||||
this.sessionProfileIdByPageId.set(browserTabId, sessionProfileId ?? null)
|
||||
if (userAgentMode) {
|
||||
this.userAgentModeByPageId.set(browserTabId, userAgentMode)
|
||||
} else {
|
||||
this.userAgentModeByPageId.delete(browserTabId)
|
||||
}
|
||||
this.rendererWebContentsIdByTabId.set(browserTabId, rendererWebContentsId)
|
||||
if (worktreeId) {
|
||||
this.worktreeIdByTabId.set(browserTabId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId)
|
||||
|
||||
this.setupContextMenu(browserTabId, guest)
|
||||
this.setupGrabShortcut(browserTabId, guest)
|
||||
this.setupShortcutForwarding(browserTabId, guest)
|
||||
this.setupMouseWheelZoomForwarding(browserTabId, guest)
|
||||
this.flushPendingLoadFailure(browserTabId, webContentsId)
|
||||
this.flushPendingPermissionEvents(browserTabId, webContentsId)
|
||||
this.flushPendingPopupEvents(browserTabId, webContentsId)
|
||||
this.flushPendingDownloadRequests(browserTabId, webContentsId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterGuest(browserTabId: string): void {
|
||||
// Why the check on the exit door too: a document page withdraws by revoking its grant, never
|
||||
// through here, so its id arriving is misaddressed — and the cancel below would evict that
|
||||
// preview's live grab on the strength of it.
|
||||
if (isWorkspaceDocPageId(browserTabId)) {
|
||||
return
|
||||
}
|
||||
// Why: teardown mid-grab must cancel it so the renderer gets a signal, not a dangling Promise.
|
||||
this.cancelGrabOp(browserTabId, 'evicted')
|
||||
|
||||
// Why: remove attachGuestPolicies listeners so their guest-WebContents closures don't block GC.
|
||||
const guestWebContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (guestWebContentsId !== undefined) {
|
||||
this.cleanupGuestPolicyAttachment(guestWebContentsId)
|
||||
}
|
||||
|
||||
const cleanup = this.contextMenuCleanupByTabId.get(browserTabId)
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
this.contextMenuCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
const shortcutCleanup = this.grabShortcutCleanupByTabId.get(browserTabId)
|
||||
if (shortcutCleanup) {
|
||||
shortcutCleanup()
|
||||
this.grabShortcutCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
const fwdCleanup = this.shortcutForwardingCleanupByTabId.get(browserTabId)
|
||||
if (fwdCleanup) {
|
||||
fwdCleanup()
|
||||
this.shortcutForwardingCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId)
|
||||
if (mouseWheelZoomCleanup) {
|
||||
mouseWheelZoomCleanup()
|
||||
this.mouseWheelZoomCleanupByTabId.delete(browserTabId)
|
||||
}
|
||||
// Why: downloads are per-tab chrome; closing the tab must cancel active writes, not orphan them.
|
||||
for (const [downloadId, download] of this.downloadsById.entries()) {
|
||||
if (download.browserTabId === browserTabId && !download.terminalEvent) {
|
||||
this.cancelDownloadInternal(downloadId, 'Tab closed before download completed.')
|
||||
}
|
||||
}
|
||||
const wcId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (wcId !== undefined) {
|
||||
this.tabIdByWebContentsId.delete(wcId)
|
||||
}
|
||||
this.webContentsIdByTabId.delete(browserTabId)
|
||||
this.rendererWebContentsIdByTabId.delete(browserTabId)
|
||||
this.workspaceIdByPageId.delete(browserTabId)
|
||||
this.sessionProfileIdByPageId.delete(browserTabId)
|
||||
this.userAgentModeByPageId.delete(browserTabId)
|
||||
this.worktreeIdByTabId.delete(browserTabId)
|
||||
// Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest.
|
||||
this.viewportOpsByTabId.delete(browserTabId)
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
if (wcId !== undefined) {
|
||||
this.pendingNavigationByGuestId.delete(wcId)
|
||||
}
|
||||
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
// Why: headless orca serve has no <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.webContentsIdByTabId.set(browserPageId, webContentsId)
|
||||
this.tabIdByWebContentsId.set(webContentsId, browserPageId)
|
||||
this.sessionProfileIdByPageId.set(browserPageId, sessionProfileId ?? null)
|
||||
if (userAgentMode) {
|
||||
this.userAgentModeByPageId.set(browserPageId, userAgentMode)
|
||||
} else {
|
||||
this.userAgentModeByPageId.delete(browserPageId)
|
||||
}
|
||||
if (worktreeId) {
|
||||
this.worktreeIdByTabId.set(browserPageId, worktreeId)
|
||||
}
|
||||
this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId)
|
||||
return true
|
||||
}
|
||||
|
||||
unregisterAll(): void {
|
||||
// Cancel all active grab ops before tearing down registrations
|
||||
this.grabSessionController.cancelAll('evicted')
|
||||
for (const downloadId of this.downloadsById.keys()) {
|
||||
this.cancelDownloadInternal(downloadId, 'Orca is shutting down.')
|
||||
}
|
||||
browserDownloadDestinationReservations.clear()
|
||||
for (const browserTabId of this.webContentsIdByTabId.keys()) {
|
||||
this.unregisterGuest(browserTabId)
|
||||
}
|
||||
this.policyAttachedGuestIds.clear()
|
||||
this.offscreenGuestIds.clear()
|
||||
// Why: unregisterGuest skips guests that were policy-attached but never registered; invoke their cleanup closures here.
|
||||
for (const cleanup of this.policyCleanupByGuestId.values()) {
|
||||
cleanup()
|
||||
}
|
||||
this.policyCleanupByGuestId.clear()
|
||||
this.clickedLinkFrameNameByGuestId.clear()
|
||||
this.tabIdByWebContentsId.clear()
|
||||
this.popupOwnerContextByGuestId.clear()
|
||||
this.pageInitiatedTabBudgetByRootGuestId.clear()
|
||||
this.worktreeIdByTabId.clear()
|
||||
this.sessionProfileIdByPageId.clear()
|
||||
this.userAgentModeByPageId.clear()
|
||||
this.viewportUaOverrideMobileByTabId.clear()
|
||||
this.authUserAgentOverrideStateByGuestId.clear()
|
||||
this.pendingNavigationByGuestId.clear()
|
||||
this.pendingLoadFailuresByGuestId.clear()
|
||||
this.loadErrorsByGuestId.clear()
|
||||
this.clearedLoadErrorsByGuestId.clear()
|
||||
this.pendingPermissionEventsByGuestId.clear()
|
||||
this.pendingPopupEventsByGuestId.clear()
|
||||
this.pendingDownloadIdsByGuestId.clear()
|
||||
this.mouseWheelZoomCleanupByTabId.clear()
|
||||
this.annotationViewportBridgeOpsByTabId.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { webContents } from 'electron'
|
||||
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
|
||||
import { BrowserGrabSessionController } from './browser-grab-session-controller'
|
||||
import type { BrowserCertificateTrustController } from './browser-certificate-trust-controller'
|
||||
import {
|
||||
createPageInitiatedTabBudget,
|
||||
type PageInitiatedTabBudget
|
||||
} from './browser-page-initiated-tab-budget'
|
||||
import type { KeybindingOverrides } from '../../shared/keybindings'
|
||||
import type {
|
||||
BrowserLoadError,
|
||||
BrowserSessionUserAgentMode
|
||||
} from '../../shared/browser-workspace-types'
|
||||
import { resolveBrowserRouteGuestPopupOpener } from './browser-route-guest-popup-ownership'
|
||||
import type {
|
||||
ActiveDownload,
|
||||
AuthUserAgentOverrideState,
|
||||
PendingMainFrameNavigation,
|
||||
PendingPermissionEvent,
|
||||
PendingPopupEvent,
|
||||
BrowserGuestPolicy,
|
||||
BrowserManagerLoadError,
|
||||
PopupOwnerContext
|
||||
} from './browser-manager-types'
|
||||
import type {
|
||||
BrowserDownloadFinishedEvent,
|
||||
BrowserDownloadProgressEvent
|
||||
} from '../../shared/browser-guest-events'
|
||||
import type { BrowserGrabCancelReason } from '../../shared/browser-grab-types'
|
||||
|
||||
export abstract class BrowserManagerState {
|
||||
protected abstract attachGuestPolicies(
|
||||
guest: Electron.WebContents,
|
||||
inheritedOwnerContext?: PopupOwnerContext | null,
|
||||
policy?: BrowserGuestPolicy
|
||||
): void
|
||||
|
||||
protected abstract forwardOrQueuePopupEvent(
|
||||
guestWebContentsId: number,
|
||||
event: PendingPopupEvent
|
||||
): void
|
||||
|
||||
protected abstract cancelPendingDownloadsForGuest(guestWebContentsId: number): void
|
||||
|
||||
protected abstract cleanupGuestPolicyAttachment(guestWebContentsId: number): void
|
||||
protected abstract notifyBrowserGuestStateChanged(webContentsId: number): void
|
||||
protected abstract buildLoadError(
|
||||
code: number,
|
||||
description: string,
|
||||
rawUrl: string
|
||||
): BrowserLoadError
|
||||
protected abstract forwardOrQueueGuestLoadFailure(
|
||||
guestWebContentsId: number,
|
||||
loadError: BrowserManagerLoadError
|
||||
): void
|
||||
protected abstract forwardOrQueuePermissionDenied(
|
||||
guestWebContentsId: number,
|
||||
event: PendingPermissionEvent
|
||||
): void
|
||||
protected abstract flushPendingLoadFailure(browserTabId: string, guestWebContentsId: number): void
|
||||
protected abstract flushPendingPermissionEvents(
|
||||
browserTabId: string,
|
||||
guestWebContentsId: number
|
||||
): void
|
||||
protected abstract flushPendingPopupEvents(browserTabId: string, guestWebContentsId: number): void
|
||||
protected abstract flushPendingDownloadRequests(
|
||||
browserTabId: string,
|
||||
guestWebContentsId: number
|
||||
): void
|
||||
protected abstract setupContextMenu(browserTabId: string, guest: Electron.WebContents): void
|
||||
protected abstract setupGrabShortcut(browserTabId: string, guest: Electron.WebContents): void
|
||||
protected abstract setupShortcutForwarding(
|
||||
browserTabId: string,
|
||||
guest: Electron.WebContents
|
||||
): void
|
||||
protected abstract setupMouseWheelZoomForwarding(
|
||||
browserTabId: string,
|
||||
guest: Electron.WebContents
|
||||
): void
|
||||
protected abstract cancelGrabOp(browserTabId: string, reason: BrowserGrabCancelReason): void
|
||||
protected abstract hasActiveGrabOp(browserTabId: string): boolean
|
||||
protected abstract unregisterGuest(browserTabId: string): void
|
||||
protected abstract cancelDownloadInternal(downloadId: string, reason: string): void
|
||||
protected abstract bindDownloadToTab(downloadId: string, browserTabId: string): void
|
||||
protected abstract flushDownloadSnapshot(downloadId: string): void
|
||||
protected abstract sendDownloadStarted(downloadId: string): void
|
||||
protected abstract sendDownloadProgress(
|
||||
browserTabId: string | null,
|
||||
payload: BrowserDownloadProgressEvent
|
||||
): void
|
||||
protected abstract sendDownloadFinished(
|
||||
browserTabId: string | null,
|
||||
payload: BrowserDownloadFinishedEvent
|
||||
): void
|
||||
protected abstract settleClientHostedDownload(
|
||||
download: ActiveDownload,
|
||||
status: BrowserDownloadFinishedEvent['status'],
|
||||
failure: string | null
|
||||
): Promise<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>()
|
||||
protected readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
// 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())
|
||||
}
|
||||
|
||||
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,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,186 @@
|
||||
import { webContents } from 'electron'
|
||||
import {
|
||||
BROWSER_ANNOTATION_VIEWPORT_BRIDGE_WORLD_ID,
|
||||
buildBrowserAnnotationViewportBridgeScript,
|
||||
type BrowserAnnotationViewportBridgeOptions
|
||||
} from '../../shared/browser-annotation-viewport-bridge'
|
||||
import type { BrowserViewportOverride } from '../../shared/browser-workspace-types'
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
import { BrowserManagerDownloadLifecycle } from './browser-manager-download-lifecycle'
|
||||
|
||||
export abstract class BrowserManagerViewport extends BrowserManagerDownloadLifecycle {
|
||||
// Why: guests are isolated from Orca's preload bridge, so main owns the devtools escape hatch after a tab→guest lookup.
|
||||
async openDevTools(browserTabId: string): Promise<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
|
||||
}
|
||||
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 prev = this.viewportOpsByTabId.get(browserTabId) ?? Promise.resolve()
|
||||
const next = prev
|
||||
.catch(() => {})
|
||||
.then(() => this.doSetViewportOverrideImpl(browserTabId, override))
|
||||
this.viewportOpsByTabId.set(browserTabId, next)
|
||||
try {
|
||||
return await next
|
||||
} finally {
|
||||
// Why: only clear if we're still the tail; a later call may have replaced the entry, and deleting would break serialization.
|
||||
if (this.viewportOpsByTabId.get(browserTabId) === next) {
|
||||
this.viewportOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setAnnotationViewportBridge(
|
||||
browserTabId: string,
|
||||
options: BrowserAnnotationViewportBridgeOptions,
|
||||
resolveGuest: () => Electron.WebContents | null
|
||||
): Promise<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
|
||||
): 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
|
||||
}
|
||||
|
||||
try {
|
||||
if (!guest.debugger.isAttached()) {
|
||||
guest.debugger.attach('1.3')
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: attach throws if DevTools is open on the guest; log context so this failure mode is diagnosable.
|
||||
console.warn('[browser-manager] setViewportOverride: failed to attach debugger', {
|
||||
browserTabId,
|
||||
webContentsId,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const dbg = guest.debugger
|
||||
try {
|
||||
if (override) {
|
||||
await dbg.sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width: override.width,
|
||||
height: override.height,
|
||||
deviceScaleFactor: override.deviceScaleFactor,
|
||||
mobile: override.mobile
|
||||
})
|
||||
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
|
||||
enabled: override.mobile,
|
||||
maxTouchPoints: override.mobile ? 5 : 0
|
||||
})
|
||||
// Why: viewport sizing must not override a profile's explicit native-UA identity.
|
||||
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
|
||||
// Navigation must see the preset intent while the final CDP command is in flight.
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
|
||||
// Why: same sender as the navigation path, so both resolve the tab's host identically.
|
||||
await this.sendViewportUserAgentOverride(guest, override.mobile)
|
||||
}
|
||||
} else {
|
||||
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
|
||||
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
|
||||
enabled: false,
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
|
||||
// A navigation after this point must not re-install the override behind the clear.
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
try {
|
||||
if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) {
|
||||
const url = this.resolveTabNavigationUrl(guest)
|
||||
const restored = await this.applyAuthUserAgentOverrideOverCdp(
|
||||
guest,
|
||||
false,
|
||||
url,
|
||||
isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent()
|
||||
)
|
||||
if (!restored) {
|
||||
throw new Error('Failed to preserve auth user agent')
|
||||
}
|
||||
} else {
|
||||
// Why: passing an empty string restores the session default UA.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
}
|
||||
} catch (error) {
|
||||
if (trackedMobile !== undefined) {
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,183 @@
|
||||
import { existsSync, chmodSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { writeFileAtomically, writeFileAtomicallyIfUnchanged } from './fs-utils'
|
||||
import type {
|
||||
CodexRuntimeLogoutMarker,
|
||||
CodexRuntimeLogoutMarkerStatus,
|
||||
CodexSharedRuntimeAuthProvenance
|
||||
} from './runtime-home-service-types'
|
||||
import { CodexRuntimeHomeLegacyMigration } from './runtime-home-service-legacy-migration'
|
||||
|
||||
export abstract class CodexRuntimeHomeAuthCore extends CodexRuntimeHomeLegacyMigration {
|
||||
protected readSystemDefaultAuth(): string | null {
|
||||
const systemDefaultAuthPath = join(getSystemCodexHomePath(), 'auth.json')
|
||||
return existsSync(systemDefaultAuthPath) ? readFileSync(systemDefaultAuthPath, 'utf-8') : null
|
||||
}
|
||||
|
||||
protected writeRuntimeAuth(
|
||||
contents: string,
|
||||
owner: { owner: 'system-default' } | { owner: 'managed'; accountId: string },
|
||||
options?: { expectedContents: string | null }
|
||||
): boolean {
|
||||
// Why: auth.json holds credentials; restrict to owner-only so other users on a shared machine cannot read it.
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (options && !this.fileContentsMatchExpected(runtimeAuthPath, options.expectedContents)) {
|
||||
return false
|
||||
}
|
||||
const provenance: CodexSharedRuntimeAuthProvenance =
|
||||
owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner
|
||||
const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents)
|
||||
if (runtimeAuthComparison === null) {
|
||||
// Why: an unreadable runtime auth.json may hold a token Codex rotated a
|
||||
// moment ago. Treating "could not read" as "differs" sent execution to the
|
||||
// unconditional write below, consuming that rotation and logging the user
|
||||
// out for good. Refuse; the next sync retries.
|
||||
return false
|
||||
}
|
||||
const runtimeAuthAlreadyMatches = runtimeAuthComparison
|
||||
if (
|
||||
runtimeAuthAlreadyMatches &&
|
||||
this.sharedRuntimeAuthProvenanceMatches(
|
||||
this.resolveSharedRuntimeAuthProvenanceStatus(),
|
||||
provenance
|
||||
)
|
||||
) {
|
||||
this.ensureOwnerOnlyMode(runtimeAuthPath)
|
||||
this.lastWrittenAuthJson = contents
|
||||
this.clearRuntimeLogoutMarker()
|
||||
return true
|
||||
}
|
||||
this.persistSharedRuntimeAuthProvenance({
|
||||
owner: 'pending',
|
||||
next: provenance,
|
||||
runtimeAuthJson: contents
|
||||
})
|
||||
if (runtimeAuthAlreadyMatches) {
|
||||
this.ensureOwnerOnlyMode(runtimeAuthPath)
|
||||
this.lastWrittenAuthJson = contents
|
||||
this.persistSharedRuntimeAuthProvenance(provenance)
|
||||
this.clearRuntimeLogoutMarker()
|
||||
return true
|
||||
}
|
||||
const replaced = options
|
||||
? writeFileAtomicallyIfUnchanged(runtimeAuthPath, options.expectedContents, contents, {
|
||||
mode: 0o600
|
||||
})
|
||||
: (writeFileAtomically(runtimeAuthPath, contents, { mode: 0o600 }), true)
|
||||
if (!replaced) {
|
||||
return false
|
||||
}
|
||||
this.lastWrittenAuthJson = contents
|
||||
this.persistSharedRuntimeAuthProvenance(provenance)
|
||||
this.clearRuntimeLogoutMarker()
|
||||
return true
|
||||
}
|
||||
|
||||
protected writeRuntimeAuthAtPath(authPath: string, contents: string): void {
|
||||
if (this.fileContentsEqual(authPath, contents)) {
|
||||
this.ensureOwnerOnlyMode(authPath)
|
||||
return
|
||||
}
|
||||
mkdirSync(dirname(authPath), { recursive: true })
|
||||
writeFileAtomically(authPath, contents, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* `true`/`false` only when the bytes were actually read; `null` when the file
|
||||
* could not be read at all. The old `catch { return false }` reported "these
|
||||
* differ" for a file nobody could open, and every caller reads that as
|
||||
* permission to write.
|
||||
*/
|
||||
protected compareFileContents(targetPath: string, contents: string): boolean | null {
|
||||
try {
|
||||
return readFileSync(targetPath, 'utf-8') === contents
|
||||
} catch (error) {
|
||||
return isDefinitiveAbsence(error) ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
protected fileContentsEqual(targetPath: string, contents: string): boolean {
|
||||
return this.compareFileContents(targetPath, contents) === true
|
||||
}
|
||||
|
||||
protected fileContentsMatchExpected(
|
||||
targetPath: string,
|
||||
expectedContents: string | null
|
||||
): boolean {
|
||||
if (expectedContents === null) {
|
||||
// Why: `!existsSync` does report `true` for a locked file, but this branch
|
||||
// is not where that matters — the write it guards is
|
||||
// `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the
|
||||
// real file and refuses on its own. Classifying here would be a guard no
|
||||
// test can drive.
|
||||
return !existsSync(targetPath)
|
||||
}
|
||||
return this.fileContentsEqual(targetPath, expectedContents)
|
||||
}
|
||||
|
||||
protected ensureOwnerOnlyMode(targetPath: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(targetPath, 0o600)
|
||||
} catch {
|
||||
/* Best effort: the next atomic write will set the restrictive mode. */
|
||||
}
|
||||
}
|
||||
|
||||
protected getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus {
|
||||
const marker = this.readRuntimeLogoutMarker()
|
||||
if (!marker) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
const systemDefaultAuthJson = this.readSystemDefaultAuth()
|
||||
if (systemDefaultAuthJson === marker.systemDefaultAuthJson) {
|
||||
return { kind: 'applies' }
|
||||
}
|
||||
this.clearRuntimeLogoutMarker()
|
||||
return { kind: 'system-default-changed', systemDefaultAuthJson }
|
||||
}
|
||||
|
||||
protected persistRuntimeLogoutMarker(systemDefaultAuthJson = this.readSystemDefaultAuth()): void {
|
||||
const marker: CodexRuntimeLogoutMarker = {
|
||||
systemDefaultAuthJson,
|
||||
loggedOutAt: Date.now()
|
||||
}
|
||||
writeFileAtomically(this.getRuntimeLogoutMarkerPath(), `${JSON.stringify(marker, null, 2)}\n`, {
|
||||
mode: 0o600
|
||||
})
|
||||
}
|
||||
|
||||
protected readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(this.getRuntimeLogoutMarkerPath(), 'utf-8')) as unknown
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
!('systemDefaultAuthJson' in parsed) ||
|
||||
!('loggedOutAt' in parsed)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const marker = parsed as { systemDefaultAuthJson: unknown; loggedOutAt: unknown }
|
||||
if (
|
||||
(marker.systemDefaultAuthJson !== null && typeof marker.systemDefaultAuthJson !== 'string') ||
|
||||
typeof marker.loggedOutAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return marker as CodexRuntimeLogoutMarker
|
||||
}
|
||||
|
||||
protected clearRuntimeLogoutMarker(): void {
|
||||
rmSync(this.getRuntimeLogoutMarkerPath(), { force: true })
|
||||
}
|
||||
}
|
||||
@@ -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,274 @@
|
||||
import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import {
|
||||
getSystemCodexHomePath,
|
||||
syncCodexGlobalInstructionsIntoManagedHome,
|
||||
syncSystemCodexResourcesIntoManagedHome
|
||||
} from '../codex/codex-home-paths'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import {
|
||||
normalizeCodexRuntimeSelection,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
|
||||
import {
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
getCodexPaneAccount,
|
||||
type CodexPaneHomeRoute
|
||||
} from '../codex/codex-pane-account-registry'
|
||||
import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env'
|
||||
import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership'
|
||||
import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import type { CodexRateLimitHomeResolution } from './runtime-home-service-types'
|
||||
import { CodexRuntimeHomeManagedHome } from './runtime-home-service-managed-home'
|
||||
|
||||
export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHome {
|
||||
getHostCodexHomePathsForSessionDiscovery(): string[] {
|
||||
const homes = [this.getRuntimeHomePath()]
|
||||
if (this.isHostSystemDefaultRealHome() || this.getSelfContainedManagedHostAccount()) {
|
||||
// Why: nested Orca processes can retain an ambient managed CODEX_HOME.
|
||||
// Per-account lanes no longer bridge real-home history into the shared
|
||||
// mirror, so include the real root for both directly-routed host lanes.
|
||||
homes.push(getSystemCodexHomePath())
|
||||
}
|
||||
// Why: account-scoped rollouts live in each account's own home, including WSL.
|
||||
for (const perAccountHome of this.getManagedAccountHomesForSessionDiscovery()) {
|
||||
homes.push(perAccountHome)
|
||||
}
|
||||
return homes.filter((home, index) => homes.indexOf(home) === index)
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-owned CODEX_HOME the current HOST selection runs against, or
|
||||
* null when the selection is not routed to one (system default, or a WSL
|
||||
* account, whose home lives inside the distro).
|
||||
*
|
||||
* Read-only on purpose: session discovery ranks homes with this before any
|
||||
* launch prep, so it must create no directories and sync no auth.
|
||||
*/
|
||||
getSelectedHostAccountCodexHomePath(): string | null {
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
return selfContainedAccount
|
||||
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Same selection, but an unreadable home refuses instead of collapsing to
|
||||
* `null`. Session resume must not read "no managed selection" out of a failed
|
||||
* marker stat: another account's readable alias would then win the legacy
|
||||
* rescan and the pane would resume under that account's credentials while the
|
||||
* UI still shows this one (#STA-4422).
|
||||
*/
|
||||
resolveSelectedHostAccountCodexHomePathForResume(): string | null {
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (!selfContainedAccount) {
|
||||
return null
|
||||
}
|
||||
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
||||
}
|
||||
if (resolved.kind === 'untrusted') {
|
||||
this.clearSelfContainedManagedSelection(selfContainedAccount)
|
||||
return null
|
||||
}
|
||||
return resolved.homePath
|
||||
}
|
||||
|
||||
/** Trust-gates host previews without changing WSL routing or durable account state. */
|
||||
resolveCodexManagedAccountHomeForInactiveFetch(
|
||||
account: CodexManagedAccount
|
||||
): { kind: 'ready'; homePath: string } | { kind: 'skip' } {
|
||||
if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) {
|
||||
return { kind: 'ready', homePath: account.managedHomePath }
|
||||
}
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
return resolved.kind === 'owned'
|
||||
? { kind: 'ready', homePath: resolved.homePath }
|
||||
: { kind: 'skip' }
|
||||
}
|
||||
|
||||
getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute {
|
||||
if (this.getSelfContainedManagedHostAccount()) {
|
||||
return 'account-home'
|
||||
}
|
||||
return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home'
|
||||
}
|
||||
|
||||
getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] {
|
||||
const settings = this.store.getSettings()
|
||||
const homes = new Map<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()
|
||||
}
|
||||
|
||||
syncActiveWslSelectionsBeforeRestart(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
for (const [selectedDistroKey, accountId] of Object.entries(
|
||||
normalizeCodexRuntimeSelection(settings).wsl
|
||||
)) {
|
||||
if (!accountId) {
|
||||
continue
|
||||
}
|
||||
const account = this.getActiveAccount(settings.codexManagedAccounts, accountId)
|
||||
if (!account || account.managedHomeRuntime !== 'wsl') {
|
||||
continue
|
||||
}
|
||||
this.safeReadBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
|
||||
}
|
||||
}
|
||||
|
||||
protected getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
const distro = target.wslDistro?.trim() || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return null
|
||||
}
|
||||
const home = getWslHome(distro)
|
||||
return home ? this.joinWslPath(home, '.codex') : null
|
||||
}
|
||||
|
||||
protected syncWslConfigAndGlobalInstructionsForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
): void {
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
const distro =
|
||||
parseWslUncPath(runtimeHomePath)?.distro || target.wslDistro?.trim() || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return
|
||||
}
|
||||
const systemHomePath = this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (!systemHomePath || systemHomePath === runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
// Why: WSL uses a distro-local CODEX_HOME, so host resource mirroring can't provide the distro user's global instructions.
|
||||
syncCodexGlobalInstructionsIntoManagedHome({
|
||||
systemHomePath,
|
||||
managedHomePath: runtimeHomePath
|
||||
})
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath,
|
||||
systemConfigDir: toLinuxPath(systemHomePath)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: `null` is a real value here — it means "use the system-default lane".
|
||||
// A skipped poll needs its own channel or the fetcher silently retargets the
|
||||
// user's real ~/.codex (#STA-4422).
|
||||
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
return {
|
||||
kind: 'ready',
|
||||
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
}
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
|
||||
if (resolved.kind === 'owned') {
|
||||
// Why: the quota fetch reads the account's own auth.json in place; no
|
||||
// shared-home hot-swap or per-poll resource relink (that is launch prep).
|
||||
return { kind: 'ready', codexHomePath: resolved.homePath }
|
||||
}
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: returning null here would NOT skip — the fetcher maps null to
|
||||
// ~/.codex and would probe the user's real home with a token-refreshing
|
||||
// app-server. Skip the poll outright and keep the selection.
|
||||
return { kind: 'skip' }
|
||||
}
|
||||
this.clearSelfContainedManagedSelection(selfContainedAccount)
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
// Why: null lets the fetcher fall back to the main process's inherited
|
||||
// CODEX_HOME before ~/.codex. Nested Orca launches can inherit the
|
||||
// managed home, restarting the background OAuth conflict (#5370), so
|
||||
// pin this non-interactive lane to the native home explicitly.
|
||||
if (hasRecordedLegacySharedCodexPane()) {
|
||||
this.syncLegacySharedSystemDefaultAuthForRetainedPanes()
|
||||
}
|
||||
return { kind: 'ready', codexHomePath: getSystemCodexHomePath() }
|
||||
}
|
||||
this.syncForCurrentSelection()
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { resolveHostCodexSessionSourceHome } from '../codex/codex-session-source-home'
|
||||
import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge'
|
||||
import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import {
|
||||
normalizeCodexRuntimeSelection,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
|
||||
import { markCodexSessionBackfillMarkerPending } from '../codex/codex-session-backfill-marker'
|
||||
import { getCodexSessionBackfillDate } from '../codex/codex-session-backfill-scan-dates'
|
||||
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
|
||||
import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types'
|
||||
import { CodexRuntimeHomeRouting } from './runtime-home-service-home-routing'
|
||||
|
||||
export abstract class CodexRuntimeHomeLaunch extends CodexRuntimeHomeRouting {
|
||||
protected initializeLastSyncedState(): void {
|
||||
const settings = this.store.getSettings()
|
||||
const activeAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
normalizeCodexRuntimeSelection(settings).host
|
||||
)
|
||||
// Why: WSL-managed homes never touch host ~/.codex; treating one as "last synced" makes cold start mangle host auth Orca never touched.
|
||||
this.lastSyncedAccountId = this.getWslManagedHomePath(activeAccount)
|
||||
? null
|
||||
: normalizeCodexRuntimeSelection(settings).host
|
||||
}
|
||||
|
||||
/**
|
||||
* Materializes the runtime home needed before launching the CLI.
|
||||
*
|
||||
* Historical session bridging is requested in the background so launch setup
|
||||
* returns as soon as the active runtime home is ready.
|
||||
*/
|
||||
prepareForCodexLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
options?: { unavailableManagedHomePath?: string }
|
||||
): string | null {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget)
|
||||
this.syncWslConfigAndGlobalInstructionsForLaunch(wslTarget, syncedRuntimeHomePath)
|
||||
const runtimeHomePath = syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath)
|
||||
return runtimeHomePath
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
const perAccountHome = this.prepareSelfContainedManagedHomeForLaunch(
|
||||
selfContainedAccount,
|
||||
options?.unavailableManagedHomePath
|
||||
)
|
||||
if (perAccountHome) {
|
||||
return perAccountHome
|
||||
}
|
||||
// Why: only an untrusted home clears the selection; fall through to the
|
||||
// system default without injecting a path Orca cannot prove it owns.
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome(launchEnv)) {
|
||||
// Why: the system default runs Codex on the user's own ~/.codex.
|
||||
// Returning null tells the PTY/env layer to inject no managed CODEX_HOME;
|
||||
// the retired mirror is refreshed only for pre-rollout PTYs.
|
||||
this.reconcileLegacySharedHomeForRetainedPanes()
|
||||
return null
|
||||
}
|
||||
this.invalidateBackfillAfterManagedSystemDefaultLaunch(launchEnv)
|
||||
this.syncForCurrentSelection(target, launchEnv)
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
// Why: sessions can be large; bridge them after launch so starting a fresh TUI never waits on a full tree walk.
|
||||
void startSystemCodexSessionBridgeInBackground(
|
||||
{},
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
beginHostSystemDefaultSessionMigrationLaunch(
|
||||
codexHomePath: string | null,
|
||||
options: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv } = {}
|
||||
): boolean | null {
|
||||
if (
|
||||
!this.isHostSystemDefaultSessionMigrationEligible() ||
|
||||
(!codexHomePath && !options.reattached) ||
|
||||
(codexHomePath &&
|
||||
normalizeRuntimePathForComparison(codexHomePath) !==
|
||||
normalizeRuntimePathForComparison(this.getRuntimeHomePath()))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
// Why: an older pass can clear launch preparation while PTY spawn awaits recovery.
|
||||
return this.invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
options.reattached && !codexHomePath ? undefined : options.launchEnv
|
||||
)
|
||||
}
|
||||
|
||||
isHostSystemDefaultSessionMigrationEligible(): boolean {
|
||||
return (
|
||||
normalizeCodexRuntimeSelection(this.store.getSettings()).host === null &&
|
||||
!hasCustomCodexHomeOverrideForLaunch()
|
||||
)
|
||||
}
|
||||
|
||||
prepareHostSystemDefaultSessionMigrationPass(
|
||||
scanDates: readonly CodexSessionBackfillDate[] = []
|
||||
): boolean {
|
||||
const paths = resolveCodexSessionBackfillPaths(
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
const target = normalizeRuntimePathForComparison(paths.systemSessionsRoot)
|
||||
if (
|
||||
this.hostSystemDefaultSessionMigrationPending &&
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget !== target
|
||||
) {
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = true
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = target
|
||||
}
|
||||
// Why: the launch creates rollouts for these dates; record them durably so a
|
||||
// force-quit recovers a bounded window instead of re-walking all history.
|
||||
const markerOwesFullScan = markCodexSessionBackfillMarkerPending(
|
||||
paths.markerPath,
|
||||
paths.systemSessionsRoot,
|
||||
scanDates.length > 0 ? scanDates : [getCodexSessionBackfillDate()]
|
||||
)
|
||||
// Why: the marker is the only place an overflowed pending window survives a
|
||||
// restart, so its demand has to reach this pass rather than die in the file.
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan ||= markerOwesFullScan
|
||||
return this.pendingHostSystemDefaultSessionMigrationNeedsFullScan
|
||||
}
|
||||
|
||||
finishHostSystemDefaultSessionMigrationPass(): void {
|
||||
this.hostSystemDefaultSessionMigrationPending = false
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = null
|
||||
}
|
||||
}
|
||||
@@ -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,270 @@
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
syncSystemCodexResourcesIntoManagedHome,
|
||||
getSystemCodexHomePath,
|
||||
resolveOrcaManagedCodexHomePath
|
||||
} from '../codex/codex-home-paths'
|
||||
import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror'
|
||||
import { startCodexAccountSessionBridgeInBackground } from '../codex/codex-account-session-bridge'
|
||||
import {
|
||||
resolveHostCodexSessionSourceHome,
|
||||
resolveWslCodexSessionSourceHome
|
||||
} from '../codex/codex-session-source-home'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
|
||||
import { normalizeCodexRuntimeSelection } from './runtime-selection'
|
||||
import {
|
||||
resolveHostCodexManagedHomeVerdict,
|
||||
ManagedCodexHomeTemporarilyUnavailableError
|
||||
} from './host-codex-managed-home-ownership'
|
||||
import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path'
|
||||
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
|
||||
import { hasCompletedCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker'
|
||||
import { getDefaultWslDistro } from '../wsl'
|
||||
import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge'
|
||||
import type { CodexAccountSelectionTarget } from './runtime-selection'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import { CodexRuntimeHomeSync } from './runtime-home-service-sync'
|
||||
|
||||
export abstract class CodexRuntimeHomeManagedHome extends CodexRuntimeHomeSync {
|
||||
// Why: a managed HOST account runs against its own self-contained CODEX_HOME
|
||||
// (codex-accounts/<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[] {
|
||||
return this.getManagedAccountHomesForSessionDiscovery().filter(
|
||||
(home) => parseWslUncPath(home) === null
|
||||
)
|
||||
}
|
||||
|
||||
protected prepareSelfContainedManagedHomeForLaunch(
|
||||
account: CodexManagedAccount,
|
||||
unavailableManagedHomePath?: string
|
||||
): string | null {
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: refuse the launch rather than silently falling through to the
|
||||
// system default, which would run a different account behind a UI still
|
||||
// showing this one. The selection stays put; a later read may succeed.
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
||||
}
|
||||
if (resolved.kind === 'untrusted') {
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
return null
|
||||
}
|
||||
const perAccountHome = resolved.homePath
|
||||
if (
|
||||
unavailableManagedHomePath &&
|
||||
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
|
||||
normalizeRuntimePathForComparison(perAccountHome)
|
||||
) {
|
||||
const absence = this.credentialAbsenceGrace.assess(join(perAccountHome, 'auth.json'))
|
||||
if (absence.state !== 'present' && absence.durable) {
|
||||
this.clearSelfContainedManagedSelection(account, 'credential remained unavailable')
|
||||
return null
|
||||
}
|
||||
// Why: a transient missing/unreadable auth.json is usually codex rotating
|
||||
// it; keep the selection and launch — the CLI re-reads the settled file.
|
||||
}
|
||||
// Why: link the user's real ~/.codex resources and mirror config into THIS
|
||||
// home (never symlinking into or mutating ~/.codex), so the per-account home
|
||||
// is a complete CODEX_HOME. Hooks/trust are installed by the launch caller.
|
||||
this.lastSyncedAccountId = account.id
|
||||
this.lastHostAccountUsedSelfContainedHome = true
|
||||
this.sharedAuthRefreshBlockedByManagedTransition = true
|
||||
this.markSharedRuntimeAuthManaged(account.id)
|
||||
syncSystemCodexResourcesIntoManagedHome(perAccountHome)
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: perAccountHome,
|
||||
systemHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
this.startSelfContainedSessionBridgeForLaunch(perAccountHome)
|
||||
return perAccountHome
|
||||
}
|
||||
|
||||
// Why: Codex's own `/resume` picker only lists rollouts under the launch
|
||||
// CODEX_HOME, so a self-contained account home starts out with no history at
|
||||
// all. Hardlink every other Orca-visible home's rollouts in — after launch,
|
||||
// since history trees can be large — so switching accounts no longer hides
|
||||
// the user's conversations.
|
||||
protected startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void {
|
||||
void startCodexAccountSessionBridgeInBackground({
|
||||
targetCodexHomePath: perAccountHome,
|
||||
sourceCodexHomePaths: this.getSelfContainedSessionBridgeSourceHomes()
|
||||
})
|
||||
}
|
||||
|
||||
protected getSelfContainedSessionBridgeSourceHomes(): string[] {
|
||||
return [
|
||||
// Why: history-only override lets custom-CODEX_HOME users bridge from the
|
||||
// home they actually record sessions in; falls back to the real ~/.codex.
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings()) ?? getSystemCodexHomePath(),
|
||||
// Why: path only — a per-account install must not materialize the mirror.
|
||||
resolveOrcaManagedCodexHomePath(),
|
||||
...this.getManagedHostAccountHomesForSessionDiscovery()
|
||||
]
|
||||
}
|
||||
|
||||
// Why: the per-account home is both the launch CODEX_HOME and the credential
|
||||
// store, so codex reads/refreshes auth.json in place — there is no shared-home
|
||||
// hot-swap or token read-back to reconcile. A trusted home remains selected
|
||||
// while Codex atomically replaces auth.json.
|
||||
protected syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: a sync runs on every app start, exactly when antivirus is busiest.
|
||||
// An unreadable home must not deselect the account (#STA-4422).
|
||||
return
|
||||
}
|
||||
const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null
|
||||
if (perAccountHome) {
|
||||
this.lastSyncedAccountId = account.id
|
||||
this.lastHostAccountUsedSelfContainedHome = true
|
||||
this.sharedAuthRefreshBlockedByManagedTransition = true
|
||||
this.markSharedRuntimeAuthManaged(account.id)
|
||||
// Why: selection runs well before the user restarts a pane, so history is
|
||||
// already linked in by the time the newly launched Codex opens /resume.
|
||||
this.startSelfContainedSessionBridgeForLaunch(perAccountHome)
|
||||
return
|
||||
}
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: an unreadable home and an untrustworthy one demand opposite responses.
|
||||
* Only `untrusted` may clear the user's selection; `indeterminate` means we
|
||||
* could not tell, so callers refuse the operation and leave durable state
|
||||
* alone (#STA-4422).
|
||||
*/
|
||||
protected resolveSelfContainedManagedHome(
|
||||
account: CodexManagedAccount
|
||||
): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } {
|
||||
const verdict = resolveHostCodexManagedHomeVerdict({
|
||||
candidatePath: account.managedHomePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
if (verdict.kind === 'owned') {
|
||||
// Preserve the persisted path spelling (notably /var vs /private/var on
|
||||
// macOS) so injected CODEX_HOME stays stable across the rollout.
|
||||
return { kind: 'owned', homePath: account.managedHomePath }
|
||||
}
|
||||
if (verdict.kind === 'untrusted') {
|
||||
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason)
|
||||
return { kind: 'untrusted' }
|
||||
}
|
||||
console.warn(
|
||||
'[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:',
|
||||
verdict.error
|
||||
)
|
||||
return { kind: 'indeterminate' }
|
||||
}
|
||||
|
||||
/** Read-only callers that mutate nothing and simply skip an unusable home. */
|
||||
protected getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
return resolved.kind === 'owned' ? resolved.homePath : null
|
||||
}
|
||||
|
||||
protected clearSelfContainedManagedSelection(
|
||||
account: CodexManagedAccount,
|
||||
reason = 'home is invalid'
|
||||
): void {
|
||||
console.warn(`[codex-runtime-home] Active managed account ${reason}, clearing selection`)
|
||||
const settings = this.store.getSettings()
|
||||
if (normalizeCodexRuntimeSelection(settings).host !== account.id) {
|
||||
return
|
||||
}
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...normalizeCodexRuntimeSelection(settings),
|
||||
host: null
|
||||
}
|
||||
})
|
||||
this.lastSyncedAccountId = null
|
||||
this.lastHostAccountUsedSelfContainedHome = false
|
||||
}
|
||||
|
||||
protected invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): boolean | null {
|
||||
const settings = this.store.getSettings()
|
||||
if (
|
||||
normalizeCodexRuntimeSelection(settings).host !== null ||
|
||||
hasCustomCodexHomeOverrideForLaunch(launchEnv)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (!this.hostSystemDefaultSessionMigrationPending) {
|
||||
const paths = resolveCodexSessionBackfillPaths(
|
||||
resolveHostCodexSessionSourceHome(this.store.getSettings())
|
||||
)
|
||||
this.pendingHostSystemDefaultSessionMigrationNeedsFullScan =
|
||||
!hasCompletedCodexSessionBackfillMarker(paths.markerPath, paths.systemSessionsRoot)
|
||||
this.pendingHostSystemDefaultSessionMigrationTarget = normalizeRuntimePathForComparison(
|
||||
paths.systemSessionsRoot
|
||||
)
|
||||
this.hostSystemDefaultSessionMigrationPending = true
|
||||
}
|
||||
return this.prepareHostSystemDefaultSessionMigrationPass()
|
||||
}
|
||||
|
||||
protected startWslSessionBridgeForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
): void {
|
||||
if (process.platform !== 'win32' || !runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
const runtimeHomeWsl = parseWslUncPath(runtimeHomePath)
|
||||
const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return
|
||||
}
|
||||
// Why: history-only override lets custom-CODEX_HOME users bridge from their real home; falls back to <wslHome>/.codex.
|
||||
const systemCodexHomePath =
|
||||
resolveWslCodexSessionSourceHome(this.store.getSettings(), distro) ??
|
||||
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
// Why: WSL history must be hardlinked inside the distro; host-side links can't bridge Windows and WSL filesystems in a resume-visible way.
|
||||
void startWslCodexSessionBridgeInBackground({
|
||||
distro,
|
||||
systemCodexHomePath,
|
||||
managedCodexHomePath: runtimeHomePath
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,278 @@
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { CodexCredentialAbsenceGrace } from './codex-credential-absence-grace'
|
||||
import type {
|
||||
CodexMirroredHomeStatus,
|
||||
CodexRateLimitHomeResolution,
|
||||
CodexReadBackMatch,
|
||||
CodexReadBackResult,
|
||||
CodexRuntimeLogoutMarker,
|
||||
CodexRuntimeLogoutMarkerStatus,
|
||||
CodexSharedRuntimeAuthPendingProvenance,
|
||||
CodexSelfContainedManagedHomeResolution,
|
||||
CodexSharedRuntimeAuthProvenance,
|
||||
CodexSharedRuntimeAuthProvenanceFile,
|
||||
CodexSharedRuntimeAuthProvenanceStatus,
|
||||
CodexSystemDefaultSnapshot
|
||||
} from './runtime-home-service-types'
|
||||
import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types'
|
||||
import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry'
|
||||
import type { CodexAccountSelectionTarget } from './runtime-selection'
|
||||
|
||||
/** Shared state and method contracts for the focused runtime-home layers. */
|
||||
export abstract class CodexRuntimeHomeState {
|
||||
// Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account.
|
||||
protected lastSyncedAccountId: string | null = null
|
||||
// Last auth.json Orca wrote to the runtime home; a later diff signals an out-of-band change (Codex token refresh, or external login to adopt).
|
||||
protected lastWrittenAuthJson: string | null = null
|
||||
// Why: WSL terminals have per-distro runtime homes; sharing the host baseline can make stale WSL auth look newer than managed storage.
|
||||
protected readonly lastWrittenWslAuthJsonByDistro = new Map<string, string | null>()
|
||||
protected readonly lastSyncedWslAccountIdByDistro = new Map<string, string | null>()
|
||||
protected readonly wslRuntimeHomePathByDistro = new Map<string, string>()
|
||||
protected skipNextReadBackForAccountId: string | null = null
|
||||
// Why: a managed host account refreshes auth in its own home. Remember that provenance so a later deselect never adopts stale shared bytes.
|
||||
protected lastHostAccountUsedSelfContainedHome = false
|
||||
protected sharedAuthRefreshBlockedByManagedTransition = false
|
||||
// Why: transient auth.json read/parse failures must not deselect an account.
|
||||
protected readonly credentialAbsenceGrace = new CodexCredentialAbsenceGrace()
|
||||
protected hostSystemDefaultSessionMigrationPending = false
|
||||
protected pendingHostSystemDefaultSessionMigrationNeedsFullScan = false
|
||||
protected pendingHostSystemDefaultSessionMigrationTarget: string | null = null
|
||||
|
||||
protected constructor(protected readonly store: Store) {}
|
||||
|
||||
protected abstract initializeLastSyncedState(): void
|
||||
abstract prepareForCodexLaunch(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv,
|
||||
options?: { unavailableManagedHomePath?: string }
|
||||
): string | null
|
||||
abstract beginHostSystemDefaultSessionMigrationLaunch(
|
||||
codexHomePath: string | null,
|
||||
options?: { reattached?: boolean; launchEnv?: NodeJS.ProcessEnv }
|
||||
): boolean | null
|
||||
abstract isHostSystemDefaultSessionMigrationEligible(): boolean
|
||||
abstract prepareHostSystemDefaultSessionMigrationPass(
|
||||
scanDates?: readonly CodexSessionBackfillDate[]
|
||||
): boolean
|
||||
abstract finishHostSystemDefaultSessionMigrationPass(): void
|
||||
|
||||
protected abstract getSelfContainedManagedHostAccount(): CodexManagedAccount | null
|
||||
protected abstract getManagedAccountHomesForSessionDiscovery(): string[]
|
||||
protected abstract getManagedHostAccountHomesForSessionDiscovery(): string[]
|
||||
protected abstract prepareSelfContainedManagedHomeForLaunch(
|
||||
account: CodexManagedAccount,
|
||||
unavailableManagedHomePath?: string
|
||||
): string | null
|
||||
protected abstract startSelfContainedSessionBridgeForLaunch(perAccountHome: string): void
|
||||
protected abstract getSelfContainedSessionBridgeSourceHomes(): string[]
|
||||
protected abstract syncSelfContainedManagedSelection(account: CodexManagedAccount): void
|
||||
protected abstract resolveSelfContainedManagedHome(
|
||||
account: CodexManagedAccount
|
||||
): CodexSelfContainedManagedHomeResolution
|
||||
protected abstract getTrustedSelfContainedManagedHomePath(
|
||||
account: CodexManagedAccount
|
||||
): string | null
|
||||
protected abstract clearSelfContainedManagedSelection(
|
||||
account: CodexManagedAccount,
|
||||
reason?: string
|
||||
): void
|
||||
protected abstract invalidateBackfillAfterManagedSystemDefaultLaunch(
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): boolean | null
|
||||
protected abstract startWslSessionBridgeForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
): void
|
||||
|
||||
abstract getHostCodexHomePathsForSessionDiscovery(): string[]
|
||||
abstract getSelectedHostAccountCodexHomePath(): string | null
|
||||
abstract resolveSelectedHostAccountCodexHomePathForResume(): string | null
|
||||
abstract resolveCodexManagedAccountHomeForInactiveFetch(
|
||||
account: CodexManagedAccount
|
||||
): { kind: 'ready'; homePath: string } | { kind: 'skip' }
|
||||
abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute
|
||||
abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[]
|
||||
abstract setRealHomeLaneGate(gate: () => boolean): void
|
||||
abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean
|
||||
abstract isHostSystemDefaultRealHome(launchEnv?: NodeJS.ProcessEnv): boolean
|
||||
abstract reconcileLegacySharedHomeForRetainedPanes(): void
|
||||
abstract syncActiveWslSelectionsBeforeRestart(): void
|
||||
|
||||
protected abstract getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null
|
||||
protected abstract syncWslConfigAndGlobalInstructionsForLaunch(
|
||||
target: CodexAccountSelectionTarget,
|
||||
runtimeHomePath: string | null
|
||||
): void
|
||||
abstract prepareForRateLimitFetch(
|
||||
target?: CodexAccountSelectionTarget
|
||||
): CodexRateLimitHomeResolution
|
||||
abstract syncForCurrentSelection(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): void
|
||||
abstract clearLastWrittenAuthJson(accountId?: string | null): void
|
||||
|
||||
protected abstract readBackRefreshedTokensFromPath(
|
||||
runtimeAuthPath: string,
|
||||
options: {
|
||||
updateLastWrittenAuthJson: boolean
|
||||
lastWrittenAuthJson?: string | null
|
||||
setLastWrittenAuthJson?: (contents: string) => void
|
||||
expectedAccountId?: string
|
||||
}
|
||||
): CodexReadBackResult
|
||||
protected abstract resolveSystemDefaultMirrorClaim(
|
||||
runtimeAuth: string,
|
||||
provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus
|
||||
): { ownershipProven: boolean; mirroredAuthJson: string | null }
|
||||
protected abstract safeSyncForCurrentSelection(): void
|
||||
protected abstract safeRecoverInterruptedRuntimeAuthOperation(): void
|
||||
protected abstract getActiveAccount(
|
||||
accounts: CodexManagedAccount[],
|
||||
activeAccountId: string | null
|
||||
): CodexManagedAccount | null
|
||||
protected abstract getWslManagedHomePath(account: CodexManagedAccount | null): string | null
|
||||
protected abstract getPreparedWslRateLimitHomePath(
|
||||
target: CodexAccountSelectionTarget
|
||||
): string | null
|
||||
protected abstract syncWslRuntimeForCurrentSelection(
|
||||
target: CodexAccountSelectionTarget
|
||||
): string | null
|
||||
protected abstract getWslRuntimeHomePath(distro: string): string | null
|
||||
protected abstract safeReadBackActiveWslAccountBeforeRestart(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void
|
||||
protected abstract readBackActiveWslAccountBeforeRestart(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void
|
||||
protected abstract safeMigrateLegacyWslActiveHomePointer(
|
||||
distro: string,
|
||||
runtimeHomePath: string
|
||||
): void
|
||||
protected abstract migrateLegacyWslActiveHomePointer(
|
||||
distro: string,
|
||||
runtimeHomePath: string
|
||||
): void
|
||||
protected abstract dirnameLinuxPath(value: string): string
|
||||
protected abstract joinWslPath(basePath: string, ...segments: string[]): string
|
||||
protected abstract resolveWslDefaultTarget(
|
||||
target: CodexAccountSelectionTarget
|
||||
): CodexAccountSelectionTarget
|
||||
protected abstract getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null
|
||||
protected abstract seedWslRuntimeHome(
|
||||
runtimeHomePath: string,
|
||||
activeAccount: CodexManagedAccount | null,
|
||||
distro: string
|
||||
): void
|
||||
protected abstract findManagedAccountForRuntimeAuth(
|
||||
runtimeAuthContents: string,
|
||||
expectedAccountId?: string
|
||||
): CodexReadBackMatch
|
||||
protected abstract runtimeAuthMatchesSystemDefaultIdentity(
|
||||
runtimeAuthContents: string,
|
||||
systemDefaultAuthContents: string
|
||||
): boolean
|
||||
protected abstract runtimeAuthIsFresher(
|
||||
runtimeAuthContents: string,
|
||||
managedAuthContents: string
|
||||
): boolean
|
||||
|
||||
protected abstract safeMigrateLegacySharedAuth(): void
|
||||
protected abstract safeMigrateLegacyManagedState(): void
|
||||
protected abstract safeMigrateLegacyActiveHomePointer(): void
|
||||
protected abstract getRuntimeHomePath(): string
|
||||
abstract getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus
|
||||
protected abstract getRuntimeAuthPath(): string
|
||||
protected abstract getSystemDefaultSnapshotPath(): string
|
||||
protected abstract getRuntimeLogoutMarkerPath(): string
|
||||
protected abstract getSharedRuntimeAuthProvenancePath(): string
|
||||
protected abstract getRuntimeMetadataDir(): string
|
||||
protected abstract getLegacyHostActiveHomePath(): string
|
||||
protected abstract getMigrationMarkerPath(): string
|
||||
protected abstract getMigrationDiagnosticsPath(): string
|
||||
protected abstract getManagedAccountsRoot(): string
|
||||
protected abstract repointLegacyActiveHomePointer(
|
||||
activeHomePath: string,
|
||||
runtimeHomePath: string
|
||||
): void
|
||||
protected abstract activeHomeAlreadyPointsToRuntimeHome(
|
||||
activeHomePath: string,
|
||||
runtimeHomePath: string
|
||||
): boolean
|
||||
protected abstract linkTargetsMatch(
|
||||
linkTarget: string,
|
||||
linkPath: string,
|
||||
expectedTargetPath: string
|
||||
): boolean
|
||||
protected abstract legacyActiveHomeLinkIsReplaceable(activeHomePath: string): boolean
|
||||
protected abstract legacyActiveHomePathExists(activeHomePath: string): boolean
|
||||
protected abstract removeLegacyActiveHomeLinkIfOwned(activeHomePath: string): void
|
||||
protected abstract isWindowsReadableLink(targetPath: string): boolean
|
||||
protected abstract migrateLegacyManagedStateIfNeeded(): void
|
||||
protected abstract getLegacyManagedHomes(): string[]
|
||||
protected abstract migrateLegacyHistory(managedHomePath: string): void
|
||||
protected abstract migrateLegacySessions(managedHomePath: string, accountId: string): void
|
||||
protected abstract listFilesRecursively(rootPath: string): string[]
|
||||
protected abstract appendListedFiles(target: string[], source: readonly string[]): void
|
||||
protected abstract getPreservedLegacySessionPath(
|
||||
runtimeFilePath: string,
|
||||
accountId: string
|
||||
): string
|
||||
protected abstract appendMigrationDiagnostic(record: Record<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 writeRuntimeAuthAtPath(authPath: string, contents: string): void
|
||||
protected abstract compareFileContents(targetPath: string, contents: string): boolean | null
|
||||
protected abstract fileContentsEqual(targetPath: string, contents: string): boolean
|
||||
protected abstract fileContentsMatchExpected(
|
||||
targetPath: string,
|
||||
expectedContents: string | null
|
||||
): boolean
|
||||
protected abstract ensureOwnerOnlyMode(targetPath: string): void
|
||||
protected abstract getRuntimeLogoutMarkerStatus(): CodexRuntimeLogoutMarkerStatus
|
||||
protected abstract persistRuntimeLogoutMarker(systemDefaultAuthJson?: string | null): void
|
||||
protected abstract readRuntimeLogoutMarker(): CodexRuntimeLogoutMarker | null
|
||||
protected abstract clearRuntimeLogoutMarker(): void
|
||||
protected abstract persistSharedRuntimeAuthProvenance(
|
||||
provenance: CodexSharedRuntimeAuthProvenanceFile
|
||||
): void
|
||||
protected abstract markSharedRuntimeAuthManaged(accountId: string): void
|
||||
protected abstract getUntouchedSystemDefaultBaseline(
|
||||
status: CodexSharedRuntimeAuthProvenanceStatus,
|
||||
runtimeAuthJson: string | null
|
||||
): { authJson: string | null } | null
|
||||
protected abstract restoreUntouchedSystemDefaultProvenance(
|
||||
provenance: Extract<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,216 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import {
|
||||
normalizeCodexRuntimeSelection,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { writeFileAtomically, recoverInterruptedGuardedFileOperation } from './fs-utils'
|
||||
import type {
|
||||
CodexSharedRuntimeAuthProvenanceStatus,
|
||||
CodexReadBackResult
|
||||
} from './runtime-home-service-types'
|
||||
import { codexAuthIsMonotonicallyFresher } from './runtime-home-service-auth-sync-identity'
|
||||
import { CodexRuntimeHomeWsl } from './runtime-home-service-wsl'
|
||||
|
||||
export abstract class CodexRuntimeHomeSync extends CodexRuntimeHomeWsl {
|
||||
syncForCurrentSelection(
|
||||
target?: CodexAccountSelectionTarget,
|
||||
launchEnv?: NodeJS.ProcessEnv
|
||||
): void {
|
||||
if (target?.runtime === 'wsl') {
|
||||
this.syncWslRuntimeForCurrentSelection(target)
|
||||
return
|
||||
}
|
||||
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
// Why: self-contained managed homes hold their own auth, so the shared
|
||||
// runtime home's snapshot/hot-swap/read-back machinery below must not run.
|
||||
this.syncSelfContainedManagedSelection(selfContainedAccount)
|
||||
return
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
if (this.lastHostAccountUsedSelfContainedHome) {
|
||||
// Why: the account's auth is already canonical in its own home. Reset the
|
||||
// legacy mirror baseline without reading it; a real-home deselect needs no
|
||||
// further sync, and the mirror lane below re-seeds from canonical storage.
|
||||
this.lastHostAccountUsedSelfContainedHome = false
|
||||
this.lastSyncedAccountId = null
|
||||
this.lastWrittenAuthJson = null
|
||||
if (this.isHostSystemDefaultRealHome(launchEnv)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome(launchEnv)) {
|
||||
// Why: retained daemon panes may own shared auth from a managed launch;
|
||||
// compatibility reconciliation runs later with durable provenance.
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.sharedAuthRefreshBlockedByManagedTransition = true
|
||||
this.lastSyncedAccountId = null
|
||||
this.lastWrittenAuthJson = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const runtimeAuthExistedBeforeSync = existsSync(this.getRuntimeAuthPath())
|
||||
if (this.lastSyncedAccountId === null) {
|
||||
this.captureSystemDefaultSnapshot({ force: false })
|
||||
}
|
||||
const activeAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
normalizeCodexRuntimeSelection(settings).host
|
||||
)
|
||||
if (activeAccount) {
|
||||
// Why: only a WSL-managed account can reach here — every host account was
|
||||
// routed to its own self-contained home above. Its auth lives in the
|
||||
// distro-local runtime home, so the host mirror only drops its baseline.
|
||||
this.lastSyncedAccountId = null
|
||||
this.lastWrittenAuthJson = null
|
||||
this.skipNextReadBackForAccountId = null
|
||||
return
|
||||
}
|
||||
if (normalizeCodexRuntimeSelection(settings).host) {
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: null,
|
||||
activeCodexManagedAccountIdsByRuntime: {
|
||||
...normalizeCodexRuntimeSelection(settings),
|
||||
host: null
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot.
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot({ detectExternalLogin: true })
|
||||
this.lastSyncedAccountId = null
|
||||
} else if (!runtimeAuthExistedBeforeSync) {
|
||||
const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus()
|
||||
if (logoutMarkerStatus.kind === 'applies') {
|
||||
this.lastWrittenAuthJson = null
|
||||
} else if (
|
||||
logoutMarkerStatus.kind === 'system-default-changed' &&
|
||||
logoutMarkerStatus.systemDefaultAuthJson !== null
|
||||
) {
|
||||
this.restoreSystemDefaultSnapshot({ detectExternalLogin: false })
|
||||
} else if (logoutMarkerStatus.kind === 'system-default-changed') {
|
||||
// Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot.
|
||||
this.captureSystemDefaultSnapshot({ force: true })
|
||||
this.persistRuntimeLogoutMarker(null)
|
||||
this.lastWrittenAuthJson = null
|
||||
} else if (this.lastWrittenAuthJson === null) {
|
||||
// Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex.
|
||||
this.restoreSystemDefaultSnapshot({ detectExternalLogin: false })
|
||||
} else {
|
||||
this.persistRuntimeLogoutMarker()
|
||||
}
|
||||
} else {
|
||||
this.clearRuntimeLogoutMarker()
|
||||
this.syncRuntimeAuthWithSystemDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens.
|
||||
clearLastWrittenAuthJson(
|
||||
accountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host
|
||||
): void {
|
||||
if (accountId === normalizeCodexRuntimeSelection(this.store.getSettings()).host) {
|
||||
this.lastWrittenAuthJson = null
|
||||
}
|
||||
this.skipNextReadBackForAccountId = accountId
|
||||
}
|
||||
|
||||
protected readBackRefreshedTokensFromPath(
|
||||
runtimeAuthPath: string,
|
||||
options: {
|
||||
updateLastWrittenAuthJson: boolean
|
||||
lastWrittenAuthJson?: string | null
|
||||
setLastWrittenAuthJson?: (contents: string) => void
|
||||
expectedAccountId?: string
|
||||
}
|
||||
): CodexReadBackResult {
|
||||
try {
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
const lastWrittenAuthJson =
|
||||
options.lastWrittenAuthJson === undefined
|
||||
? this.lastWrittenAuthJson
|
||||
: options.lastWrittenAuthJson
|
||||
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (lastWrittenAuthJson !== null && runtimeContents === lastWrittenAuthJson) {
|
||||
return 'unchanged'
|
||||
}
|
||||
|
||||
const match = this.findManagedAccountForRuntimeAuth(
|
||||
runtimeContents,
|
||||
options.expectedAccountId
|
||||
)
|
||||
if (match.kind !== 'matched') {
|
||||
if (match.kind === 'ambiguous') {
|
||||
console.warn('[codex-runtime-home] Refusing ambiguous Codex auth read-back')
|
||||
}
|
||||
return 'rejected'
|
||||
}
|
||||
// Why: after restart there's no last-written baseline, so identity alone can't prove runtime auth is newer than managed storage.
|
||||
if (
|
||||
lastWrittenAuthJson === null &&
|
||||
!this.runtimeAuthIsFresher(runtimeContents, match.managedAuthContents)
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
|
||||
writeFileAtomically(match.managedAuthPath, runtimeContents, { mode: 0o600 })
|
||||
if (options.updateLastWrittenAuthJson) {
|
||||
if (options.setLastWrittenAuthJson) {
|
||||
options.setLastWrittenAuthJson(runtimeContents)
|
||||
} else {
|
||||
this.lastWrittenAuthJson = runtimeContents
|
||||
}
|
||||
}
|
||||
return 'persisted'
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort; a transient fs error must not block the forward sync — worst case is one more stale-token cycle.
|
||||
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
|
||||
return 'rejected'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: which ~/.codex bytes the mirror was seeded from, and whether the system
|
||||
// default can be proven to own the mirror at all.
|
||||
protected resolveSystemDefaultMirrorClaim(
|
||||
runtimeAuth: string,
|
||||
provenanceStatus: CodexSharedRuntimeAuthProvenanceStatus
|
||||
): { ownershipProven: boolean; mirroredAuthJson: string | null } {
|
||||
const provenance = provenanceStatus.kind === 'committed' ? provenanceStatus.provenance : null
|
||||
const snapshotAuth =
|
||||
this.readSystemDefaultSnapshot(this.getSystemDefaultSnapshotPath())?.authJson ?? null
|
||||
const preProvenanceRuntimeRefreshProven =
|
||||
provenanceStatus.kind === 'missing' &&
|
||||
snapshotAuth !== null &&
|
||||
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, snapshotAuth) &&
|
||||
codexAuthIsMonotonicallyFresher(runtimeAuth, snapshotAuth)
|
||||
return {
|
||||
ownershipProven: provenance?.owner === 'system-default' || preProvenanceRuntimeRefreshProven,
|
||||
mirroredAuthJson:
|
||||
provenance?.owner === 'system-default'
|
||||
? provenance.authJson
|
||||
: provenanceStatus.kind === 'missing'
|
||||
? (this.lastWrittenAuthJson ?? snapshotAuth)
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
protected safeSyncForCurrentSelection(): void {
|
||||
try {
|
||||
this.syncForCurrentSelection()
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to sync runtime auth state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
protected safeRecoverInterruptedRuntimeAuthOperation(): void {
|
||||
try {
|
||||
recoverInterruptedGuardedFileOperation(this.getRuntimeAuthPath())
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to recover interrupted auth update:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
|
||||
export type CodexSystemDefaultSnapshot = {
|
||||
authJson: string | null
|
||||
}
|
||||
|
||||
export type CodexRuntimeLogoutMarker = {
|
||||
systemDefaultAuthJson: string | null
|
||||
loggedOutAt: number
|
||||
}
|
||||
|
||||
export type CodexSharedRuntimeAuthProvenance =
|
||||
| { owner: 'system-default'; authJson: string | null }
|
||||
| {
|
||||
owner: 'managed'
|
||||
accountId: string
|
||||
systemDefaultBaseline?: { authJson: string | null }
|
||||
}
|
||||
|
||||
export type CodexSharedRuntimeAuthPendingProvenance = {
|
||||
owner: 'pending'
|
||||
next: CodexSharedRuntimeAuthProvenance
|
||||
runtimeAuthJson: string | null
|
||||
}
|
||||
|
||||
export type CodexSharedRuntimeAuthProvenanceFile =
|
||||
| CodexSharedRuntimeAuthProvenance
|
||||
| CodexSharedRuntimeAuthPendingProvenance
|
||||
| { owner: 'fenced' }
|
||||
|
||||
export type CodexSharedRuntimeAuthProvenanceStatus =
|
||||
| { kind: 'missing' | 'fenced' }
|
||||
| { kind: 'committed'; provenance: CodexSharedRuntimeAuthProvenance }
|
||||
|
||||
export type CodexRuntimeLogoutMarkerStatus =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'applies' }
|
||||
| { kind: 'system-default-changed'; systemDefaultAuthJson: string | null }
|
||||
|
||||
export type CodexReadBackResult = 'unchanged' | 'persisted' | 'rejected'
|
||||
|
||||
export type CodexReadBackMatch =
|
||||
| {
|
||||
kind: 'matched'
|
||||
account: CodexManagedAccount
|
||||
managedAuthPath: string
|
||||
managedAuthContents: string
|
||||
}
|
||||
| { kind: 'none' | 'ambiguous' }
|
||||
|
||||
export type CodexSelfContainedManagedHomeResolution =
|
||||
| { kind: 'owned'; homePath: string }
|
||||
| { kind: 'untrusted' }
|
||||
| { kind: 'indeterminate' }
|
||||
|
||||
/** Status used by the config-sync surface; `unavailable` is not a healthy null lane. */
|
||||
export type CodexMirroredHomeStatus =
|
||||
| { kind: 'ready'; homePath: string | null }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
/** Result used by quota polling, where `skip` means no process should be spawned. */
|
||||
export type CodexRateLimitHomeResolution =
|
||||
| { kind: 'ready'; codexHomePath: string | null }
|
||||
| { kind: 'skip' }
|
||||
@@ -0,0 +1,96 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
codexAuthCouldBelongToManagedAccount,
|
||||
codexAuthMatchesManagedAccount,
|
||||
codexAuthMatchesSystemDefaultIdentity,
|
||||
codexAuthIsFresher
|
||||
} from './codex-auth-identity'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import type { CodexReadBackMatch } from './runtime-home-service-types'
|
||||
import { CodexRuntimeHomeAuthProvenance } from './runtime-home-service-auth-provenance'
|
||||
|
||||
export abstract class CodexRuntimeHomeWslCore extends CodexRuntimeHomeAuthProvenance {
|
||||
protected getActiveAccount(
|
||||
accounts: CodexManagedAccount[],
|
||||
activeAccountId: string | null
|
||||
): CodexManagedAccount | null {
|
||||
if (!activeAccountId) {
|
||||
return null
|
||||
}
|
||||
return accounts.find((account) => account.id === activeAccountId) ?? null
|
||||
}
|
||||
|
||||
protected getWslManagedHomePath(account: CodexManagedAccount | null): string | null {
|
||||
if (!account) {
|
||||
return null
|
||||
}
|
||||
if (account.managedHomeRuntime === 'wsl' && parseWslUncPath(account.managedHomePath)) {
|
||||
return account.managedHomePath
|
||||
}
|
||||
return parseWslUncPath(account.managedHomePath) ? account.managedHomePath : null
|
||||
}
|
||||
|
||||
protected findManagedAccountForRuntimeAuth(
|
||||
runtimeAuthContents: string,
|
||||
expectedAccountId?: string
|
||||
): CodexReadBackMatch {
|
||||
const matches: {
|
||||
account: CodexManagedAccount
|
||||
managedAuthPath: string
|
||||
managedAuthContents: string
|
||||
}[] = []
|
||||
let unreadableHomeCouldOwnRuntimeAuth = false
|
||||
for (const account of this.store.getSettings().codexManagedAccounts) {
|
||||
if (expectedAccountId && account.id !== expectedAccountId) {
|
||||
continue
|
||||
}
|
||||
const managedAuthPath = join(account.managedHomePath, 'auth.json')
|
||||
if (!existsSync(managedAuthPath)) {
|
||||
continue
|
||||
}
|
||||
let managedAuthContents: string
|
||||
try {
|
||||
managedAuthContents = readFileSync(managedAuthPath, 'utf-8')
|
||||
} catch {
|
||||
// Why: an unreadable home can never be compared, but letting the read
|
||||
// throw abandons the scan for every other account — dropping a refresh
|
||||
// the runtime home holds for one of them. Only its record can rule it
|
||||
// out as the owner; when it cannot, the scan is no longer unambiguous.
|
||||
if (
|
||||
!expectedAccountId &&
|
||||
codexAuthCouldBelongToManagedAccount(runtimeAuthContents, account)
|
||||
) {
|
||||
unreadableHomeCouldOwnRuntimeAuth = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (codexAuthMatchesManagedAccount(runtimeAuthContents, account, managedAuthContents)) {
|
||||
matches.push({ account, managedAuthPath, managedAuthContents })
|
||||
}
|
||||
}
|
||||
|
||||
if (unreadableHomeCouldOwnRuntimeAuth) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return { kind: 'matched', ...matches[0] }
|
||||
}
|
||||
return { kind: matches.length === 0 ? 'none' : 'ambiguous' }
|
||||
}
|
||||
|
||||
protected runtimeAuthMatchesSystemDefaultIdentity(
|
||||
runtimeAuthContents: string,
|
||||
systemDefaultAuthContents: string
|
||||
): boolean {
|
||||
return codexAuthMatchesSystemDefaultIdentity(runtimeAuthContents, systemDefaultAuthContents)
|
||||
}
|
||||
|
||||
protected runtimeAuthIsFresher(
|
||||
runtimeAuthContents: string,
|
||||
managedAuthContents: string
|
||||
): boolean {
|
||||
return codexAuthIsFresher(runtimeAuthContents, managedAuthContents)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { prepareSystemConfigForFreshRuntimeMirror } from '../codex/codex-config-mirror'
|
||||
|
||||
// Codex resolves relative config paths from its Linux-side home in WSL.
|
||||
export function prepareWslRuntimeSeedConfig(
|
||||
configContents: string,
|
||||
sourceHomePath: string
|
||||
): string {
|
||||
return prepareSystemConfigForFreshRuntimeMirror(
|
||||
configContents,
|
||||
parseWslUncPath(sourceHomePath)?.linuxPath ?? sourceHomePath
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { join, win32 as pathWin32 } from 'node:path'
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
getWslSelectionKey,
|
||||
getSelectedCodexAccountIdForTarget,
|
||||
normalizeCodexRuntimeSelection,
|
||||
setSelectedCodexAccountIdForTarget,
|
||||
type CodexAccountSelectionTarget
|
||||
} from './runtime-selection'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { writeFileAtomically } from './fs-utils'
|
||||
import type { CodexManagedAccount } from '../../shared/managed-account-types'
|
||||
import { CodexRuntimeHomeWslCore } from './runtime-home-service-wsl-core'
|
||||
import { prepareWslRuntimeSeedConfig } from './runtime-home-service-wsl-seed-config'
|
||||
|
||||
export abstract class CodexRuntimeHomeWsl extends CodexRuntimeHomeWslCore {
|
||||
protected getPreparedWslRateLimitHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
const distro = target.wslDistro?.trim()
|
||||
if (distro) {
|
||||
const settings = this.store.getSettings()
|
||||
const selectedAccountId = getSelectedCodexAccountIdForTarget(settings, target)
|
||||
if (selectedAccountId === null) {
|
||||
// Why: the system-default account changes outside Orca, so read its real home directly to avoid a stale cached runtime copy.
|
||||
return this.getWslSystemCodexHomePath(target)
|
||||
}
|
||||
const cachedRuntimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
|
||||
if (
|
||||
cachedRuntimeHomePath &&
|
||||
this.lastSyncedWslAccountIdByDistro.has(distro) &&
|
||||
this.lastSyncedWslAccountIdByDistro.get(distro) === selectedAccountId
|
||||
) {
|
||||
// Why: RateLimitService resolves provenance twice per poll; stay path-only so it doesn't block main on UNC reads and a wsl.exe probe.
|
||||
return cachedRuntimeHomePath
|
||||
}
|
||||
}
|
||||
return this.syncWslRuntimeForCurrentSelection(target)
|
||||
}
|
||||
|
||||
protected syncWslRuntimeForCurrentSelection(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
}
|
||||
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const settings = this.store.getSettings()
|
||||
const activeAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
getSelectedCodexAccountIdForTarget(settings, wslTarget)
|
||||
)
|
||||
const distro = wslTarget.wslDistro?.trim() || activeAccount?.wslDistro || getDefaultWslDistro()
|
||||
if (!distro) {
|
||||
return null
|
||||
}
|
||||
|
||||
const runtimeHomePath = this.getWslRuntimeHomePath(distro)
|
||||
if (!runtimeHomePath) {
|
||||
return null
|
||||
}
|
||||
this.wslRuntimeHomePathByDistro.set(distro, runtimeHomePath)
|
||||
|
||||
mkdirSync(runtimeHomePath, { recursive: true })
|
||||
this.safeMigrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
|
||||
this.seedWslRuntimeHome(runtimeHomePath, activeAccount, distro)
|
||||
|
||||
const runtimeAuthPath = join(runtimeHomePath, 'auth.json')
|
||||
const previousWslAccountId = this.lastSyncedWslAccountIdByDistro.get(distro) ?? null
|
||||
if (previousWslAccountId) {
|
||||
if (this.skipNextReadBackForAccountId === previousWslAccountId) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
} else {
|
||||
const previousWslAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
previousWslAccountId
|
||||
)
|
||||
if (previousWslAccount) {
|
||||
this.readBackRefreshedTokensFromPath(runtimeAuthPath, {
|
||||
updateLastWrittenAuthJson: true,
|
||||
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
|
||||
setLastWrittenAuthJson: (contents) => {
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
|
||||
},
|
||||
expectedAccountId: previousWslAccount.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeAuthPath = activeAccount ? join(activeAccount.managedHomePath, 'auth.json') : null
|
||||
if (activeAccount && activeAuthPath && existsSync(activeAuthPath)) {
|
||||
const activeAuth = readFileSync(activeAuthPath, 'utf-8')
|
||||
this.writeRuntimeAuthAtPath(runtimeAuthPath, activeAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, activeAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, activeAccount.id)
|
||||
return runtimeHomePath
|
||||
}
|
||||
if (activeAccount && activeAuthPath) {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active WSL managed account is missing auth.json, restoring system default'
|
||||
)
|
||||
this.store.updateSettings({
|
||||
activeCodexManagedAccountId: settings.activeCodexManagedAccountId,
|
||||
activeCodexManagedAccountIdsByRuntime: setSelectedCodexAccountIdForTarget(
|
||||
normalizeCodexRuntimeSelection(settings),
|
||||
null,
|
||||
wslTarget
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const systemAuthPath = this.getWslSystemCodexAuthPath({ runtime: 'wsl', wslDistro: distro })
|
||||
if (systemAuthPath && existsSync(systemAuthPath)) {
|
||||
const systemAuth = readFileSync(systemAuthPath, 'utf-8')
|
||||
const mirroredSystemDefaultAuth = this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null
|
||||
const runtimeAuth = existsSync(runtimeAuthPath)
|
||||
? readFileSync(runtimeAuthPath, 'utf-8')
|
||||
: null
|
||||
if (
|
||||
runtimeAuth !== null &&
|
||||
runtimeAuth !== systemAuth &&
|
||||
this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemAuth) &&
|
||||
((mirroredSystemDefaultAuth !== null && systemAuth === mirroredSystemDefaultAuth) ||
|
||||
(mirroredSystemDefaultAuth === null &&
|
||||
this.runtimeAuthIsFresher(runtimeAuth, systemAuth)))
|
||||
) {
|
||||
// Why: WSL baselines are lost on restart, so a same-identity fresher runtime auth is a token refresh; copy it back before mirroring ~/.codex.
|
||||
this.writeRuntimeAuthAtPath(systemAuthPath, runtimeAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, runtimeAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
}
|
||||
this.writeRuntimeAuthAtPath(runtimeAuthPath, systemAuth)
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, systemAuth)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
}
|
||||
|
||||
rmSync(runtimeAuthPath, { force: true })
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, null)
|
||||
this.lastSyncedWslAccountIdByDistro.set(distro, null)
|
||||
return runtimeHomePath
|
||||
}
|
||||
|
||||
protected getWslRuntimeHomePath(distro: string): string | null {
|
||||
const home = getWslHome(distro)
|
||||
return home ? this.joinWslPath(home, ...WSL_CODEX_RUNTIME_HOME_SEGMENTS) : null
|
||||
}
|
||||
|
||||
protected safeReadBackActiveWslAccountBeforeRestart(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void {
|
||||
try {
|
||||
this.readBackActiveWslAccountBeforeRestart(account, selectedDistroKey)
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to preserve WSL Codex auth before restart:', error)
|
||||
}
|
||||
}
|
||||
|
||||
protected readBackActiveWslAccountBeforeRestart(
|
||||
account: CodexManagedAccount,
|
||||
selectedDistroKey: string
|
||||
): void {
|
||||
const distro =
|
||||
selectedDistroKey === getWslSelectionKey(null)
|
||||
? account.wslDistro?.trim()
|
||||
: selectedDistroKey.trim() || account.wslDistro?.trim()
|
||||
if (!distro) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeHomePath = this.wslRuntimeHomePathByDistro.get(distro)
|
||||
if (!runtimeHomePath) {
|
||||
return
|
||||
}
|
||||
|
||||
this.readBackRefreshedTokensFromPath(join(runtimeHomePath, 'auth.json'), {
|
||||
updateLastWrittenAuthJson: true,
|
||||
lastWrittenAuthJson: this.lastWrittenWslAuthJsonByDistro.get(distro) ?? null,
|
||||
setLastWrittenAuthJson: (contents) => {
|
||||
this.lastWrittenWslAuthJsonByDistro.set(distro, contents)
|
||||
},
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
}
|
||||
|
||||
protected safeMigrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
|
||||
try {
|
||||
this.migrateLegacyWslActiveHomePointer(distro, runtimeHomePath)
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Failed to migrate legacy WSL active Codex home:', error)
|
||||
}
|
||||
}
|
||||
|
||||
protected migrateLegacyWslActiveHomePointer(distro: string, runtimeHomePath: string): void {
|
||||
const runtimeWsl = parseWslUncPath(runtimeHomePath)
|
||||
if (!runtimeWsl?.linuxPath.endsWith('/codex-runtime-home/home')) {
|
||||
return
|
||||
}
|
||||
const activeLinuxPath = runtimeWsl.linuxPath.replace(
|
||||
/\/codex-runtime-home\/home$/,
|
||||
'/codex-runtime-home/active/wsl/home'
|
||||
)
|
||||
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
|
||||
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
|
||||
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
[
|
||||
'set -e',
|
||||
`if [ ! -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
`elif [ -e ${quotePosixShell(activeLinuxPath)} ] && [ ! -L ${quotePosixShell(activeLinuxPath)} ]; then :`,
|
||||
'else',
|
||||
`mkdir -p ${quotePosixShell(activeLinuxParentPath)}`,
|
||||
`rm -rf -- ${quotePosixShell(nextLinuxPath)}`,
|
||||
`ln -s -- ${quotePosixShell(runtimeWsl.linuxPath)} ${quotePosixShell(nextLinuxPath)}`,
|
||||
`mv -Tf -- ${quotePosixShell(nextLinuxPath)} ${quotePosixShell(activeLinuxPath)}`,
|
||||
'fi'
|
||||
].join('\n')
|
||||
],
|
||||
// wsl.exe is console-subsystem: without this a GUI-launched Orca flashes
|
||||
// a conhost and steals foreground for up to the timeout (#10488).
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true }
|
||||
)
|
||||
}
|
||||
|
||||
protected dirnameLinuxPath(value: string): string {
|
||||
const index = value.lastIndexOf('/')
|
||||
return index > 0 ? value.slice(0, index) : '/'
|
||||
}
|
||||
|
||||
protected joinWslPath(basePath: string, ...segments: string[]): string {
|
||||
return parseWslUncPath(basePath)
|
||||
? pathWin32.join(basePath, ...segments)
|
||||
: join(basePath, ...segments)
|
||||
}
|
||||
|
||||
protected resolveWslDefaultTarget(
|
||||
target: CodexAccountSelectionTarget
|
||||
): CodexAccountSelectionTarget {
|
||||
if (target.runtime !== 'wsl' || target.wslDistro?.trim()) {
|
||||
return target
|
||||
}
|
||||
const defaultDistro = getDefaultWslDistro()
|
||||
return defaultDistro ? { runtime: 'wsl', wslDistro: defaultDistro } : target
|
||||
}
|
||||
|
||||
protected getWslSystemCodexAuthPath(target: CodexAccountSelectionTarget): string | null {
|
||||
const home = this.getWslSystemCodexHomePath(target)
|
||||
return home ? this.joinWslPath(home, 'auth.json') : null
|
||||
}
|
||||
|
||||
protected seedWslRuntimeHome(
|
||||
runtimeHomePath: string,
|
||||
activeAccount: CodexManagedAccount | null,
|
||||
distro: string
|
||||
): void {
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
if (existsSync(runtimeConfigPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidateHomes = [
|
||||
activeAccount?.managedHomePath,
|
||||
this.getWslSystemCodexHomePath({ runtime: 'wsl', wslDistro: distro })
|
||||
].filter((value): value is string => Boolean(value))
|
||||
for (const homePath of candidateHomes) {
|
||||
const configPath = join(homePath, 'config.toml')
|
||||
if (existsSync(configPath)) {
|
||||
writeFileAtomically(
|
||||
runtimeConfigPath,
|
||||
prepareWslRuntimeSeedConfig(readFileSync(configPath, 'utf-8'), homePath)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user