Merge remote-tracking branch 'origin/main' into brennanb2025/workspace-cleanup-dialog

# Conflicts:
#	src/main/ipc/workspace-cleanup-disconnected-ssh.ts
#	src/main/ipc/workspace-cleanup-scan.ts
#	src/preload/api-types.ts
This commit is contained in:
Brennan Benson
2026-08-13 22:22:14 -07:00
947 changed files with 36012 additions and 14587 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -179,7 +179,7 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
},
{
name: 'agent-hooks',
keys: ['agent hooks status', 'agent hooks off', 'agent hooks on'],
keys: ['agent hooks prepare-codex', 'agent hooks status', 'agent hooks off', 'agent hooks on'],
load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS
},
{
+83 -3
View File
@@ -10,7 +10,8 @@ const {
callMock,
getCliStatusMock,
getDefaultUserDataPathMock,
getManagedAgentHookStatusesMock
getManagedAgentHookStatusesMock,
prepareManagedCodexHomeBeforeShellLaunchMock
} = vi.hoisted(() => ({
applyAgentStatusHooksEnabledMock: vi.fn(),
callMock: vi.fn(),
@@ -27,7 +28,8 @@ const {
})
),
getDefaultUserDataPathMock: vi.fn(),
getManagedAgentHookStatusesMock: vi.fn()
getManagedAgentHookStatusesMock: vi.fn(),
prepareManagedCodexHomeBeforeShellLaunchMock: vi.fn()
}))
vi.mock('../runtime-client', () => {
@@ -54,7 +56,8 @@ vi.mock('../runtime-client', () => {
vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock,
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock,
prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock
}))
import { main } from '../index'
@@ -82,6 +85,7 @@ describe('agent hooks CLI handler', () => {
callMock.mockReset()
getCliStatusMock.mockClear()
getManagedAgentHookStatusesMock.mockReturnValue([])
prepareManagedCodexHomeBeforeShellLaunchMock.mockReset()
process.exitCode = undefined
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -120,4 +124,80 @@ describe('agent hooks CLI handler', () => {
expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true)
})
it('prepares managed Codex trust with the current hooks setting', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = false
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('honors Codex-specific disablement when the runtime is unavailable', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.disabledTuiAgents = ['codex']
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('uses the active profile settings instead of stale legacy settings', async () => {
const profileId = 'work-profile'
const legacy = getDefaultPersistedState(userDataPath)
legacy.settings.agentStatusHooksEnabled = true
writeDataFile(userDataPath, legacy)
const profile = getDefaultPersistedState(userDataPath)
profile.settings.agentStatusHooksEnabled = false
writeDataFile(join(userDataPath, 'profiles', profileId), profile)
writeFileSync(
join(userDataPath, 'orca-profile-index.json'),
JSON.stringify({
activeProfileId: profileId,
profiles: [{ id: profileId }]
}),
'utf-8'
)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
})
it('honors live hook and Codex-specific disablement before persistence settles', async () => {
const state = getDefaultPersistedState(userDataPath)
state.settings.agentStatusHooksEnabled = true
writeDataFile(userDataPath, state)
getDefaultUserDataPathMock.mockReturnValue(userDataPath)
callMock.mockResolvedValue({
result: {
settings: { agentStatusHooksEnabled: true, disabledTuiAgents: ['codex'] }
}
})
await main(['agent', 'hooks', 'prepare-codex'], userDataPath)
expect(prepareManagedCodexHomeBeforeShellLaunchMock).toHaveBeenCalledWith({
userDataPath,
hooksEnabled: false
})
expect(callMock).toHaveBeenCalledExactlyOnceWith('settings.get', undefined, {
timeoutMs: 1_000
})
})
})
+61 -5
View File
@@ -12,10 +12,12 @@ import {
} from '../runtime-client'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { getDefaultPersistedState } from '../../shared/constants'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings, PersistedState } from '../../shared/types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses
getManagedAgentHookStatuses,
prepareManagedCodexHomeBeforeShellLaunch
} from '../../main/agent-hooks/managed-agent-hook-controls'
type AgentHookCommandResult = {
@@ -26,7 +28,27 @@ type AgentHookCommandResult = {
}
function getDataPath(): string {
return join(getDefaultUserDataPath(), 'orca-data.json')
const userDataPath = getDefaultUserDataPath()
const indexPath = join(userDataPath, 'orca-profile-index.json')
for (const candidate of [indexPath, `${indexPath}.bak`]) {
try {
const parsed: unknown = JSON.parse(readFileSync(candidate, 'utf-8'))
if (!isRecord(parsed) || !Array.isArray(parsed.profiles)) {
continue
}
const profileId = parsed.activeProfileId
if (
typeof profileId === 'string' &&
/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(profileId) &&
parsed.profiles.some((profile) => isRecord(profile) && profile.id === profileId)
) {
return join(userDataPath, 'profiles', profileId, 'orca-data.json')
}
} catch {
// Try the profile-index backup, then the legacy pre-profile path.
}
}
return join(userDataPath, 'orca-data.json')
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -70,9 +92,35 @@ function writePersistedState(dataPath: string, state: PersistedState): void {
}
}
function readEnabledFromDisk(): boolean {
function readHookSettingsFromDisk(): Pick<
GlobalSettings,
'agentStatusHooksEnabled' | 'disabledTuiAgents'
> {
const state = readPersistedState(getDataPath())
return state.settings?.agentStatusHooksEnabled !== false
return {
agentStatusHooksEnabled: state.settings?.agentStatusHooksEnabled !== false,
disabledTuiAgents: normalizeDisabledTuiAgents(state.settings?.disabledTuiAgents)
}
}
async function readHookSettings(
client: RuntimeClient
): Promise<Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>> {
try {
const response = await client.call<{
settings?: Pick<GlobalSettings, 'agentStatusHooksEnabled' | 'disabledTuiAgents'>
}>('settings.get', undefined, { timeoutMs: 1_000 })
const settings = response.result.settings
if (settings && typeof settings.agentStatusHooksEnabled === 'boolean') {
return {
agentStatusHooksEnabled: settings.agentStatusHooksEnabled,
disabledTuiAgents: normalizeDisabledTuiAgents(settings.disabledTuiAgents)
}
}
} catch {
// The active profile on disk is the offline fallback.
}
return readHookSettingsFromDisk()
}
function updateEnabledOnDisk(enabled: boolean): {
@@ -157,9 +205,17 @@ async function setAgentHooksEnabled(
}
export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
'agent hooks prepare-codex': async ({ client }) => {
const settings = await readHookSettings(client)
prepareManagedCodexHomeBeforeShellLaunch({
userDataPath: getDefaultUserDataPath(),
hooksEnabled:
settings.agentStatusHooksEnabled && !settings.disabledTuiAgents.includes('codex')
})
},
'agent hooks status': async ({ json }) => {
const result: AgentHookCommandResult = {
enabled: readEnabledFromDisk(),
enabled: readHookSettingsFromDisk().agentStatusHooksEnabled,
settingsPath: getDataPath(),
appliedBy: 'offline',
statuses: getManagedAgentHookStatuses()
+1 -1
View File
@@ -1,7 +1,7 @@
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear-agent-access'
} from '../../shared/linear/agent-access'
import type { CommandHandler } from '../dispatch'
import {
getOptionalNonNegativeIntegerFlag,
+1 -1
View File
@@ -2,7 +2,7 @@ import type {
LinearIssueRelationship,
LinearIssueRelationWriteRequest,
LinearIssueRelationWriteResult
} from '../../shared/linear-agent-access'
} from '../../shared/linear/agent-access'
import type { CommandHandler } from '../dispatch'
import { getRequiredStringFlag } from '../flags'
import { printResult } from '../format'
+1 -1
View File
@@ -1,7 +1,7 @@
import type {
LinearSaveIssueRequest,
LinearSaveIssueResult
} from '../../shared/linear-agent-access'
} from '../../shared/linear/agent-access'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { formatLinearSaveIssue } from '../linear-format'
+2 -2
View File
@@ -19,8 +19,8 @@ import type {
LinearTeamListResult,
LinearTeamMembersResult,
LinearTeamStatesResult
} from '../../shared/linear-agent-access'
import { clampLinearSearchLimit } from '../../shared/linear-agent-access'
} from '../../shared/linear/agent-access'
import { clampLinearSearchLimit } from '../../shared/linear/agent-access'
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import { RuntimeClientError } from '../runtime-client'
@@ -0,0 +1,75 @@
import { afterEach, expect, it, vi } from 'vitest'
const callMock = vi.fn()
vi.mock('../format', () => ({ printResult: vi.fn() }))
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
afterEach(() => callMock.mockReset())
it('accepts an explicitly attested legacy worker-server settlement', async () => {
callMock.mockResolvedValueOnce({
result: {
relay: {
messageId: 'relay_legacy_done',
sequence: 1,
dispatchId: 'ctx_legacy',
destination: 'run_home',
accepted: true
},
lifecycle: {
action: 'completed',
authority: 'worker_server_legacy'
}
}
})
await ORCHESTRATION_HANDLERS['orchestration send']({
flags: new Map([
['from', 'term_worker'],
['subject', 'done'],
['type', 'worker_done'],
['task-id', 'task_legacy'],
['dispatch-id', 'ctx_legacy'],
['outcome', 'succeeded']
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: true
} as never)
expect(callMock).toHaveBeenCalledOnce()
})
it('rejects an unattested relay settlement', async () => {
callMock.mockResolvedValueOnce({
result: {
relay: {
messageId: 'relay_unattested',
sequence: 1,
dispatchId: 'ctx_legacy',
destination: 'run_home',
accepted: true
},
lifecycle: { action: 'completed' }
}
})
await expect(
ORCHESTRATION_HANDLERS['orchestration send']({
flags: new Map([
['from', 'term_worker'],
['subject', 'done'],
['type', 'worker_done'],
['task-id', 'task_legacy'],
['dispatch-id', 'ctx_legacy'],
['outcome', 'succeeded']
]),
client: { call: callMock },
cwd: '/tmp/repo',
json: true
} as never)
).rejects.toMatchObject({ code: 'operation_unknown' })
expect(callMock).toHaveBeenCalledOnce()
})
@@ -103,8 +103,9 @@ function hasLifecycleVerdict(result: unknown): boolean {
}
const action = (lifecycle as { action?: unknown }).action
if ('relay' in result) {
const authority = (lifecycle as { authority?: unknown }).authority
return (
(lifecycle as { authority?: unknown }).authority === 'run_home' &&
(authority === 'run_home' || authority === 'worker_server_legacy') &&
(action === 'completed' || action === 'failed' || action === 'rejected')
)
}
+4 -1
View File
@@ -92,7 +92,10 @@ const WORKER_TERMINAL_LIST_STATES = [
] as const
type LifecycleSendResult =
| { action: 'completed' | 'failed'; authority?: 'run_home' }
| {
action: 'completed' | 'failed'
authority?: 'run_home' | 'worker_server_legacy'
}
| { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean }
| { action: 'rejected'; code: string; reason: string }
-64
View File
@@ -61,67 +61,3 @@ describe('terminal close CLI', () => {
expect(help).toContain('durable persistence')
})
})
describe('terminal split CLI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends the old-host attribution bypass through the legacy-compatible env field', async () => {
const call = vi.fn(async (method: string) =>
method === 'status.get'
? {
result: {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}
}
: { result: { split: { handle: 'term-2', parentHandle: 'term-1' } } }
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await TERMINAL_HANDLERS['terminal split']({
flags: new Map([
['terminal', 'term-1'],
['direction', 'horizontal']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
expect(call).toHaveBeenNthCalledWith(1, 'status.get')
expect(call).toHaveBeenNthCalledWith(
2,
'terminal.split',
{
terminal: 'term-1',
direction: 'horizontal',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION']
},
{ expectedRuntimeId: 'runtime-1' }
)
})
it('refuses legacy hosts because renderer-owned splits discard environment fields', async () => {
const call = vi.fn().mockResolvedValue({
result: { appVersion: '1.4.181', capabilities: ['mobile.tasks.v1'] }
})
await expect(
TERMINAL_HANDLERS['terminal split']({
flags: new Map([['terminal', 'term-1']]),
client: { call } as unknown as RuntimeClient,
cwd: '/tmp/worktree',
json: true
})
).rejects.toMatchObject({
code: 'runtime_update_required',
message: expect.stringContaining('Update the host and try again')
})
expect(call).toHaveBeenCalledTimes(1)
expect(call).not.toHaveBeenCalledWith('terminal.split', expect.anything())
})
})
+17 -52
View File
@@ -8,8 +8,7 @@ import type {
RuntimeTerminalSend,
RuntimeTerminalShow,
RuntimeTerminalSplit,
RuntimeTerminalWait,
RuntimeStatus
RuntimeTerminalWait
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
@@ -38,14 +37,6 @@ import {
getRequiredWorktreeSelector,
getTerminalHandle
} from '../selectors'
import {
addLegacyTerminalAttributionDisableRequest,
hostSupportsTerminalCreateAttributionDisable,
hostSupportsTerminalSplitAttributionDisable,
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE,
withLegacyTerminalAttributionDisabledEnv
} from '../../shared/legacy-terminal-attribution-env'
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
// timeout. Even without an explicit server timeout, the client must allow
@@ -146,30 +137,17 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
const useRendererBackedInteractiveTerminal =
!client.isRemote && shouldUseRendererBackedInteractiveTerminal(command)
const focus = flags.get('focus') === true
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalCreateAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_CREATE_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ terminal: RuntimeTerminalCreate }>(
'terminal.create',
{
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined),
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
},
{ expectedRuntimeId: status.result.runtimeId }
)
const result = await client.call<{ terminal: RuntimeTerminalCreate }>('terminal.create', {
worktree: await getBrowserWorktreeSelector(flags, cwd, client),
command,
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
})
printResult(result, json, formatTerminalCreate)
},
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
@@ -190,24 +168,11 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
) {
throw new RuntimeClientError('invalid_argument', '--direction must be horizontal or vertical')
}
const status = await client.call<RuntimeStatus>('status.get')
if (!hostSupportsTerminalSplitAttributionDisable(status.result)) {
throw new RuntimeClientError(
'runtime_update_required',
TERMINAL_SPLIT_ATTRIBUTION_UPDATE_REQUIRED_MESSAGE
)
}
const result = await client.call<{ split: RuntimeTerminalSplit }>(
'terminal.split',
{
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command'),
env: withLegacyTerminalAttributionDisabledEnv(undefined),
envToDelete: addLegacyTerminalAttributionDisableRequest(undefined)
},
{ expectedRuntimeId: status.result.runtimeId }
)
const result = await client.call<{ split: RuntimeTerminalSplit }>('terminal.split', {
terminal: await getTerminalHandle(flags, cwd, client),
direction: directionFlag,
command: getOptionalStringFlag(flags, 'command')
})
printResult(result, json, formatTerminalSplit)
}
}
@@ -2,7 +2,7 @@ import {
buildLinearIssueLinkUpdates,
LINEAR_ISSUE_LINK_CLEARED,
type LinearIssueLinkUpdates
} from '../../shared/linear-links'
} from '../../shared/linear/links'
import { RuntimeClientError } from '../runtime-client'
export function getOptionalLinearIssueLinkFlag(
+82 -202
View File
@@ -3112,10 +3112,6 @@ describe('orca cli worktree awareness', () => {
it('passes explicit focus through terminal.create', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3140,19 +3136,13 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'RUNNER',
focus: true,
presentation: 'focused'
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: undefined,
title: 'RUNNER',
focus: true,
presentation: 'focused'
})
})
it('prints terminal.read fallback screen lines in json mode', async () => {
@@ -3194,10 +3184,6 @@ describe('orca cli worktree awareness', () => {
it('keeps interactive Codex startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3223,29 +3209,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps explicit focus semantics when forcing Codex through the renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3272,30 +3248,20 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex',
title: 'Codex',
focus: true,
presentation: 'focused',
rendererBacked: true,
activate: true
})
})
it('does not force the visible terminal path for explicit Codex exec commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3321,27 +3287,17 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex exec commands after global options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3367,27 +3323,17 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex exec',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 --sandbox workspace-write exec summarize',
title: 'Codex exec',
focus: false
})
})
it('does not force the visible terminal path for Codex review commands after long options', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3413,27 +3359,17 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex review',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --model=gpt-5 --sandbox=workspace-write review',
title: 'Codex review',
focus: false
})
})
it('does not force the visible terminal path for Codex help commands', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3459,27 +3395,17 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex help',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex --help',
title: 'Codex help',
focus: false
})
})
it('keeps Codex prompts after global options backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3505,29 +3431,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'codex -m gpt-5 "fix the flaky test"',
title: 'Codex prompt',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps interactive Claude startup commands backgrounded unless focus is explicit', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3553,29 +3469,19 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude',
title: 'Claude',
focus: false,
rendererBacked: true,
activate: false
})
})
it('keeps Claude print commands on the background terminal path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3601,18 +3507,12 @@ describe('orca cli worktree awareness', () => {
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Claude print',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'path:/tmp/repo/feature',
command: 'claude -p "summarize"',
title: 'Claude print',
focus: false
})
})
it('uses the resolved enclosing worktree for other worktree consumers', async () => {
@@ -3919,10 +3819,6 @@ describe('orca cli worktree awareness', () => {
it('sends explicit remote terminal create worktree selectors unchanged', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -3946,18 +3842,12 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: undefined,
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: undefined,
title: undefined,
focus: false
})
})
it('collects and formats memory diagnostics', async () => {
@@ -4065,10 +3955,6 @@ describe('orca cli worktree awareness', () => {
it('does not force remote Codex terminal creates through a local renderer path', async () => {
queueFixtures(
callMock,
okFixture('req_status', {
runtimeId: 'runtime-1',
capabilities: ['terminal.attribution-removed.v1']
}),
okFixture('req_terminal_create', {
terminal: {
handle: 'term_1',
@@ -4096,18 +3982,12 @@ describe('orca cli worktree awareness', () => {
'/tmp/client/repo/src'
)
expect(callMock).toHaveBeenCalledWith(
'terminal.create',
{
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
env: { ORCA_ATTRIBUTION_BYPASS: '1' },
envToDelete: ['ORCA_ENABLE_GIT_ATTRIBUTION'],
title: 'Codex',
focus: false
},
{ expectedRuntimeId: 'runtime-1' }
)
expect(callMock).toHaveBeenCalledWith('terminal.create', {
worktree: 'id:repo-1::/srv/orca/feature',
command: 'codex',
title: 'Codex',
focus: false
})
})
it('does not resolve implicit remote browser targets from client cwd', async () => {
+1 -1
View File
@@ -5,7 +5,7 @@ import type {
LinearMcpIssueListResult,
LinearProjectListResult,
LinearSearchResult
} from '../shared/linear-agent-access'
} from '../shared/linear/agent-access'
import {
formatLinearCreate,
formatLinearIssue,
+2 -2
View File
@@ -16,11 +16,11 @@ import type {
LinearTeamMembersResult,
LinearTeamStatesResult,
LinearStatusSetResult
} from '../shared/linear-agent-access'
} from '../shared/linear/agent-access'
import {
formatLinearProjectListRows,
linearProjectListWarningLines
} from '../shared/linear-project-list-format'
} from '../shared/linear/project-list-format'
export function formatLinearIssue(result: LinearIssueContextResult): string {
const issue = result.issue
+3 -3
View File
@@ -6,13 +6,13 @@ import type {
LinearIssueRequest,
LinearIssueTaskUpdateRequest,
LinearWriteTargetRequest
} from '../shared/linear-agent-access'
} from '../shared/linear/agent-access'
import {
LINEAR_CHILDREN_MAX_DEPTH,
LINEAR_WRITE_BODY_CAP,
clampLinearIssueDepth
} from '../shared/linear-agent-access'
import { isLinearUuid } from '../shared/linear-uuid'
} from '../shared/linear/agent-access'
import { isLinearUuid } from '../shared/linear/uuid'
import {
getOptionalNonNegativeIntegerFlag,
getOptionalStringFlag,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { LinearSaveIssueRequest } from '../shared/linear-agent-access'
import type { LinearSaveIssueRequest } from '../shared/linear/agent-access'
import {
getOptionalNullableNumberFlag,
getOptionalStringFlag,
-1
View File
@@ -87,7 +87,6 @@ export class RuntimeClient {
}
: {}
const envelope = {
expectedRuntimeId: options?.expectedRuntimeId,
orchestrationCapability: options?.orchestrationCapability,
orchestrationContractVersion: method.startsWith('orchestration.')
? ORCHESTRATION_CONTRACT_VERSION
-42
View File
@@ -50,48 +50,6 @@ describe('runtime transport timeout validation', () => {
// Why: these tests create Unix domain socket servers in temp directories.
// Windows does not support Unix domain sockets in the same way.
describe.skipIf(process.platform === 'win32')('runtime transport', () => {
it('forwards the expected runtime fence in local requests', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
let receivedRequest: Record<string, unknown> | null = null
const server = createServer((socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
socket.once('data', (data) => {
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
socket.write(
`${JSON.stringify({
id: receivedRequest.id,
ok: true,
result: { terminal: { handle: 'terminal-1' } },
_meta: { runtimeId: 'runtime-1' }
})}\n`
)
})
})
servers.add(server)
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
await sendRequest(
{
runtimeId: 'runtime-1',
pid: 123,
transports: [{ kind: 'unix', endpoint }],
authToken: 'token',
startedAt: 1
},
'terminal.create',
{ worktree: 'id:worktree-1' },
1_000,
{ expectedRuntimeId: 'runtime-1' }
)
expect(receivedRequest).toMatchObject({
method: 'terminal.create',
expectedRuntimeId: 'runtime-1'
})
})
it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-'))
const endpoint = join(userDataPath, 'runtime.sock')
-1
View File
@@ -186,7 +186,6 @@ export async function sendRequest<TResult>(
authToken: metadata.authToken,
method,
params,
expectedRuntimeId: envelope?.expectedRuntimeId,
orchestrationCapability: envelope?.orchestrationCapability,
orchestrationContractVersion: envelope?.orchestrationContractVersion,
orchestrationRequestId: envelope?.orchestrationRequestId,
+6
View File
@@ -2,6 +2,12 @@ import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const AGENT_HOOK_COMMAND_SPECS: CommandSpec[] = [
{
path: ['agent', 'hooks', 'prepare-codex'],
summary: 'Repair Orca-managed Codex hook trust before a shell launch',
usage: 'orca agent hooks prepare-codex',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['agent', 'hooks', 'status'],
summary: 'Show whether Orca-managed agent status hooks are enabled',
@@ -1,6 +1,6 @@
import type { vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id'
import type {
FirstWorkBranchRenameDeps,
FirstWorkBranchRenameEvent
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings, Repo } from '../../shared/types'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id'
const {
gitExecFileAsyncMock,
@@ -1,6 +1,6 @@
// On first agent work in a fresh workspace, replace the auto-generated creature branch (e.g. `you/Nautilus`) with a short work-derived name.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { parsePaneKey } from '../../shared/stable-pane-id'
import {
@@ -10,7 +10,7 @@ import {
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
getRepoIdFromWorktreeId,
splitWorktreeIdForFilesystem
} from '../../shared/worktree-id'
} from '../../shared/worktree/id'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target'
@@ -15,6 +15,7 @@ import {
} from './managed-agent-hook-registry'
export { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-registry'
export { prepareManagedCodexHomeBeforeShellLaunch } from '../codex/managed-home-shell-preflight'
type ManagedHookSettings = Partial<
Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
@@ -5,7 +5,7 @@ import {
type ExecutionHostId
} from '../../shared/execution-host'
import type { FolderWorkspace, ProjectGroup } from '../../shared/types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
export type RestoredSubagentLivenessSweepDeps = {
+68
View File
@@ -1070,6 +1070,74 @@ describe('AgentHookServer listener replay', () => {
}
})
it('does not let late Codex tool hooks with explicit prompt resurrect an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'UserPromptSubmit',
payload: {
state: 'working',
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'Run sleep 30, then reply done.',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
).toBe(true)
vi.setSystemTime(6_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
hookEventName: 'PostToolUse',
payload: {
state: 'working',
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex',
toolName: 'Bash',
toolInput: 'sleep 30'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'Run sleep 30, then reply done.',
agentType: 'codex',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a new prompt after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+4 -1
View File
@@ -448,7 +448,7 @@ function isToolProgressWorkingAfterInterrupt(next: AgentHookEventPayload): boole
if (next.payload.state !== 'working') {
return false
}
if (next.payload.agentType !== 'claude') {
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.
@@ -1260,6 +1260,9 @@ export class AgentHookServer {
(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 (
+129 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -46,6 +46,134 @@ const SCOPE_TRUNCATION: AiVaultScanIssue = {
}
describe('mergeAiVaultListResults', () => {
afterEach(() => {
vi.useRealTimers()
})
it('keeps the latest input scannedAt instead of restamping the merge', () => {
const merged = mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:00.000Z' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
],
undefined
)
expect(merged.scannedAt).toBe('2026-08-02T00:00:05.000Z')
})
it('ignores a future or unparsable remote stamp so the merge cannot pin the renderer guard', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:20.000Z' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:05.000Z')
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: 'scan-A' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:04.000Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:04.000Z')
expect(
mergeAiVaultListResults([{ ...listResult([]), scannedAt: 'scan-A' }], undefined).scannedAt
).toBe('2026-08-02T00:00:10.000Z')
})
// `scannedAt` is only `z.string()` on the wire, so a leg may legally send an
// ISO variant that sorts against the local stamp differently than it reads.
// Both cases below invert under a lexicographic compare.
it('orders remote stamps by instant, not by string', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
// '...:05Z' sorts after '...:05.500Z' ('Z' > '.') but is half a second older.
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05Z' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.500Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:05.500Z')
// A -05:00 offset makes this five hours in the future, but it sorts below
// the local stamp ('-' < '.'), so a string compare would accept it and pin
// the merged stamp above every later local rescan.
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:10-05:00' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:04.000Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:04.000Z')
})
// Leg order is host-enumeration order (local, then SSH, then runtime), so it
// must not decide the merged stamp.
it('resolves an equal-instant tie the same way in either leg order', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
const noMillis = { ...listResult([]), scannedAt: '2026-08-02T00:00:05Z' }
const withMillis = { ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
expect(mergeAiVaultListResults([noMillis, withMillis], undefined).scannedAt).toBe(
'2026-08-02T00:00:05.000Z'
)
expect(mergeAiVaultListResults([withMillis, noMillis], undefined).scannedAt).toBe(
'2026-08-02T00:00:05.000Z'
)
})
it('normalizes the merged stamp so a leg ISO variant cannot leak into it', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
expect(
mergeAiVaultListResults(
[{ ...listResult([]), scannedAt: '2026-08-01T19:00:05.000-05:00' }],
undefined
).scannedAt
).toBe('2026-08-02T00:00:05.000Z')
})
// `new Date(ms).toISOString()` throws RangeError outside +/-8.64e15. It cannot
// be reached because Date.parse applies TimeClip, so a non-NaN parse is always
// in range — pin both sides of that boundary so widening the accept guard
// turns a would-be crash on a hostile leg stamp into a test failure.
it('accepts the oldest representable stamp and rejects one millisecond beyond it', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
expect(
mergeAiVaultListResults(
[{ ...listResult([]), scannedAt: '-271821-04-20T00:00:00.000Z' }],
undefined
).scannedAt
).toBe('-271821-04-20T00:00:00.000Z')
expect(
mergeAiVaultListResults(
[{ ...listResult([]), scannedAt: '-271821-04-19T23:59:59.999Z' }],
undefined
).scannedAt
).toBe('2026-08-02T00:00:10.000Z')
})
it('does not cap an Unlimited all-host merge', () => {
const sessions = Array.from({ length: 1001 }, (_, index) => session(index))
const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true)
+26 -1
View File
@@ -75,6 +75,31 @@ export function mergeAiVaultListResults(
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
// Why: a merge is not a new scan. Reminting here made every all-host cache
// hit look fresh to the renderer, which only skipped apply when scannedAt
// matched. Keep the latest input stamp so identical legs stay a no-op.
scannedAt: latestAiVaultScannedAt(results)
}
}
function latestAiVaultScannedAt(results: readonly AiVaultListResult[]): string {
const nowMs = Date.now()
let latestMs = Number.NEGATIVE_INFINITY
for (const result of results) {
const stampMs = Date.parse(result.scannedAt)
// Remote legs carry their own clock and only `z.string()` validation. An
// unparsable or future stamp would pin the merged stamp above every local
// rescan and silently freeze the renderer's scannedAt equality guard.
// Compare parsed instants, not strings: `z.string()` does not pin the stamp
// to the exact `toISOString()` shape, and a legal variant (no milliseconds,
// a `+00:00` offset) orders wrongly under lexicographic compare.
if (Number.isNaN(stampMs) || stampMs > nowMs) {
continue
}
latestMs = Math.max(latestMs, stampMs)
}
// A merge has no single scan instant, so return the canonical form of the
// newest accepted one: echoing a winning leg's string made two legs at the
// same instant in different ISO shapes resolve by host-enumeration order.
return new Date(latestMs === Number.NEGATIVE_INFINITY ? nowMs : latestMs).toISOString()
}
@@ -3,7 +3,7 @@ import type { Automation } from '../../shared/automations-types'
import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup, Repo } from '../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
export type AutomationRunTargetResult =
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Session } from 'electron'
const electron = vi.hoisted(() => {
const windows: BrowserWindow[] = []
const getAllWebContents = vi.fn(() => [])
class BrowserWindow {
destroy = vi.fn()
resolveLoad!: () => void
webContents = {
debugger: { sendCommand: vi.fn(async (_method: string) => ({ cookies: [] })) },
isDestroyed: vi.fn(() => false)
}
constructor() {
windows.push(this)
}
loadURL() {
return new Promise<void>((resolve) => {
this.resolveLoad = resolve
})
}
}
return { BrowserWindow, getAllWebContents, windows }
})
const lease = vi.hoisted(() => ({ release: vi.fn() }))
vi.mock('electron', () => ({
BrowserWindow: electron.BrowserWindow,
webContents: { getAllWebContents: electron.getAllWebContents }
}))
vi.mock('./electron-debugger-lease', () => ({
acquireElectronDebugger: vi.fn(() => lease)
}))
import { openCookieClearStore } from './browser-cookie-clear-store'
function targetSession(): Session {
return { cookies: { get: vi.fn(), remove: vi.fn() } } as unknown as Session
}
describe('cookie clear debugger lifecycle', () => {
beforeEach(() => {
electron.windows.length = 0
electron.getAllWebContents.mockReturnValue([])
lease.release.mockClear()
})
it('memoizes a pending hidden-window attachment across concurrent callers', async () => {
const store = openCookieClearStore(targetSession())
const snapshot = store.snapshotClearIdentities([])
const restore = store.restoreClearIdentities([])
expect(electron.windows).toHaveLength(1)
electron.windows[0].resolveLoad()
await Promise.all([snapshot, restore])
expect(
electron.windows[0].webContents.debugger.sendCommand.mock.calls.map(([method]) => method)
).toEqual(['Network.getAllCookies'])
store.dispose()
expect(lease.release).toHaveBeenCalledOnce()
})
it('releases an attachment that resolves after disposal', async () => {
const store = openCookieClearStore(targetSession())
const snapshot = store.snapshotClearIdentities([])
store.dispose()
electron.windows[0].resolveLoad()
await expect(snapshot).rejects.toThrow(/disposed during debugger attachment/)
expect(lease.release).toHaveBeenCalledOnce()
expect(electron.windows[0].destroy).toHaveBeenCalledOnce()
await expect(store.restoreClearIdentities([])).rejects.toThrow(/store was disposed/)
expect(electron.windows).toHaveLength(1)
})
})
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import type { Cookie } from 'electron'
import {
cdpRestoreParamsFromIdentity,
cookieClearIdentitiesFromCdp
} from './browser-cookie-clear-store'
const chipsCookie: Cookie = {
domain: 'app.acme-chips.test',
name: 'chips-auth',
path: '/',
secure: true,
sameSite: 'no_restriction',
value: 'keep-me'
}
describe('cookie clear CDP identities', () => {
it('captures a CHIPS partition key for restore and never invents one', () => {
const identities = cookieClearIdentitiesFromCdp(
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
[
{
name: 'chips-auth',
value: 'keep-me',
domain: 'app.acme-chips.test',
path: '/',
secure: true,
sameSite: 'None',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
}
]
)
expect(identities).toEqual([
expect.objectContaining({
name: 'chips-auth',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
])
expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual(
expect.objectContaining({
name: 'chips-auth',
sameSite: 'None',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
})
)
})
it('fails closed when CDP cannot identify a removable cookie', () => {
expect(() =>
cookieClearIdentitiesFromCdp(
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
[]
)
).toThrow(/Could not snapshot cookie identity/)
})
it('keeps host-only and domain cookies with the same coordinates distinct', () => {
const cookies: Cookie[] = [
{ ...chipsCookie, domain: 'example.com', hostOnly: true, name: 'twin', value: 'host' },
{ ...chipsCookie, domain: '.example.com', hostOnly: false, name: 'twin', value: 'domain' }
]
const identities = cookieClearIdentitiesFromCdp(
cookies.map((cookie) => ({ cookie, url: 'https://example.com/' })),
[
{ name: 'twin', value: 'host', domain: 'example.com', path: '/' },
{ name: 'twin', value: 'domain', domain: '.example.com', path: '/' }
]
)
expect(identities).toEqual([
expect.objectContaining({ value: 'host', domain: 'example.com', hostOnly: true }),
expect.objectContaining({ value: 'domain', domain: '.example.com', hostOnly: false })
])
expect(cdpRestoreParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
expect(cdpRestoreParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
})
it('indexes CDP cookies once instead of rescanning the jar for every cookie', () => {
let domainReads = 0
const count = 200
const cookies = Array.from({ length: count }, (_, index) => ({
cookie: { ...chipsCookie, domain: `host-${index}.example`, name: `cookie-${index}` },
url: `https://host-${index}.example/`
}))
const cdpCookies = cookies.map(({ cookie }) => ({
name: cookie.name,
value: cookie.value,
get domain() {
domainReads += 1
return cookie.domain
},
path: '/'
}))
expect(cookieClearIdentitiesFromCdp(cookies, cdpCookies)).toHaveLength(count)
expect(domainReads).toBeLessThan(count * 6)
})
it('does not turn an unspecified SameSite policy into explicit Lax', () => {
expect(
cdpRestoreParamsFromIdentity({
url: 'https://example.com/',
name: 'unspecified',
value: 'value',
sameSite: 'unspecified'
})
).not.toHaveProperty('sameSite')
})
})
@@ -0,0 +1,297 @@
import { BrowserWindow, webContents, type Cookie, type Session } from 'electron'
import { acquireElectronDebugger } from './electron-debugger-lease'
import { normalizeCookieDomain } from './browser-cookie-import-policy'
import type {
CookieClearIdentity,
CookieClearPartitionKey,
CookieClearStore
} from './browser-cookie-import-clear'
type CdpCookiePartitionKey = {
topLevelSite?: string
hasCrossSiteAncestor?: boolean
}
type CdpCookie = {
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
session?: boolean
expires?: number
sameSite?: string
partitionKey?: CdpCookiePartitionKey
}
type CookieClearDebugger = {
sendCommand: (method: string, params?: Record<string, unknown>) => Promise<unknown>
}
type CookieClearSession = {
debugger: CookieClearDebugger
dispose: () => void
}
function findPartitionWebContents(targetSession: Session) {
return webContents
.getAllWebContents()
.find((contents) => !contents.isDestroyed() && contents.session === targetSession)
}
function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' | undefined {
if (sameSite === 'strict') {
return 'Strict'
}
if (sameSite === 'no_restriction') {
return 'None'
}
return sameSite === 'lax' ? 'Lax' : undefined
}
function electronSameSite(sameSite: string | undefined): Cookie['sameSite'] {
if (sameSite === 'Strict') {
return 'strict'
}
if (sameSite === 'None') {
return 'no_restriction'
}
return sameSite === 'Lax' ? 'lax' : 'unspecified'
}
function partitionKeyFromCdp(
partitionKey: CdpCookiePartitionKey | undefined
): CookieClearPartitionKey | undefined {
const topLevelSite = partitionKey?.topLevelSite
if (!topLevelSite) {
return undefined
}
return {
topLevelSite,
hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor === true
}
}
function cookieScopeKey(
name: string,
domain: string | undefined,
path: string | undefined,
hostOnly: boolean
): string | null {
const normalizedDomain = domain ? normalizeCookieDomain(domain) : null
return normalizedDomain ? JSON.stringify([name, normalizedDomain, path || '/', hostOnly]) : null
}
function cdpCookieScopeKey(cookie: CdpCookie): string | null {
return cookieScopeKey(cookie.name, cookie.domain, cookie.path, !cookie.domain?.startsWith('.'))
}
function indexCdpCookies(cookies: readonly CdpCookie[]): Map<string, CdpCookie[]> {
const index = new Map<string, CdpCookie[]>()
for (const cookie of cookies) {
const key = cdpCookieScopeKey(cookie)
if (!key) {
continue
}
const matches = index.get(key) ?? []
matches.push(cookie)
index.set(key, matches)
}
return index
}
function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
const partitionKey = partitionKeyFromCdp(cdpCookie.partitionKey)
return {
url,
name: cdpCookie.name,
value: cdpCookie.value,
domain: cdpCookie.domain,
hostOnly: !cdpCookie.domain?.startsWith('.'),
path: cdpCookie.path,
secure: cdpCookie.secure,
httpOnly: cdpCookie.httpOnly,
sameSite: electronSameSite(cdpCookie.sameSite),
...(cdpCookie.session === true || cdpCookie.expires == null
? {}
: { expirationDate: cdpCookie.expires }),
...(partitionKey ? { partitionKey } : {})
}
}
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
const existing = findPartitionWebContents(targetSession)
const window = existing
? null
: new BrowserWindow({
show: false,
webPreferences: {
session: targetSession,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
try {
if (window) {
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
}
const contents = existing ?? window?.webContents
if (!contents || contents.isDestroyed()) {
throw new Error('Could not attach to the cookie session for an atomic clear')
}
const lease = acquireElectronDebugger(contents)
return {
debugger: contents.debugger,
dispose: () => {
lease.release()
window?.destroy()
}
}
} catch (error) {
window?.destroy()
throw error
}
}
export function cookieClearIdentitiesFromCdp(
cookies: readonly { cookie: Cookie; url: string }[],
cdpCookies: readonly CdpCookie[]
): CookieClearIdentity[] {
const identities: CookieClearIdentity[] = []
const seen = new Set<string>()
const cdpCookieIndex = indexCdpCookies(cdpCookies)
for (const item of cookies) {
const key = cookieScopeKey(
item.cookie.name,
item.cookie.domain,
item.cookie.path,
item.cookie.hostOnly ?? !item.cookie.domain?.startsWith('.')
)
const matches = key ? (cdpCookieIndex.get(key) ?? []) : []
if (matches.length === 0) {
throw new Error('Could not snapshot cookie identity for an atomic clear')
}
for (const match of matches) {
const key = JSON.stringify([
item.url,
match.name,
match.domain,
match.path,
partitionKeyFromCdp(match.partitionKey) ?? null
])
if (seen.has(key)) {
continue
}
seen.add(key)
identities.push(identityFromCdpCookie(item.url, match))
}
}
return identities
}
export function cdpRestoreParamsFromIdentity(
identity: CookieClearIdentity
): Record<string, unknown> {
const sameSite = cdpSameSite(identity.sameSite)
return {
url: identity.url,
name: identity.name,
value: identity.value,
...(identity.hostOnly ? {} : { domain: identity.domain }),
...(identity.path ? { path: identity.path } : {}),
secure: identity.secure,
httpOnly: identity.httpOnly,
...(sameSite ? { sameSite } : {}),
...(identity.expirationDate ? { expires: identity.expirationDate } : {}),
...(identity.partitionKey ? { partitionKey: identity.partitionKey } : {})
}
}
function cdpCookiesFromCommand(value: unknown): CdpCookie[] {
if (typeof value !== 'object' || value === null || !('cookies' in value)) {
return []
}
const cookies = value.cookies
return Array.isArray(cookies) ? cookies : []
}
function cdpSetCookieSucceeded(value: unknown): boolean {
if (typeof value !== 'object' || value === null || !('success' in value)) {
return true
}
return value.success !== false
}
async function snapshotClearIdentitiesFromCdp(
cookieDebugger: CookieClearDebugger,
cookies: readonly { cookie: Cookie; url: string }[]
): Promise<CookieClearIdentity[]> {
const result = await cookieDebugger.sendCommand('Network.getAllCookies')
return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result))
}
async function restoreClearIdentitiesWithCdp(
cookieDebugger: CookieClearDebugger,
identities: readonly CookieClearIdentity[]
): Promise<void> {
for (const identity of identities) {
const result = await cookieDebugger.sendCommand(
'Network.setCookie',
cdpRestoreParamsFromIdentity(identity)
)
if (!cdpSetCookieSucceeded(result)) {
throw new Error(`Could not restore cookie ${identity.name}`)
}
}
}
export function openCookieClearStore(
targetSession: Session
): CookieClearStore & { dispose: () => void } {
let attached: CookieClearSession | null = null
let pendingAttach: Promise<CookieClearSession> | null = null
let disposed = false
const attach = async () => {
if (disposed) {
throw new Error('Cookie clear store was disposed')
}
if (attached) {
return attached
}
if (pendingAttach) {
return pendingAttach
}
const pending = attachCookieClearSession(targetSession).then((session) => {
if (disposed) {
session.dispose()
throw new Error('Cookie clear store was disposed during debugger attachment')
}
attached = session
return session
})
pendingAttach = pending
try {
return await pending
} finally {
if (pendingAttach === pending) {
pendingAttach = null
}
}
}
return {
get: (filter) => targetSession.cookies.get(filter),
remove: (url, name) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (cookies) =>
snapshotClearIdentitiesFromCdp((await attach()).debugger, cookies),
restoreClearIdentities: async (identities) =>
restoreClearIdentitiesWithCdp((await attach()).debugger, identities),
dispose: () => {
disposed = true
pendingAttach = null
attached?.dispose()
attached = null
}
}
}
@@ -0,0 +1,253 @@
import { describe, expect, it } from 'vitest'
import type { Cookie } from 'electron'
import {
identitiesFromClearCookies,
removeTransplantableCookies,
type CookieClearIdentity,
type CookieClearSession
} from './browser-cookie-import-clear'
function cookie(domain: string, name: string, path = '/', secure = true): Cookie {
return {
domain,
name,
path,
secure,
sameSite: 'unspecified',
value: `${name}-value`
}
}
function valueCookie(domain: string, name: string, value: string): Cookie {
return { ...cookie(domain, name), value }
}
function createJarSession(
initial: Cookie[],
options: {
failOn?: string
restoreError?: Error
snapshot?: CookieClearSession['snapshotClearIdentities']
arrivalDuringClear?: Cookie
} = {}
) {
let jar = [...initial]
const removedNames: string[] = []
const restoredNames: string[] = []
const session: CookieClearSession & {
names: () => string[]
removedNames: () => string[]
restoredNames: () => string[]
} = {
cookies: {
get: async () => [...jar],
remove: async (_url, name) => {
if (name === (options.failOn ?? 'stale')) {
throw new Error('cookie store unavailable')
}
removedNames.push(name)
jar = jar.filter((entry) => entry.name !== name)
}
},
clearData: async () => {
// Why: a cookie the user creates mid-clear lands before the rejection surfaces.
if (options.arrivalDuringClear) {
jar.push(options.arrivalDuringClear)
}
throw new Error('storage busy')
},
snapshotClearIdentities:
options.snapshot ?? (async (items) => identitiesFromClearCookies(items)),
restoreClearIdentities: async (identities) => {
restoredNames.push(...identities.map((identity) => identity.name))
if (options.restoreError) {
throw options.restoreError
}
for (const identity of identities) {
if (jar.some((entry) => entry.name === identity.name)) {
continue
}
jar.push(cookie(identity.domain ?? '', identity.name, identity.path, identity.secure))
}
},
names: () => jar.map((entry) => entry.name).sort(),
removedNames: () => [...removedNames],
restoredNames: () => [...restoredNames].sort()
}
return session
}
describe('STA-4090 failed full cookie clear', () => {
it('does not permanently delete a cookie removed before another removal rejects', async () => {
const session = createJarSession([
cookie('.google.com', 'SID'),
cookie('.example.com', 'removed-first', '/one'),
cookie('.other.test', 'stale', '/two')
])
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/existing cookies were restored/
)
expect(session.removedNames()).toEqual(['removed-first'])
expect(session.names()).toEqual(['SID', 'removed-first', 'stale'])
})
it('does not start removing when a cookie identity cannot be snapshotted', async () => {
const session = createJarSession(
[cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
{
snapshot: async (items) =>
identitiesFromClearCookies(items.filter(({ cookie: entry }) => entry.name !== 'stale'))
}
)
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/the session was left unchanged/
)
expect(session.removedNames()).toEqual([])
expect(session.names()).toEqual(['removed-first', 'stale'])
})
it('reports a partial clear only when restore also fails', async () => {
const session = createJarSession(
[cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
{ restoreError: new Error('restore rejected') }
)
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/the session was left partially cleared/
)
expect(session.names()).toEqual(['stale'])
})
it('restores a partitioned identity through the captured restore channel', async () => {
const identities: CookieClearIdentity[] = []
const session: CookieClearSession = {
cookies: {
get: async () => [cookie('.example.com', 'removed-first'), cookie('.other.test', 'stale')],
remove: async (_url, name) => {
if (name === 'stale') {
throw new Error('cookie store unavailable')
}
}
},
clearData: async () => {
throw new Error('storage busy')
},
snapshotClearIdentities: async (items) =>
identitiesFromClearCookies(items).map((identity) =>
identity.name === 'removed-first'
? {
...identity,
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
}
: identity
),
restoreClearIdentities: async (restored) => {
identities.push(...restored)
}
}
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/existing cookies were restored/
)
expect(identities).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'removed-first',
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
}),
expect.objectContaining({ name: 'stale' })
])
)
})
// Why (STA-4170): the fallback used to re-read the jar, so a login completed mid-clear was
// deleted with no snapshot identity to restore it — permanent auth loss reported as a restore.
it('leaves a cookie that arrived during the clear in the jar', async () => {
const session = createJarSession(
[cookie('.example.com', 'removed-first', '/one'), cookie('.other.test', 'stale', '/two')],
{ arrivalDuringClear: cookie('.arrived.test', 'fresh-login') }
)
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/existing cookies were restored/
)
expect(session.removedNames()).toEqual(['removed-first'])
expect(session.names()).toEqual(['fresh-login', 'removed-first', 'stale'])
// The restore set is exactly the mutated set: the arrival never needed restoring.
expect(session.restoredNames()).toEqual(['removed-first', 'stale'])
})
// Why (STA-4170): an arrival that reuses a snapshotted url+name IS removed — that coordinate is
// what the clear was asked to empty. It is not a loss: the identity is in the snapshot, so the
// rollback repopulates it with the pre-clear value and the coordinate is never left empty.
it('rolls a same-coordinate arrival back to its pre-clear value', async () => {
let jar = [valueCookie('.example.com', 'session', 'pre-clear'), cookie('.other.test', 'stale')]
const restored: CookieClearIdentity[] = []
const session: CookieClearSession = {
cookies: {
get: async () => [...jar],
remove: async (_url, name) => {
if (name === 'stale') {
throw new Error('cookie store unavailable')
}
jar = jar.filter((entry) => entry.name !== name)
}
},
clearData: async () => {
// The site re-sets the same cookie while the bulk clear is failing.
jar = jar.filter((entry) => entry.name !== 'session')
jar.push(valueCookie('.example.com', 'session', 'mid-clear'))
throw new Error('storage busy')
},
snapshotClearIdentities: async (items) => identitiesFromClearCookies(items),
restoreClearIdentities: async (identities) => {
restored.push(...identities)
for (const identity of identities) {
if (jar.some((entry) => entry.name === identity.name)) {
continue
}
jar.push(valueCookie(identity.domain ?? '', identity.name, identity.value))
}
}
}
await expect(removeTransplantableCookies(session)).rejects.toThrow(
/existing cookies were restored/
)
expect(jar.map((entry) => [entry.name, entry.value])).toEqual([
['stale', 'stale-value'],
['session', 'pre-clear']
])
expect(restored.map((identity) => identity.name)).toContain('session')
})
it('serializes concurrent clears on the same session', async () => {
const activeClears: number[] = []
let inClear = 0
const session: CookieClearSession = {
cookies: {
get: async () => [cookie('.example.com', 'session')],
remove: async () => {
inClear += 1
activeClears.push(inClear)
await Promise.resolve()
inClear -= 1
}
},
clearData: async () => {
throw new Error('storage busy')
},
snapshotClearIdentities: async (items) => identitiesFromClearCookies(items),
restoreClearIdentities: async () => undefined
}
await Promise.all([removeTransplantableCookies(session), removeTransplantableCookies(session)])
expect(activeClears).toEqual([1, 1])
})
})
@@ -0,0 +1,199 @@
import type { Cookie, Cookies, Session } from 'electron'
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
import {
cookieRemovalUrl,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS,
normalizeCookieDomain
} from './browser-cookie-import-policy'
const COOKIE_CLEAR_CONCURRENCY = 8
export type CookieClearPartitionKey = {
topLevelSite: string
hasCrossSiteAncestor: boolean
}
export type CookieClearIdentity = {
url: string
name: string
value: string
domain?: string
hostOnly?: boolean
path?: string
secure?: boolean
httpOnly?: boolean
sameSite: Cookie['sameSite']
expirationDate?: number
partitionKey?: CookieClearPartitionKey
}
export type CookieClearStore = Pick<Cookies, 'get' | 'remove'> & {
snapshotClearIdentities(
cookies: readonly { cookie: Cookie; url: string }[]
): Promise<CookieClearIdentity[]>
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
}
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
export type CookieClearSession = {
cookies: Pick<Cookies, 'get' | 'remove'>
clearData: Session['clearData']
snapshotClearIdentities: CookieClearStore['snapshotClearIdentities']
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
}
const clearLocks = new WeakMap<object, Promise<void>>()
function cookieClearKey(url: string, name: string): string {
return JSON.stringify([url, name])
}
export function identitiesFromClearCookies(
cookies: readonly { cookie: Cookie; url: string }[]
): CookieClearIdentity[] {
return cookies.map(({ cookie, url }) => ({
url,
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
hostOnly: cookie.hostOnly,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
expirationDate: cookie.expirationDate
}))
}
export async function withCookieClearLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
const previous = clearLocks.get(owner) ?? Promise.resolve()
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
clearLocks.set(
owner,
previous.then(() => current)
)
await previous
try {
return await run()
} finally {
release()
}
}
function removableCookieEntries(cookies: readonly Cookie[]): { cookie: Cookie; url: string }[] {
const removable: { cookie: Cookie; url: string }[] = []
for (const cookie of cookies) {
if (isNonTransplantableCookieDomain(cookie.domain ?? '')) {
continue
}
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
const url = domain ? cookieRemovalUrl(cookie, domain) : null
if (!url) {
throw new Error('Could not clear existing cookies; the session was left unchanged')
}
removable.push({ cookie, url })
}
return removable
}
function assertClearIdentitiesCoverRemovable(
removable: readonly { cookie: Cookie; url: string }[],
identities: readonly CookieClearIdentity[]
): void {
const covered = new Set(identities.map((identity) => cookieClearKey(identity.url, identity.name)))
for (const item of removable) {
if (!covered.has(cookieClearKey(item.url, item.cookie.name))) {
throw new Error('Could not clear existing cookies; the session was left unchanged')
}
}
}
function groupRemovableCookies(
removable: readonly { cookie: Cookie; url: string }[]
): Map<string, { cookie: Cookie; url: string }[]> {
const groups = new Map<string, { cookie: Cookie; url: string }[]>()
for (const item of removable) {
const key = cookieClearKey(item.url, item.cookie.name)
const group = groups.get(key) ?? []
group.push(item)
groups.set(key, group)
}
return groups
}
async function restoreClearedCookies(
targetSession: CookieClearSession,
identities: readonly CookieClearIdentity[],
failures: unknown[]
): Promise<never> {
try {
await targetSession.restoreClearIdentities(identities.toReversed())
} catch (restoreError) {
throw new AggregateError(
[...failures, restoreError],
'Could not clear existing cookies; the session was left partially cleared'
)
}
throw new AggregateError(
failures,
'Could not clear existing cookies; existing cookies were restored'
)
}
export async function removeTransplantableCookies(
targetSession: CookieClearSession
): Promise<void> {
return withCookieClearLock(targetSession, async () => {
const store = targetSession.cookies
const initialCookies = await store.get({})
if (initialCookies.length === 0) {
return
}
const initialRemovable = removableCookieEntries(initialCookies)
if (initialRemovable.length === 0) {
return
}
const identities = await targetSession.snapshotClearIdentities(initialRemovable)
assertClearIdentitiesCoverRemovable(initialRemovable, identities)
// Why (STA-4170): fixing the removal plan here, beside the identities that can undo it, is what
// keeps the two sets equal. Re-reading the jar in the fallback widened the removal set past the
// restore set, so a cookie that arrived mid-clear — a login the user had just completed — was
// deleted with nothing able to put it back. Removing an already-deleted cookie is a harmless
// no-op, so the stale plan costs nothing; only its narrowness matters.
const removalGroups = [...groupRemovableCookies(initialRemovable).values()]
try {
// Why (STA-4065): excludeOrigins keeps the google.com family, including partitioned
// cookies, so one call replaces a remove() per cookie on the ordinary import path.
await targetSession.clearData({
dataTypes: ['cookies'],
excludeOrigins: NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS
})
return
} catch {
// Why: a rejected bulk clear can still have emptied part of the jar.
}
const results = await mapSettledWithConcurrency(
removalGroups,
COOKIE_CLEAR_CONCURRENCY,
async (group) => {
// Why: identical removal coordinates must stay ordered instead of racing.
for (const { cookie, url } of group) {
await store.remove(url, cookie.name)
}
}
)
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
await restoreClearedCookies(targetSession, identities, failures)
}
})
}
@@ -30,6 +30,78 @@ vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn() },
session: { fromPartition: sessionFromPartitionMock }
}))
vi.mock('./browser-cookie-clear-store', () => ({
openCookieClearStore: (targetSession: {
cookies: {
get: (filter: object) => Promise<unknown>
remove: (url: string, name: string) => Promise<void>
set?: (details: Record<string, unknown>) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (
items: {
cookie: {
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
sameSite: string
expirationDate?: number
hostOnly?: boolean
}
url: string
}[]
) =>
items.map(({ cookie, url }) => ({
url,
name: cookie.name,
value: cookie.value,
domain: cookie.domain,
hostOnly: cookie.hostOnly,
path: cookie.path,
secure: cookie.secure,
httpOnly: cookie.httpOnly,
sameSite: cookie.sameSite,
expirationDate: cookie.expirationDate
})),
restoreClearIdentities: async (
identities: {
url: string
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
sameSite: string
expirationDate?: number
hostOnly?: boolean
}[]
) => {
if (!targetSession.cookies.set) {
return
}
for (const identity of identities.toReversed()) {
await targetSession.cookies.set({
url: identity.url,
name: identity.name,
value: identity.value,
...(identity.hostOnly ? {} : { domain: identity.domain }),
...(identity.path ? { path: identity.path } : {}),
secure: identity.secure,
httpOnly: identity.httpOnly,
sameSite: identity.sameSite,
...(identity.expirationDate ? { expirationDate: identity.expirationDate } : {})
})
}
},
dispose: () => undefined
})
}))
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
@@ -268,22 +340,29 @@ describe('native Chromium import excludes the Google cookie family', () => {
])
})
// Why (STA-4061): reconstruction drops partition keys, so a rejected removal must not rebuild
// anything — not the excluded Google rows, and not the non-Google rows that were already gone.
it('fails the import without reconstructing any cookie when selective removal rejects', async () => {
// Why (STA-4090): a rejected removal must restore cookies already deleted in the same clear
// without writing imported rows or touching the live Google session.
it('fails the import without permanently deleting a cookie removed before a later rejection', async () => {
const sourceCookiesPath = seedSource([
{ domain: '.example.com', name: 'session', value: 'new' }
])
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
cookiesGetMock.mockResolvedValue([
let jar = [
existingCookie('.google.com', 'SID'),
existingCookie('.example.com', 'removed-first'),
existingCookie('.other.test', 'stale')
])
]
cookiesGetMock.mockImplementation(async () => [...jar])
cookiesRemoveMock.mockImplementation(async (_url: string, name: string) => {
if (name === 'stale') {
throw new Error('cookie store unavailable')
}
jar = jar.filter((entry) => entry.name !== name)
})
cookiesSetMock.mockImplementation(async (details: { domain?: string; name: string }) => {
if (!jar.some((entry) => entry.name === details.name)) {
jar.push(existingCookie(details.domain ?? '.example.com', details.name))
}
})
clearDataMock.mockRejectedValue(new Error('storage busy'))
@@ -293,7 +372,10 @@ describe('native Chromium import excludes the Google cookie family', () => {
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
expect(clearDataMock).toHaveBeenCalledOnce()
expect(cookiesRemoveMock.mock.calls.map(([, name]) => name)).toEqual(['removed-first', 'stale'])
expect(cookiesSetMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(
expect.arrayContaining(['removed-first'])
)
expect(jar.map((entry) => entry.name).sort()).toEqual(['SID', 'removed-first', 'stale'])
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
})
})
@@ -31,11 +31,11 @@ const EXPECTED_PARTITION_KEY = {
hasCrossSiteAncestor: true
}
function buildFixtureMain(policyPath: string, resultPath: string): string {
function buildFixtureMain(bundlePath: string, resultPath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { writeFileSync } = require('node:fs')
const { removeTransplantableCookies } = require(${JSON.stringify(policyPath)})
const { openCookieClearStore, removeTransplantableCookies } = require(${JSON.stringify(bundlePath)})
const resultPath = ${JSON.stringify(resultPath)}
let currentStep = 'starting'
const mark = (step) => {
@@ -59,8 +59,6 @@ async function run() {
const debug = window.webContents.debugger
debug.attach('1.3')
mark('debugger attached')
await debug.sendCommand('Network.enable')
mark('network enabled')
// Only CDP can create a partitioned cookie; Electron's cookies API has no partitionKey.
await debug.sendCommand('Network.setCookie', {
@@ -83,25 +81,24 @@ async function run() {
await targetSession.cookies.set({ url: 'https://accounts.google.com/', name: 'SID', value: 'live', secure: true })
mark('removable cookies set')
const store = {
get: (filter) => targetSession.cookies.get(filter),
set: (details) => targetSession.cookies.set(details),
// Why: one rejecting removal is what drove the old rollback; the rest still succeed.
remove: async (url, name) => {
if (name === 'victim') throw new Error('forced victim removal failure')
return targetSession.cookies.remove(url, name)
}
}
// Why: the bulk clear is the ordinary path now, so rejecting it is what routes this fixture onto
// the per-cookie fallback where a partial failure — and the old rollback — could happen at all.
// Why: the bulk clear is the ordinary path now, so rejecting it routes this fixture onto
// the per-cookie fallback where a later removal can fail after earlier ones succeeded.
let bulkClearCalls = 0
const cookieClearStore = openCookieClearStore(targetSession)
const clearSession = {
cookies: store,
cookies: {
get: (filter) => targetSession.cookies.get(filter),
remove: async (url, name) => {
if (name === 'victim') throw new Error('forced victim removal failure')
return targetSession.cookies.remove(url, name)
}
},
clearData: async () => {
bulkClearCalls++
throw new Error('forced bulk clear failure')
}
},
snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies),
restoreClearIdentities: (identities) => cookieClearStore.restoreClearIdentities(identities)
}
let clearError = null
@@ -109,6 +106,8 @@ async function run() {
await removeTransplantableCookies(clearSession)
} catch (error) {
clearError = String(error?.message || error)
} finally {
cookieClearStore.dispose()
}
mark('clear finished')
@@ -140,25 +139,33 @@ run().catch((error) => {
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-partition-rollback-'))
fixtureRoots.push(root)
const policyPath = join(root, 'browser-cookie-import-policy.cjs')
const bundlePath = join(root, 'cookie-clear-rollback.cjs')
const bundleEntryPath = join(root, 'cookie-clear-rollback.ts')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
writeFileSync(
bundleEntryPath,
[
`export { openCookieClearStore } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-clear-store.ts'))}`,
`export { removeTransplantableCookies } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import-clear.ts'))}`
].join('\n')
)
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import-policy.ts'),
entry: bundleEntryPath,
formats: ['cjs'],
fileName: () => 'browser-cookie-import-policy.cjs'
fileName: () => 'cookie-clear-rollback.cjs'
},
outDir: root,
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(policyPath, resultPath))
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath))
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
@@ -178,20 +185,18 @@ async function runFixture(): Promise<FixtureResult> {
}
describe('non-Google partitioned cookie under a failed Electron cookie clear', () => {
it('never resurrects a removed partitioned cookie', async () => {
// Why (STA-4090): a later fallback rejection must not permanently drop cookies already
// removed in the same clear — including CHIPS cookies cookies.set() cannot round-trip.
it('keeps already-removed CHIPS and ordinary cookies after a later removal rejects', async () => {
const result = await runFixture()
expect(result.beforePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
// Why (STA-4065): the fallback is only reachable once the bulk clear has been tried and failed;
// if it ever succeeded here the partial-failure assertions below would be vacuous.
expect(result.bulkClearCalls).toBe(1)
expect(result.remainingExcluded.map(({ name }) => name)).toEqual(['SID'])
expect(result.clearError).toContain('Could not clear existing cookies')
// STA-4061: rollback rebuilt this cookie through cookies.set, which drops partitionKey.
expect(
result.remainingChips,
`chips-auth survived: ${JSON.stringify(result.remainingChips)}`
).toEqual([])
expect(result.remainingPlain).toEqual([])
expect(result.remainingChips).toEqual([
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
])
expect(result.remainingPlain).toEqual([{ name: 'plain', value: 'stale' }])
}, 90_000)
})
@@ -1,12 +1,15 @@
import { describe, expect, it, vi, type Mock } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import type { Cookie } from 'electron'
import {
identitiesFromClearCookies,
removeTransplantableCookies
} from './browser-cookie-import-clear'
import {
isGoogleSourceBoundCookie,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
removeTransplantableCookies,
replaceCookiesForImportedDomains
} from './browser-cookie-import-policy'
@@ -225,12 +228,24 @@ describe('removeTransplantableCookies', () => {
...overrides
}
const clearData = overrides.clearData ?? vi.fn().mockResolvedValue(undefined)
const restoreClearIdentities = vi.fn().mockResolvedValue(undefined)
const snapshotClearIdentities = vi.fn(
async (items: Parameters<typeof identitiesFromClearCookies>[0]) =>
identitiesFromClearCookies(items)
)
return {
session: { cookies: store, clearData },
session: {
cookies: store,
clearData,
snapshotClearIdentities,
restoreClearIdentities
},
get: store.get,
remove: store.remove,
set: store.set,
clearData
clearData,
snapshotClearIdentities,
restoreClearIdentities
}
}
@@ -278,14 +293,29 @@ describe('removeTransplantableCookies', () => {
expect(remove).not.toHaveBeenCalled()
})
it('bulk clears cookies the per-cookie path could never address', async () => {
const { session, clearData } = clearSession([
{ ...cookie('.example.com', 'session'), domain: '' }
it('does not attach or clear when the jar contains only excluded cookies', async () => {
const { session, snapshotClearIdentities, clearData, remove } = clearSession([
cookie('.google.com', 'SID'),
cookie('accounts.google.com', 'ACCOUNT')
])
await removeTransplantableCookies(session)
expect(clearData).toHaveBeenCalledOnce()
expect(snapshotClearIdentities).not.toHaveBeenCalled()
expect(clearData).not.toHaveBeenCalled()
expect(remove).not.toHaveBeenCalled()
})
it('does not mutate when a transplantable cookie cannot be represented for rollback', async () => {
const { session, clearData, remove, restoreClearIdentities } = clearSession([
{ ...cookie('.example.com', 'session'), domain: '' }
])
await expect(removeTransplantableCookies(session)).rejects.toThrow(/session was left unchanged/)
expect(clearData).not.toHaveBeenCalled()
expect(remove).not.toHaveBeenCalled()
expect(restoreClearIdentities).not.toHaveBeenCalled()
})
it('falls back to per-cookie removal when the bulk clear rejects', async () => {
@@ -303,28 +333,73 @@ describe('removeTransplantableCookies', () => {
])
})
it('re-reads the jar after a rejected bulk clear', async () => {
const beforeAttempt = [cookie('.removed.test', 'gone-before-fallback')]
const afterAttempt = [
cookie('.google.com', 'SID'),
cookie('.survivor.test', 'survived'),
cookie('.arrived.test', 'arrived-during-clear')
// Why (STA-4170): the fallback may only mutate what the identity snapshot can undo. Re-reading
// the jar here widened the removal set past the restore set. Re-removing a cookie the partial
// bulk clear already deleted is a harmless no-op, so the narrower stale plan costs nothing.
it('removes only the pre-clear snapshot after a rejected bulk clear', async () => {
const beforeAttempt = [
cookie('.removed.test', 'gone-before-fallback'),
cookie('.survivor.test', 'survived')
]
const get = vi.fn().mockResolvedValueOnce(beforeAttempt).mockResolvedValueOnce(afterAttempt)
const { session, remove } = clearSession(beforeAttempt, {
const get = vi
.fn()
.mockResolvedValue([
cookie('.google.com', 'SID'),
cookie('.survivor.test', 'survived'),
cookie('.arrived.test', 'arrived-during-clear')
])
.mockResolvedValueOnce(beforeAttempt)
const { session, remove, clearData } = clearSession(beforeAttempt, {
get,
clearData: rejectingBulkClear()
})
await removeTransplantableCookies(session)
expect(get).toHaveBeenCalledTimes(2)
expect(clearData).toHaveBeenCalledOnce()
expect(get).toHaveBeenCalledOnce()
expect(remove.mock.calls).toEqual([
['https://survivor.test/', 'survived'],
['https://arrived.test/', 'arrived-during-clear']
['https://removed.test/', 'gone-before-fallback'],
['https://survivor.test/', 'survived']
])
})
// Why (STA-4170): arrival plus a later removal failure is the exact shape that deleted a login
// the user had just completed and still reported restoration. Mutated set must equal restore set.
it('never touches a cookie that arrives while a rejected clear falls back', async () => {
const beforeAttempt = [
cookie('.example.com', 'first', '/one'),
cookie('.example.com', 'second', '/two')
]
const get = vi
.fn()
.mockResolvedValue([...beforeAttempt, cookie('.arrived.test', 'fresh-login')])
.mockResolvedValueOnce(beforeAttempt)
const { session, remove, restoreClearIdentities } = clearSession(beforeAttempt, {
get,
clearData: rejectingBulkClear(),
remove: vi.fn().mockImplementation(async (_url: string, name: string) => {
if (name === 'second') {
throw new Error('store unavailable')
}
})
})
await expect(removeTransplantableCookies(session)).rejects.toThrow(
'existing cookies were restored'
)
expect(remove.mock.calls).toEqual([
['https://example.com/one', 'first'],
['https://example.com/two', 'second']
])
expect(restoreClearIdentities).toHaveBeenCalledOnce()
const restored = restoreClearIdentities.mock.calls[0][0].map(
(identity: { name: string }) => identity.name
)
expect([...restored].sort()).toEqual(['first', 'second'])
})
// Why: the fallback carries the same exclusion as the bulk call, so a rejected clearData must
// not become the path that finally deletes a live Google session.
it('still preserves Google cookies on the per-cookie fallback', async () => {
@@ -343,10 +418,9 @@ describe('removeTransplantableCookies', () => {
expect(set).not.toHaveBeenCalled()
})
// Why (STA-4061): reconstructing a removed cookie loses its partition key, and the snapshot
// cannot say which cookies had one, so a failed clear must stay failed.
it('never reconstructs removed cookies when another removal fails', async () => {
const { session, remove, set } = clearSession(
// Why (STA-4090): a failed fallback must restore through captured identities, never cookies.set.
it('restores removed cookies through captured identities when another removal fails', async () => {
const { session, remove, set, restoreClearIdentities } = clearSession(
[
cookie('.google.com', 'SID'),
cookie('.example.com', 'first', '/one'),
@@ -364,10 +438,14 @@ describe('removeTransplantableCookies', () => {
)
await expect(removeTransplantableCookies(session)).rejects.toThrow(
'the session was left partially cleared'
'existing cookies were restored'
)
expect(remove).toHaveBeenCalledTimes(3)
expect(set).not.toHaveBeenCalled()
expect(restoreClearIdentities).toHaveBeenCalledOnce()
expect(
restoreClearIdentities.mock.calls[0][0].map((identity: { name: string }) => identity.name)
).toEqual(expect.arrayContaining(['first', 'second', 'third']))
})
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
@@ -1,6 +1,5 @@
import type { Cookie, Cookies, Session } from 'electron'
import type { Cookie, Cookies } from 'electron'
import { parse as parseDomain } from 'psl'
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
'SIDCC',
@@ -66,10 +65,9 @@ export function normalizeCookieImportDomain(domain: string): string | null {
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
// users actually asked for.
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
const NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS = NON_TRANSPLANTABLE_DOMAINS.map(
export const NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS = NON_TRANSPLANTABLE_DOMAINS.map(
(root) => `https://${root}`
)
const COOKIE_CLEAR_CONCURRENCY = 8
export function isNonTransplantableCookieDomain(domain: string): boolean {
const normalized = normalizeCookieDomain(domain)
@@ -159,7 +157,7 @@ function overlapsImportedDomain(
return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix))
}
function cookieRemovalUrl(cookie: Cookie, domain: string): string | null {
export function cookieRemovalUrl(cookie: Cookie, domain: string): string | null {
try {
const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`)
url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/'
@@ -201,83 +199,6 @@ export async function restoreImportedDomainCookies(
}
}
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
export type CookieClearSession = {
cookies: Pick<Cookies, 'get' | 'remove'>
clearData: Session['clearData']
}
// Why: Electron cannot round-trip partition identity, so excluded cookies must never be removed.
// Why (STA-4061): the same gap forbids rolling a partial clear back. cookies.get() omits
// partitionKey and cookies.set() silently drops it, so every reconstruction is a coin flip that
// can downgrade a partitioned (CHIPS) cookie into an unpartitioned one — and nothing in the
// snapshot says which cookies are at risk. A partially cleared jar is a retryable import failure;
// a downgraded cookie is unrecoverable auth-state corruption that survives restart.
// Why (STA-4065): the exclusion is module state rather than a parameter so the predicate and the
// origins the bulk clear preserves cannot drift apart — a caller-supplied predicate that disagreed
// with NON_TRANSPLANTABLE_DOMAINS would silently delete a cookie the bulk call is meant to keep.
export async function removeTransplantableCookies(
targetSession: CookieClearSession
): Promise<void> {
const store = targetSession.cookies
const initialCookies = await store.get({})
if (initialCookies.length === 0) {
return
}
// Why (STA-4065): measured on Electron 43, excludeOrigins preserves the whole registrable
// family — host, leading-dot, subdomain, and partitioned Google cookies — so one call replaces a
// remove() per cookie even when the jar holds cookies to keep. That is the ordinary case here:
// this import exists for Google, so a Google cookie is usually present.
try {
await targetSession.clearData({
dataTypes: ['cookies'],
excludeOrigins: NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS
})
return
} catch {
// Why: a rejected bulk clear can still have changed the jar, so the fallback must act on the
// survivors rather than stale removal coordinates from before the attempt.
}
const existingCookies = await store.get({})
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
for (const cookie of existingCookies) {
if (isNonTransplantableCookieDomain(cookie.domain ?? '')) {
continue
}
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
const url = domain ? cookieRemovalUrl(cookie, domain) : null
if (!url) {
continue
}
const key = JSON.stringify([url, cookie.name])
const group = removableGroups.get(key) ?? []
group.push({ cookie, url })
removableGroups.set(key, group)
}
const results = await mapSettledWithConcurrency(
[...removableGroups.values()],
COOKIE_CLEAR_CONCURRENCY,
async (group) => {
// Why: identical removal coordinates must stay ordered instead of racing.
for (const { cookie, url } of group) {
await store.remove(url, cookie.name)
}
}
)
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length > 0) {
throw new AggregateError(
failures,
'Could not clear existing cookies; the session was left partially cleared'
)
}
}
export async function replaceCookiesForImportedDomains(
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
importedDomains: readonly string[]
@@ -26,6 +26,21 @@ vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn() },
session: { fromPartition: sessionFromPartitionMock }
}))
vi.mock('./browser-cookie-clear-store', () => ({
openCookieClearStore: (targetSession: {
cookies: {
get: (filter: object) => Promise<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
items.map(({ cookie, url }) => ({ url, ...cookie })),
restoreClearIdentities: async () => undefined,
dispose: () => undefined
})
}))
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
@@ -44,6 +44,21 @@ vi.mock('electron', () => ({
dialog: { showOpenDialog: dialogShowOpenDialogMock },
session: { fromPartition: sessionFromPartitionMock }
}))
vi.mock('./browser-cookie-clear-store', () => ({
openCookieClearStore: (targetSession: {
cookies: {
get: (filter: object) => Promise<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
items.map(({ cookie, url }) => ({ url, ...cookie })),
restoreClearIdentities: async () => undefined,
dispose: () => undefined
})
}))
import {
buildChromiumCookieInsertParams,
+16 -2
View File
@@ -80,11 +80,12 @@ import {
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
normalizeCookieImportDomain,
removeTransplantableCookies,
replaceCookiesForImportedDomains,
restoreImportedDomainCookies,
type CookieImportMode
} from './browser-cookie-import-policy'
import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear'
import { openCookieClearStore } from './browser-cookie-clear-store'
import {
createChromiumCookieSnapshot,
type ChromiumCookieSnapshot
@@ -1780,7 +1781,20 @@ export async function importCookiesFromBrowser(
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
// session. Non-transplantable families are exempt — nothing was imported for them, and their
// live session is the only one that works.
await removeTransplantableCookies(targetSession)
const cookieClearStore = openCookieClearStore(targetSession)
try {
await withCookieClearLock(targetSession, () =>
removeTransplantableCookies({
cookies: cookieClearStore,
clearData: (options) => targetSession.clearData(options),
snapshotClearIdentities: (cookies) => cookieClearStore.snapshotClearIdentities(cookies),
restoreClearIdentities: (identities) =>
cookieClearStore.restoreClearIdentities(identities)
})
)
} finally {
cookieClearStore.dispose()
}
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
)
+2 -2
View File
@@ -97,7 +97,7 @@ describe('CliInstaller', () => {
expect(installed.pathConfigured).toBe(true)
const launcherContent = await readFile(installed.launcherPath as string, 'utf8')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"')
expect(launcherContent).toContain(`export ORCA_USER_DATA_PATH='${fixture.userDataPath}'`)
expect(launcherContent).toContain('export ORCA_APP_EXECUTABLE="$ELECTRON"')
expect(launcherContent).toContain(join(fixture.appPath, 'out', 'cli', 'index.js'))
@@ -130,7 +130,7 @@ describe('CliInstaller', () => {
expect(installed.detail).toContain('.local')
const launcherContent = await readFile(installed.launcherPath as string, 'utf8')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1')
expect(launcherContent).toContain('ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"')
expect(launcherContent).toContain(`export ORCA_USER_DATA_PATH='${fixture.userDataPath}'`)
const removed = await installer.remove()
+1 -1
View File
@@ -959,7 +959,7 @@ export ORCA_NODE_OPTIONS="\${NODE_OPTIONS-}"
export ORCA_NODE_REPL_EXTERNAL_MODULE="\${NODE_REPL_EXTERNAL_MODULE-}"
unset NODE_OPTIONS
unset NODE_REPL_EXTERNAL_MODULE
ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"
ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"
`
}
+249 -3
View File
@@ -1,8 +1,8 @@
import { execFile } from 'node:child_process'
import { copyFile, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { execFile, spawn } from 'node:child_process'
import { copyFile, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { dirname, join, sep } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
@@ -10,6 +10,7 @@ import { buildAppImageCliWrapper } from './appimage-cli-wrapper'
const require = createRequire(import.meta.url)
const execFileAsync = promisify(execFile)
const itRunsUnixShell = process.platform === 'win32' ? it.skip : it
const unixTerminationSignals = ['SIGINT', 'SIGTERM'] as const
const builderConfig = require('../../../config/electron-builder.config.cjs') as {
files?: string[]
asarUnpack?: string[]
@@ -18,6 +19,25 @@ const builderConfig = require('../../../config/electron-builder.config.cjs') as
win?: { extraResources?: { from?: string; to?: string }[] }
}
const linuxLauncherAsset = new URL('../../../resources/linux/bin/orca-ide', import.meta.url)
const darwinLauncherAsset = new URL('../../../resources/darwin/bin/orca', import.meta.url)
const unixLauncherFixtures = [
{
name: 'Linux',
asset: linuxLauncherAsset,
appDir: ['Orca'],
launcher: ['resources', 'bin', 'orca-ide'],
executable: ['orca-ide'],
cli: ['resources', 'app.asar.unpacked', 'out', 'cli', 'index.js']
},
{
name: 'macOS',
asset: darwinLauncherAsset,
appDir: ['Orca.app'],
launcher: ['Contents', 'Resources', 'bin', 'orca'],
executable: ['Contents', 'MacOS', 'Orca'],
cli: ['Contents', 'Resources', 'app.asar.unpacked', 'out', 'cli', 'index.js']
}
] as const
describe('packaged CLI assets', () => {
it('ships embedded skill guides with the CLI instead of source Markdown', () => {
@@ -60,6 +80,118 @@ describe('packaged CLI assets', () => {
expect(launcherStats.mode & 0o111).not.toBe(0)
})
itRunsUnixShell('replaces the shell process in packaged Unix launchers', async () => {
for (const launcher of [linuxLauncherAsset, darwinLauncherAsset]) {
const content = await readFile(launcher, 'utf8')
expect(content).toContain('ELECTRON_RUN_AS_NODE=1 exec "$ELECTRON" "$CLI" "$@"')
}
})
it('retries an incomplete listener-state write', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-listener-state-'))
const statePath = join(root, 'listener-state.json')
const expectedState = { pid: 1234, port: 5678 }
await writeFile(statePath, '{"pid":', 'utf8')
const completedWrite = new Promise<void>((resolve, reject) => {
setTimeout(() => {
writeFile(statePath, JSON.stringify(expectedState), 'utf8').then(resolve, reject)
}, 50)
})
try {
await expect(waitForListenerState(statePath)).resolves.toEqual(expectedState)
} finally {
await completedWrite
await rm(root, { recursive: true, force: true })
}
})
it.each([
{ state: { pid: '1234', port: 5678 }, reason: 'non-numeric pid' },
{ state: { pid: 0, port: 5678 }, reason: 'non-positive pid' },
{ state: { pid: 1234, port: '5678' }, reason: 'non-numeric port' },
{ state: { pid: 1234, port: 65_536 }, reason: 'out-of-range port' }
])('rejects $reason in listener state', async ({ state }) => {
const root = await mkdtemp(join(tmpdir(), 'orca-listener-state-'))
const statePath = join(root, 'listener-state.json')
try {
await writeFile(statePath, JSON.stringify(state), 'utf8')
await expect(waitForListenerState(statePath)).rejects.toThrow(
'Invalid launcher listener state'
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
itRunsUnixShell.each(unixLauncherFixtures)(
'delivers Unix termination signals to the $name executable and releases its listener',
async (launcherFixture) => {
for (const signal of unixTerminationSignals) {
const root = await mkdtemp(join(tmpdir(), 'orca-unix-cli-signal-'))
const appDir = join(root, ...launcherFixture.appDir)
const launcherPath = join(appDir, ...launcherFixture.launcher)
const electronPath = join(appDir, ...launcherFixture.executable)
const cliPath = join(appDir, ...launcherFixture.cli)
const statePath = join(root, `${signal}-listener-state.json`)
let executablePid: number | null = null
let launcher: ReturnType<typeof spawn> | null = null
try {
await mkdir(dirname(launcherPath), { recursive: true })
await mkdir(dirname(electronPath), { recursive: true })
await mkdir(dirname(cliPath), { recursive: true })
await copyFile(launcherFixture.asset, launcherPath)
await writeFile(cliPath, '', 'utf8')
await writeFile(
electronPath,
`#!/usr/bin/env node
const fs = require('node:fs')
const net = require('node:net')
const server = net.createServer()
const shutdown = () => server.close(() => process.exit(0))
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
server.listen(0, '127.0.0.1', () => {
fs.writeFileSync(process.env.ORCA_TEST_LISTENER_STATE, JSON.stringify({
pid: process.pid,
port: server.address().port
}))
})
`,
{ encoding: 'utf8', mode: 0o755 }
)
launcher = spawn(launcherPath, [], {
env: { ...process.env, ORCA_TEST_LISTENER_STATE: statePath },
stdio: 'ignore'
})
const state = await waitForListenerState(statePath)
executablePid = state.pid
const launcherPid = launcher.pid
const launcherExitPromise = waitForChildExit(launcher, 5_000)
launcher.kill(signal)
const launcherExit = await launcherExitPromise
const exitCode = launcherExit?.[0]
const exitSignal = launcherExit?.[1]
expect.soft(executablePid).toBe(launcherPid)
expect.soft(launcherExit).not.toBeNull()
expect.soft(exitCode).toBe(0)
expect.soft(exitSignal).toBeNull()
expect.soft(await waitForPortRelease(state.port)).toBe(true)
} finally {
if (launcher?.pid) {
await terminateSyntheticProcess(launcher.pid)
}
if (executablePid && executablePid !== launcher?.pid) {
await terminateSyntheticProcess(executablePid)
}
await rm(root, { recursive: true, force: true })
}
}
}
)
itRunsUnixShell(
'runs the Linux launcher from its packaged path and installed symlink',
async () => {
@@ -182,3 +314,117 @@ exec node "$@"
}
})
})
async function waitForListenerState(path: string): Promise<{ pid: number; port: number }> {
const deadline = Date.now() + 5_000
while (Date.now() < deadline) {
try {
const state: unknown = JSON.parse(await readFile(path, 'utf8'))
if (
typeof state !== 'object' ||
state === null ||
!('pid' in state) ||
typeof state.pid !== 'number' ||
!Number.isInteger(state.pid) ||
state.pid <= 0 ||
!('port' in state) ||
typeof state.port !== 'number' ||
!Number.isInteger(state.port) ||
state.port <= 0 ||
state.port > 65_535
) {
throw new Error('Invalid launcher listener state')
}
return { pid: state.pid, port: state.port }
} catch (error) {
if (!(error instanceof SyntaxError) && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
throw new Error('Timed out waiting for launcher listener state')
}
async function waitForPortRelease(port: number): Promise<boolean> {
const { createConnection } = await import('node:net')
const deadline = Date.now() + 2_000
while (Date.now() < deadline) {
const connected = await new Promise<boolean>((resolve) => {
const socket = createConnection({ host: '127.0.0.1', port })
socket.once('connect', () => {
socket.destroy()
resolve(true)
})
socket.once('error', () => resolve(false))
})
if (!connected) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
return false
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function waitForChildExit(
child: ReturnType<typeof spawn>,
timeoutMs: number
): Promise<readonly [number | null, NodeJS.Signals | null] | null> {
return new Promise((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve([child.exitCode, child.signalCode])
return
}
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
clearTimeout(timeout)
resolve([code, signal])
}
const timeout = setTimeout(() => {
child.off('exit', onExit)
resolve(null)
}, timeoutMs)
child.once('exit', onExit)
})
}
async function terminateSyntheticProcess(pid: number): Promise<void> {
if (!isProcessAlive(pid)) {
return
}
try {
process.kill(pid, 'SIGTERM')
} catch {
return
}
if (await waitForProcessExit(pid, 1_000)) {
return
}
try {
process.kill(pid, 'SIGKILL')
} catch {
return
}
if (!(await waitForProcessExit(pid, 1_000))) {
throw new Error(`Failed to terminate synthetic launcher process ${pid}`)
}
}
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 10))
}
return !isProcessAlive(pid)
}
@@ -75,6 +75,7 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeightBold: 700,
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
+1
View File
@@ -67,6 +67,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
markdownReviewToolsEnabled: true,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeightBold: 700,
terminalFontWeight: 500,
terminalLineHeight: 1,
terminalScrollSensitivity: 1.15,
@@ -0,0 +1,152 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
prepareManagedCodexHomeBeforeShellLaunch,
resolveManagedCodexShellPreflightHome
} from './managed-home-shell-preflight'
const roots: string[] = []
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'orca-codex-shell-preflight-'))
roots.push(root)
return root
}
afterEach(() => {
for (const root of roots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
})
describe('managed Codex shell preflight', () => {
it('accepts the Orca shared runtime home', () => {
const userDataPath = makeRoot()
const home = join(userDataPath, 'codex-runtime-home', 'home')
mkdirSync(home, { recursive: true })
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: home, ORCA_CODEX_HOME: home },
userDataPath
)
).toBe(home)
})
it('accepts a marker-proven account home and installs only while hooks are enabled', () => {
const userDataPath = makeRoot()
const home = join(userDataPath, 'codex-accounts', 'account-1', 'home')
mkdirSync(home, { recursive: true })
writeFileSync(join(home, '.orca-managed-home'), 'account-1\n')
const install = vi.fn(() => ({
agent: 'codex' as const,
state: 'installed' as const,
configPath: join(home, 'hooks.json'),
managedHooksPresent: true,
detail: null
}))
const env = { CODEX_HOME: home, ORCA_CODEX_HOME: home }
expect(
prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: true, env, install })
).toMatchObject({ state: 'installed' })
expect(install).toHaveBeenCalledWith(home)
expect(
prepareManagedCodexHomeBeforeShellLaunch({ userDataPath, hooksEnabled: false, env, install })
).toBeNull()
expect(install).toHaveBeenCalledTimes(1)
})
it('rejects user-owned homes and mismatched shell routing markers', () => {
const userDataPath = makeRoot()
const userHome = join(userDataPath, 'user-codex-home')
const managedHome = join(userDataPath, 'codex-runtime-home', 'home')
mkdirSync(userHome, { recursive: true })
mkdirSync(managedHome, { recursive: true })
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: userHome, ORCA_CODEX_HOME: userHome },
userDataPath
)
).toBeNull()
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: userHome, ORCA_CODEX_HOME: managedHome },
userDataPath
)
).toBeNull()
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: managedHome, ORCA_CODEX_HOME: undefined },
userDataPath
)
).toBeNull()
})
it.skipIf(process.platform === 'win32')('rejects a shared-home symlink escape', () => {
const userDataPath = makeRoot()
const outside = makeRoot()
mkdirSync(join(userDataPath, 'codex-runtime-home'))
symlinkSync(outside, join(userDataPath, 'codex-runtime-home', 'home'))
const candidate = join(userDataPath, 'codex-runtime-home', 'home')
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate },
userDataPath
)
).toBeNull()
})
it.skipIf(process.platform === 'win32')('rejects an account-home symlink escape', () => {
const userDataPath = makeRoot()
const outside = makeRoot()
const accountDir = join(userDataPath, 'codex-accounts', 'account-1')
mkdirSync(accountDir, { recursive: true })
writeFileSync(join(outside, '.orca-managed-home'), 'account-1\n')
symlinkSync(outside, join(accountDir, 'home'))
const candidate = join(accountDir, 'home')
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate },
userDataPath
)
).toBeNull()
})
it('rejects a parent traversal account marker', () => {
const userDataPath = makeRoot()
const candidate = join(userDataPath, 'home')
mkdirSync(join(userDataPath, 'codex-accounts'))
mkdirSync(candidate)
writeFileSync(join(candidate, '.orca-managed-home'), '..\n')
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate },
userDataPath
)
).toBeNull()
})
it.skipIf(process.platform === 'win32')('rejects a symlinked accounts root', () => {
const userDataPath = makeRoot()
const outside = makeRoot()
const candidate = join(userDataPath, 'codex-accounts', 'account-1', 'home')
mkdirSync(join(outside, 'account-1', 'home'), { recursive: true })
writeFileSync(join(outside, 'account-1', 'home', '.orca-managed-home'), 'account-1\n')
symlinkSync(outside, join(userDataPath, 'codex-accounts'))
expect(
resolveManagedCodexShellPreflightHome(
{ CODEX_HOME: candidate, ORCA_CODEX_HOME: candidate },
userDataPath
)
).toBeNull()
})
})
@@ -0,0 +1,98 @@
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { join, relative, resolve, sep } from 'node:path'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { codexHookService } from './hook-service'
type ShellPreflightEnvironment = {
CODEX_HOME?: string
ORCA_CODEX_HOME?: string
}
function pathsEqual(left: string, right: string): boolean {
return (
normalizeRuntimePathForComparison(resolve(left)) ===
normalizeRuntimePathForComparison(resolve(right))
)
}
function realPathMatches(left: string, expectedRealPath: string): boolean {
try {
return pathsEqual(realpathSync.native(left), expectedRealPath)
} catch {
return false
}
}
function resolveAccountManagedHome(candidate: string, userDataPath: string): string | null {
const accountsRoot = join(userDataPath, 'codex-accounts')
const pathParts = relative(accountsRoot, candidate).split(sep)
if (pathParts.length !== 2 || !pathParts[0] || pathParts[1] !== 'home') {
return null
}
const accountId = pathParts[0]
if (accountId === '.' || accountId === '..') {
return null
}
const expectedHome = join(accountsRoot, accountId, 'home')
let expectedRealHome: string
try {
expectedRealHome = join(realpathSync.native(userDataPath), 'codex-accounts', accountId, 'home')
} catch {
return null
}
if (!pathsEqual(candidate, expectedHome) || !realPathMatches(candidate, expectedRealHome)) {
return null
}
try {
return readFileSync(join(candidate, '.orca-managed-home'), 'utf-8').trim() === accountId
? expectedHome
: null
} catch {
return null
}
}
export function resolveManagedCodexShellPreflightHome(
env: ShellPreflightEnvironment,
userDataPath: string
): string | null {
const codexHome = env.CODEX_HOME?.trim()
const orcaCodexHome = env.ORCA_CODEX_HOME?.trim()
if (!codexHome || !orcaCodexHome || !pathsEqual(codexHome, orcaCodexHome)) {
return null
}
const sharedHome = join(userDataPath, 'codex-runtime-home', 'home')
if (pathsEqual(codexHome, sharedHome)) {
let expectedRealHome: string
try {
expectedRealHome = join(realpathSync.native(userDataPath), 'codex-runtime-home', 'home')
} catch {
return null
}
return existsSync(sharedHome) && realPathMatches(codexHome, expectedRealHome)
? sharedHome
: null
}
return resolveAccountManagedHome(codexHome, userDataPath)
}
export function prepareManagedCodexHomeBeforeShellLaunch(args: {
env?: ShellPreflightEnvironment
userDataPath: string
hooksEnabled: boolean
install?: (runtimeHomePath: string) => AgentHookInstallStatus
}): AgentHookInstallStatus | null {
if (!args.hooksEnabled) {
return null
}
const env = args.env ?? {
CODEX_HOME: process.env.CODEX_HOME,
ORCA_CODEX_HOME: process.env.ORCA_CODEX_HOME
}
const runtimeHomePath = resolveManagedCodexShellPreflightHome(env, args.userDataPath)
if (!runtimeHomePath) {
return null
}
return (args.install ?? ((home) => codexHookService.install(home)))(runtimeHomePath)
}
+1 -1
View File
@@ -35,7 +35,7 @@ hands → probe once more → `rename` in one syscall → verify we kept it.
- **Do not identify an entry by `birthtimeMs`.** Node documents it as sometimes holding the ctime,
filesystems without a birth time report the epoch, and its granularity is often coarser than the
events it must separate. Three attempts to patch around this produced three more defects; inode
recycling is now settled by asking whether anything is *serving*.
recycling is now settled by asking whether anything is _serving_.
- **Do not add a sweeper.** Deciding whether someone else's leftover is safe to delete is the
question this design retired; the last one produced five defects, including deleting a live
listener's only pathname. Every actor removes its own scratch name on each non-crash path.
@@ -20,5 +20,7 @@ export type TerminalCheckpointFile = {
/** Ties this checkpoint to the output.log whose header carries the same
* generation. Absent on checkpoints written before incremental logs. */
generation?: number
/** Last daemon pending-output batch included in this checkpoint. */
pendingOutputSeq?: number
checkpointedAt: string
}
@@ -0,0 +1,156 @@
import { describe, expect, it, vi } from 'vitest'
import {
CHECKPOINT_SESSION_QUEUE_MAX_PENDING,
CheckpointSessionQueue
} from './daemon-checkpoint-session-queue'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('CheckpointSessionQueue', () => {
it('serializes operations for one session', async () => {
const queue = new CheckpointSessionQueue()
const order: string[] = []
const first = deferred<void>()
const a = queue.run('s', async () => {
order.push('a:start')
await first.promise
order.push('a:end')
})
const b = queue.run('s', async () => {
order.push('b:start')
})
await vi.waitFor(() => expect(order).toEqual(['a:start']))
first.resolve()
await Promise.all([a, b])
expect(order).toEqual(['a:start', 'a:end', 'b:start'])
})
it('does not make one session wait on another', async () => {
const queue = new CheckpointSessionQueue()
const stalled = deferred<void>()
let stalledEntered = false
void queue
.run('stalled', async () => {
stalledEntered = true
await stalled.promise
})
.catch(() => {})
const healthy = await queue.run('healthy', async () => 'ran')
// Why assert entry: a stall that never started would prove nothing about isolation.
expect(stalledEntered).toBe(true)
expect(healthy).toBe('ran')
stalled.resolve()
})
it('resolves the fallback when the deadline fires and still runs the operation', async () => {
const queue = new CheckpointSessionQueue()
const stalled = deferred<void>()
let completed = false
const outcome = await queue.runWithDeadline(
'stalled',
async () => {
await stalled.promise
completed = true
return 'durable'
},
5,
'live'
)
expect(outcome).toBe('live')
// Why: abandoning the wait must not abandon the write, or a reattach deadline
// would drop durable history instead of merely delaying it.
expect(completed).toBe(false)
stalled.resolve()
await vi.waitFor(() => expect(completed).toBe(true))
})
it('keeps a later waiter behind an abandoned operation', async () => {
const queue = new CheckpointSessionQueue()
const stalled = deferred<void>()
const order: string[] = []
expect(
await queue.runWithDeadline<'ran' | 'timed-out'>(
's',
async () => {
order.push('stalled:start')
await stalled.promise
order.push('stalled:end')
return 'ran'
},
5,
'timed-out'
)
).toBe('timed-out')
const later = queue.run('s', async () => {
order.push('later')
})
await vi.waitFor(() => expect(order).toEqual(['stalled:start']))
stalled.resolve()
await later
expect(order).toEqual(['stalled:start', 'stalled:end', 'later'])
})
it('reports saturation once the queue bound is reached and clears it after draining', async () => {
const queue = new CheckpointSessionQueue()
const blocker = deferred<void>()
const pending: Promise<void>[] = []
for (let index = 0; index < CHECKPOINT_SESSION_QUEUE_MAX_PENDING; index += 1) {
expect(queue.isSaturated('s')).toBe(false)
pending.push(queue.run('s', async () => await blocker.promise))
}
expect(queue.isSaturated('s')).toBe(true)
expect(queue.isSaturated('other')).toBe(false)
blocker.resolve()
await Promise.all(pending)
expect(queue.isSaturated('s')).toBe(false)
})
it('keeps a stalled session queued rather than admitting a second writer', async () => {
const queue = new CheckpointSessionQueue()
const stalled = deferred<void>()
let secondEntered = false
void queue.run('s', async () => await stalled.promise).catch(() => {})
const second = queue.run('s', async () => {
secondEntered = true
})
// Why this matters: two concurrent tmp-write/rename pairs in one session directory lose a
// checkpoint, so a parked write must hold its slot rather than be dropped.
await new Promise((resolve) => setTimeout(resolve, 20))
expect(secondEntered).toBe(false)
stalled.resolve()
await second
expect(secondEntered).toBe(true)
})
it('does not strand a session after an operation rejects', async () => {
const queue = new CheckpointSessionQueue()
await expect(
queue.run('s', async () => {
throw new Error('checkpoint failed')
})
).rejects.toThrow('checkpoint failed')
expect(queue.isSaturated('s')).toBe(false)
expect(await queue.run('s', async () => 'next')).toBe('next')
})
})
@@ -0,0 +1,83 @@
/**
* Serializes terminal-history checkpoint work per session.
*
* Why per session and not one process-wide tail: checkpoint exclusivity only
* protects one session directory's tmp-write/rename pair. A single global tail
* made a warm reattach wait on every other session's checkpoint, so one
* never-settling checkpoint blocked every daemon-backed terminal (STA-4173).
*
* Why deadlines never cancel: an operation that blows its deadline keeps running
* to completion. Waiters degrade (older snapshot, retry next tick) instead of
* dropping durable history that a later reattach would need.
*/
/** Running plus queued operations one session may hold before callers are turned away. */
export const CHECKPOINT_SESSION_QUEUE_MAX_PENDING = 3
export class CheckpointSessionQueue {
private tails = new Map<string, Promise<unknown>>()
private pending = new Map<string, number>()
/** True when the session's queue is full and a new caller should degrade instead of wait. */
isSaturated(sessionId: string): boolean {
return (this.pending.get(sessionId) ?? 0) >= CHECKPOINT_SESSION_QUEUE_MAX_PENDING
}
/** Waits for the session's turn and resolves when the operation finishes. */
run<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
return this.enqueue(sessionId, operation)
}
/**
* Waits for the session's turn, giving up after `deadlineMs` and resolving
* `onDeadline`. The operation still runs to completion in the background.
*/
runWithDeadline<T>(
sessionId: string,
operation: () => Promise<T>,
deadlineMs: number,
onDeadline: T
): Promise<T> {
const work = this.enqueue(sessionId, operation)
return new Promise<T>((resolve) => {
const timer = setTimeout(() => resolve(onDeadline), deadlineMs)
timer.unref?.()
work.then(
(value) => {
clearTimeout(timer)
resolve(value)
},
() => {
clearTimeout(timer)
resolve(onDeadline)
}
)
})
}
// Why there is no clear(): dropping a tail while its write is parked would let the next caller
// into the same session directory, and two concurrent tmp-write/rename pairs lose a checkpoint.
// Entries remove themselves as operations settle, so a stalled session pins one entry at most.
private enqueue<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.tails.get(sessionId) ?? Promise.resolve()
this.pending.set(sessionId, (this.pending.get(sessionId) ?? 0) + 1)
const next = previous.catch(() => {}).then(operation)
this.tails.set(sessionId, next)
const settle = (): void => {
const remaining = (this.pending.get(sessionId) ?? 1) - 1
if (remaining > 0) {
this.pending.set(sessionId, remaining)
return
}
this.pending.delete(sessionId)
if (this.tails.get(sessionId) === next) {
this.tails.delete(sessionId)
}
}
// Why a detached handler: `next` is the shared tail, so a rejection the caller
// already sees would still surface here as an unhandled rejection.
next.then(settle, settle)
return next
}
}
@@ -0,0 +1,159 @@
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { HeadlessEmulator } from './headless-emulator'
import { isValidTerminalHistorySize } from './terminal-history-dimensions'
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
import type { PendingOutputRecord, TerminalSnapshot } from './types'
type RestoreBase = {
scrollbackAnsi: string
rehydrateSequences: string
snapshotAnsi: string
pendingEscapeTailAnsi?: string
oscLinks?: TerminalSnapshot['oscLinks']
lastTitle?: string
cwd: string | null
cols: number
rows: number
}
export function terminalSnapshotFromColdRestore(
info: ColdRestoreInfo,
opts?: { outputSequence?: number; frameRestoreAnsi?: string }
): TerminalSnapshot {
return {
snapshotAnsi: info.snapshotAnsi,
scrollbackAnsi: info.modes.alternateScreen ? info.scrollbackAnsi : '',
oscLinks: info.oscLinks,
rehydrateSequences: info.rehydrateSequences,
...(info.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: info.pendingEscapeTailAnsi } : {}),
...(opts?.frameRestoreAnsi ? { frameRestoreAnsi: opts.frameRestoreAnsi } : {}),
cwd: info.cwd,
modes: info.modes,
cols: info.cols,
rows: info.rows,
scrollbackLines:
info.scrollbackLines ?? Math.max(0, countAnsiRows(info.scrollbackAnsi) - info.rows),
...(info.lastTitle ? { lastTitle: info.lastTitle } : {}),
...(opts?.outputSequence !== undefined ? { outputSequence: opts.outputSequence } : {})
}
}
export async function buildDurableCheckpointSnapshot(opts: {
liveSnapshot: TerminalSnapshot
restoreInfo: ColdRestoreInfo | null
pendingRecords?: readonly PendingOutputRecord[]
scrollbackRows?: number
}): Promise<TerminalSnapshot> {
const pendingRecords = opts.pendingRecords ?? []
if (!opts.restoreInfo && pendingRecords.length === 0) {
return opts.liveSnapshot
}
if (
opts.restoreInfo &&
pendingRecords.length === 0 &&
(opts.scrollbackRows === undefined || opts.scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS)
) {
return terminalSnapshotFromColdRestore(opts.restoreInfo, {
outputSequence: opts.liveSnapshot.outputSequence,
frameRestoreAnsi: opts.liveSnapshot.frameRestoreAnsi
})
}
const emulator = new HeadlessEmulator({
cols: opts.restoreInfo?.cols ?? opts.liveSnapshot.cols,
rows: opts.restoreInfo?.rows ?? opts.liveSnapshot.rows,
scrollback: Math.min(
opts.scrollbackRows ?? DAEMON_RESTORE_SCROLLBACK_ROWS,
DAEMON_RESTORE_SCROLLBACK_ROWS
)
})
const replay = new ColdRestoreReplayWriter(emulator)
try {
// Why not seed the live window when there is no disk history: pending records
// are the raw stream. Replaying them on top of the already-truncated live
// snapshot would duplicate the newest rows and evict the older recoverable ones.
if (opts.restoreInfo) {
const base = restoreBaseFrom(opts.restoreInfo)
for (const segment of [
base.scrollbackAnsi,
base.rehydrateSequences,
base.snapshotAnsi,
base.pendingEscapeTailAnsi ?? ''
]) {
if (!(await replay.write(segment))) {
return opts.liveSnapshot
}
}
emulator.setRestoredOscLinks(base.oscLinks)
if (base.lastTitle) {
emulator.setLastTitle(base.lastTitle)
}
emulator.setCwd(base.cwd)
}
if (!(await replayPendingRecords(replay, pendingRecords))) {
return opts.liveSnapshot
}
const snapshot = emulator.getSnapshot()
return {
...snapshot,
...(opts.liveSnapshot.outputSequence !== undefined
? { outputSequence: opts.liveSnapshot.outputSequence }
: {}),
...(opts.liveSnapshot.frameRestoreAnsi && !snapshot.frameRestoreAnsi
? { frameRestoreAnsi: opts.liveSnapshot.frameRestoreAnsi }
: {})
}
} catch (error) {
console.warn('[history] durable snapshot rebuild failed:', error)
return opts.liveSnapshot
} finally {
emulator.dispose()
}
}
function restoreBaseFrom(restoreInfo: ColdRestoreInfo): RestoreBase {
return {
scrollbackAnsi: restoreInfo.modes.alternateScreen ? restoreInfo.scrollbackAnsi : '',
rehydrateSequences: restoreInfo.rehydrateSequences,
snapshotAnsi: restoreInfo.snapshotAnsi,
...(restoreInfo.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: restoreInfo.pendingEscapeTailAnsi }
: {}),
oscLinks: restoreInfo.oscLinks,
lastTitle: restoreInfo.lastTitle,
cwd: restoreInfo.cwd,
cols: restoreInfo.cols,
rows: restoreInfo.rows
}
}
async function replayPendingRecords(
replay: ColdRestoreReplayWriter,
records: readonly PendingOutputRecord[]
): Promise<boolean> {
for (const record of records) {
if (record.kind === 'output') {
if (!(await replay.write(record.data))) {
return false
}
continue
}
if (record.kind === 'resize') {
if (!isValidTerminalHistorySize(record.cols, record.rows)) {
return false
}
await replay.resize(record.cols, record.rows)
continue
}
await replay.clearScrollback()
}
return true
}
function countAnsiRows(ansi: string): number {
if (ansi.length === 0) {
return 0
}
return ansi.split(/\r\n|\n|\r/).filter((row) => row.length > 0).length
}
@@ -3,7 +3,7 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
expect(PROTOCOL_VERSION).toBe(33)
expect(PROTOCOL_VERSION).toBe(34)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
@@ -16,5 +16,6 @@ describe('foreground-confirmation daemon protocol', () => {
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(30)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(31)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(32)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(33)
})
})
+108 -21
View File
@@ -29,6 +29,7 @@ import {
} from './types'
const HEALTH_CHECK_TIMEOUT_MS = 3_000
const PS_IDENTITY_TIMEOUT_MS = 2_000
const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000
const KILL_WAIT_MS = 3_000
const KILL_POLL_MS = 100
@@ -438,19 +439,47 @@ type PsProcessIdentity = {
startedAtMs: number | null
}
function parsePsProcessIdentity(output: string): PsProcessIdentity {
// BSD ps formats lstart as a fixed-width 24-character timestamp.
const startedAtMs = Date.parse(output.slice(0, 24))
return {
commandLine: output.slice(24).trim(),
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null
}
}
function getPsProcessIdentity(pid: number): PsProcessIdentity | null {
try {
const output = execFileSync('ps', ['-p', String(pid), '-o', 'lstart=', '-o', 'command='], {
encoding: 'utf8',
timeout: 2_000
})
// BSD ps formats lstart as a fixed-width 24-character timestamp.
const startedAtMs = Date.parse(output.slice(0, 24))
const commandLine = output.slice(24).trim()
return {
commandLine,
startedAtMs: Number.isFinite(startedAtMs) ? startedAtMs : null
}
return parsePsProcessIdentity(output)
} catch {
return null
}
}
async function getPsProcessIdentityAsync(pid: number): Promise<PsProcessIdentity | null> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
'ps',
['-p', String(pid), '-o', 'lstart=', '-o', 'command='],
{
encoding: 'utf8',
timeout: PS_IDENTITY_TIMEOUT_MS
},
(error, output) => {
if (error) {
reject(error)
return
}
resolve(output)
}
)
})
return parsePsProcessIdentity(stdout)
} catch {
return null
}
@@ -560,7 +589,7 @@ async function inspectDaemonProcessIdentity(
commandLineMatchesDaemon(cmdline, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
)
} catch {
const identity = getPsProcessIdentity(pid)
const identity = await getPsProcessIdentityAsync(pid)
if (!identity) {
return 'unknown'
}
@@ -579,7 +608,7 @@ async function getDaemonCommandLine(pid: number): Promise<string | null> {
try {
return readFileSync(`/proc/${pid}/cmdline`, 'utf8')
} catch {
return getPsProcessIdentity(pid)?.commandLine ?? null
return (await getPsProcessIdentityAsync(pid))?.commandLine ?? null
}
}
@@ -669,6 +698,38 @@ export async function isDaemonStaleForCurrentBundle(
// 'unknown' fails open: legacy pid files and probe failures must not trigger replacement.
export type MacDaemonTccAttributionHealth = 'intact' | 'severed' | 'unknown'
let cachedMacDaemonTccAttributionHealth: {
key: string
pending: Promise<MacDaemonTccAttributionHealth>
} | null = null
function getMacDaemonTccAttributionCacheKey(
runtimeDir: string,
socketPath: string,
tokenPath: string,
packagedAppVersion: string | null,
protocolVersion: number
): string | null {
try {
const pidRecord = readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8')
const parsedPid = parseDaemonPidFile(pidRecord)
if (!parsedPid) {
return null
}
const spawnerExists = parsedPid.spawnerExecPath ? existsSync(parsedPid.spawnerExecPath) : null
return JSON.stringify([
socketPath,
tokenPath,
packagedAppVersion,
protocolVersion,
pidRecord,
spawnerExists
])
} catch {
return null
}
}
/**
* macOS pins a process's TCC "responsible process" to the binary that forked it,
* by file reference. The detached daemon outlives that app instance, and once the
@@ -686,23 +747,49 @@ export async function getMacDaemonTccAttributionHealth(
if (process.platform !== 'darwin') {
return 'unknown'
}
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
if (!parsedPid) {
const cacheKey = getMacDaemonTccAttributionCacheKey(
runtimeDir,
socketPath,
tokenPath,
packagedAppVersion,
protocolVersion
)
if (cacheKey && cachedMacDaemonTccAttributionHealth?.key === cacheKey) {
return await cachedMacDaemonTccAttributionHealth.pending
}
const pending = (async (): Promise<MacDaemonTccAttributionHealth> => {
const parsedPid = await readVerifiedDaemonPid(
runtimeDir,
socketPath,
tokenPath,
protocolVersion
)
if (!parsedPid) {
return 'unknown'
}
// Packaged updates can replace the bundle at the same path; missing version
// metadata also identifies a daemon from before the current packaged generation.
if (packagedAppVersion !== null && parsedPid.appVersion !== packagedAppVersion) {
return 'severed'
}
if (parsedPid.spawnerExecPath) {
return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed'
}
return 'unknown'
})()
if (cacheKey) {
cachedMacDaemonTccAttributionHealth = { key: cacheKey, pending }
}
// Packaged updates can replace the bundle at the same path, so path existence
// alone cannot prove the recorded spawning binary still backs this daemon.
const health = await pending
if (
packagedAppVersion !== null &&
parsedPid.appVersion !== null &&
parsedPid.appVersion !== packagedAppVersion
health === 'unknown' &&
cachedMacDaemonTccAttributionHealth?.key === cacheKey &&
cachedMacDaemonTccAttributionHealth.pending === pending
) {
return 'severed'
cachedMacDaemonTccAttributionHealth = null
}
if (parsedPid.spawnerExecPath) {
return existsSync(parsedPid.spawnerExecPath) ? 'intact' : 'severed'
}
return 'unknown'
return health
}
function isNoSuchProcessError(error: unknown): boolean {
+13 -5
View File
@@ -949,7 +949,9 @@ export async function initDaemonPtyProvider(
socketPath: info.socketPath,
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null
})
releaseDaemonAdoptionLease(newSpawner.getHandle())
await abortedStartupAdapter.disconnectOnly()
@@ -961,6 +963,8 @@ export async function initDaemonPtyProvider(
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
historyPath: getHistoryDir(),
// Why: on daemon death, ensureConnected() detects the dead socket and calls this to fork a replacement before retrying.
respawn: async (reason: DaemonRespawnReason) => {
@@ -975,9 +979,9 @@ export async function initDaemonPtyProvider(
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
} else if (reason === 'unhealthy_resolver' || reason === 'severed_tcc_attribution') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
attributedReplaceReason = reason
}
newSpawner.resetHandle()
await newSpawner.ensureRunning()
@@ -1196,6 +1200,8 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
tokenPath: info.tokenPath,
pidPath: getDaemonPidPath(runtimeDir),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
historyPath: getHistoryDir(),
respawn: async (reason: DaemonRespawnReason) => {
// Why: attribute rather than emit — the launcher below is the one that completes the
@@ -1209,9 +1215,9 @@ async function runRestartDaemon(): Promise<RestartDaemonResult> {
if (!restartInFlight) {
trackDaemonRetired('died_respawn')
}
} else if (reason === 'unhealthy_resolver') {
} else if (reason === 'unhealthy_resolver' || reason === 'severed_tcc_attribution') {
// Must reach the launcher below without an await in between; see the consume site.
attributedReplaceReason = 'unhealthy_resolver'
attributedReplaceReason = reason
}
currentSpawner.resetHandle()
await currentSpawner.ensureRunning()
@@ -1414,6 +1420,8 @@ export async function createLegacyDaemonAdapters(
tokenPath,
pidPath: getDaemonPidPath(runtimeDir, protocolVersion),
profileScope: runtimeDir,
runtimeDir,
packagedAppVersion: app.isPackaged ? app.getVersion() : null,
protocolVersion,
historyPath
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION,
COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GET_FOREGROUND_PROCESS_PROTOCOL_VERSION,
HISTORY_SEED_TRANSFER_PROTOCOL_VERSION,
@@ -16,7 +17,8 @@ import {
describe('daemon protocol version', () => {
it('ships bounded history transfer after the 2031-unsubscribe fact', () => {
expect(PROTOCOL_VERSION).toBe(33)
expect(PROTOCOL_VERSION).toBe(34)
expect(CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION).toBe(34)
expect(WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION).toBe(33)
expect(SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION).toBe(32)
expect(STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION).toBe(31)
@@ -27,7 +29,7 @@ describe('daemon protocol version', () => {
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
Array.from({ length: 32 }, (_, index) => index + 1)
Array.from({ length: 33 }, (_, index) => index + 1)
)
})
+4 -3
View File
@@ -1,6 +1,7 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
// v33 preserves selected-WSL POSIX cwd semantics; older owners remain attachable.
export const PROTOCOL_VERSION = 33
// v34 prepares Codex hook trust at the shell launch boundary; older owners remain attachable.
export const PROTOCOL_VERSION = 34
export const CODEX_SHELL_LAUNCH_PREFLIGHT_DAEMON_PROTOCOL_VERSION = 34
export const WSL_POSIX_CWD_DAEMON_PROTOCOL_VERSION = 33
export const SNAPSHOT_SERIALIZER_FIDELITY_DAEMON_PROTOCOL_VERSION = 32
export const STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION = 31
@@ -27,7 +28,7 @@ export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28, 29, 30, 31, 32
28, 29, 30, 31, 32, 33
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
+128 -19
View File
@@ -24,9 +24,16 @@ import { getDaemonSocketPath, serializeDaemonPidFile } from './daemon-spawner'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks'
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown')
}))
const { getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock } = vi.hoisted(
() => ({
getMacDaemonSystemResolverHealthMock: vi.fn(
async (): Promise<'unknown' | 'unhealthy'> => 'unknown'
),
getMacDaemonTccAttributionHealthMock: vi.fn(
async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown'
)
})
)
const itOnPosix = process.platform === 'win32' ? it.skip : it
@@ -34,7 +41,8 @@ vi.mock('./daemon-health', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonHealthModule>()
return {
...actual,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock
}
})
@@ -137,6 +145,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSpawnOpts = null
getMacDaemonSystemResolverHealthMock.mockReset()
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
getMacDaemonTccAttributionHealthMock.mockReset()
getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown')
})
it('reports whether its daemon protocol can participate in agent claims', () => {
@@ -2717,7 +2727,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') })
expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') }),
{ pendingOutputSeq: expect.any(Number) }
)
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
@@ -2850,7 +2861,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') })
expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') }),
{ pendingOutputSeq: expect.any(Number) }
)
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
})
@@ -2866,14 +2878,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
env: { SHELL: '/bin/zsh' },
sessionId: 'sleep-checkpoint-tail'
})
const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements')
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
lastSubprocess._simulateData('\x1b]777;orca-shell-ready')
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [
{ kind: 'output', data: '\x1b]777;orca-shell-ready' }
])
expect(checkpointSpy).toHaveBeenCalledWith(
id,
expect.objectContaining({
pendingEscapeTailAnsi: expect.stringContaining('\x1b]777;orca-shell-ready')
}),
{ pendingOutputSeq: expect.any(Number) }
)
})
it('returns cold restore data when disk history has unclean shutdown', async () => {
@@ -3261,7 +3277,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
expect(appendSpy).not.toHaveBeenCalled()
expect(checkpointSpy).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({ snapshotAnsi: expect.stringContaining('revived session') })
expect.objectContaining({ snapshotAnsi: expect.stringContaining('revived session') }),
{ pendingOutputSeq: expect.any(Number) }
)
// Subsequent ticks return to incremental appends.
@@ -3320,8 +3337,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
}
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.isReattach).toBe(true)
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(false)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(true)
})
it('skips the cold-restore replay when the daemon session is still alive', async () => {
@@ -3332,20 +3349,17 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
await first.disconnectOnly()
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader
const detectSpy = vi.spyOn(reader, 'detectColdRestore')
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
expect(result.isReattach).toBe(true)
expect(result.coldRestore).toBeUndefined()
expect(detectSpy).not.toHaveBeenCalled()
// The unmanaged-reattach re-anchor must survive the skipped detect.
// The unmanaged-reattach re-anchor must survive a live remount overlay.
const internals = historyAdapter as unknown as {
sessionsNeedingFullCheckpoint: Set<string>
lastFullCheckpointAt: Map<string, number>
}
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(false)
expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(false)
expect(internals.lastFullCheckpointAt.has(sessionId)).toBe(true)
})
it('does not probe session aliveness when there is no restorable history', async () => {
@@ -3921,6 +3935,101 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
respawnAdapter.dispose()
})
it('preserves a severed-TCC daemon that still owns live sessions before a new spawn', async () => {
const respawnFn = vi.fn()
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
// One live session in this adapter — the zero-session gate must fail closed.
await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(getMacDaemonTccAttributionHealthMock).toHaveBeenCalledWith(
dir,
socketPath,
tokenPath,
'1.4.178',
respawnAdapter.protocolVersion
)
expect(respawnFn).not.toHaveBeenCalled()
expect(next.id).toBeDefined()
respawnAdapter.dispose()
})
it('preserves a severed-TCC daemon when its live session inventory is unavailable', async () => {
const respawnFn = vi.fn()
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
const internals = respawnAdapter as unknown as {
client: { request: (type: string, payload?: unknown) => Promise<unknown> }
}
const originalRequest = internals.client.request.bind(internals.client)
vi.spyOn(internals.client, 'request').mockImplementation((type, payload) => {
if (type === 'listSessions') {
return Promise.reject(new Error('inventory unavailable'))
}
return originalRequest(type, payload)
})
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const next = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(respawnFn).not.toHaveBeenCalled()
expect(next.id).toBeDefined()
respawnAdapter.dispose()
})
it('replaces a severed-TCC daemon before a fresh session when no sessions are active', async () => {
let respawnServer: DaemonServer | undefined
const respawnFn = vi.fn(async () => {
await server.shutdown()
rmSync(socketPath, { force: true })
respawnServer = new DaemonServer({
socketPath,
tokenPath,
spawnSubprocess: () => createMockSubprocess()
})
await respawnServer.start()
})
const respawnAdapter = new DaemonPtyAdapter({
socketPath,
tokenPath,
runtimeDir: dir,
packagedAppVersion: '1.4.178',
respawn: respawnFn
})
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
const replacement = await respawnAdapter.spawn({ cols: 80, rows: 24, isNewSession: true })
expect(getMacDaemonTccAttributionHealthMock).toHaveBeenCalledWith(
dir,
socketPath,
tokenPath,
'1.4.178',
respawnAdapter.protocolVersion
)
expect(respawnFn).toHaveBeenCalledTimes(1)
expect(respawnFn).toHaveBeenCalledWith('severed_tcc_attribution')
expect(replacement.id).toBeDefined()
respawnAdapter.dispose()
await respawnServer?.shutdown()
})
it('propagates respawn failure to the caller', async () => {
const respawnFn = vi.fn(async () => {
throw new Error('Daemon entry file missing')
+337 -51
View File
@@ -6,6 +6,7 @@ import { DaemonClient } from './client'
import { DAEMON_ENDPOINT_LOST_MESSAGE } from './daemon-endpoint-ownership'
import {
getMacDaemonSystemResolverHealth,
getMacDaemonTccAttributionHealth,
parseDaemonPidFile,
type ParsedDaemonPid
} from './daemon-health'
@@ -69,6 +70,10 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
import {
iterateTerminalHistorySeedChunks,
@@ -86,6 +91,7 @@ import {
} from './daemon-audit-classifier'
import type { DaemonEvidenceSource, ExactDaemonIncarnation } from './daemon-incarnation-evidence'
import { createDaemonAuditEligibilityTracker } from './daemon-audit-eligibility-event'
import { normalizeDesktopTerminalSnapshotRows } from '../../shared/terminal-scrollback-policy'
type PendingDaemonSpawnOperation = {
exitsBySessionId: Map<string, { incarnationId?: string }[]>
@@ -99,6 +105,11 @@ type HistoryRecoveryContext = {
identityChanged: boolean
}
type SnapshotCheckpointResult = {
checkpoint: HistoryCheckpointResult
snapshot: NonNullable<TakePendingOutputResult['snapshot']> | null
}
// Why take-and-clear together: every consuming branch must reset the field, so pairing them stops one from forgetting.
function takeRecoveryFreeze(
historyRecovery: HistoryRecoveryContext,
@@ -128,11 +139,15 @@ export type DaemonPtyAdapterOptions = {
protocolVersion?: number
/** Directory for disk-based terminal history; when set, raw PTY output is written to disk for cold restore on daemon crash. */
historyPath?: string
/** Runtime profile directory used to verify daemon TCC attribution. */
runtimeDir?: string
/** Current packaged version, or null for unpackaged builds. */
packagedAppVersion?: string | null
/** Forks a fresh daemon after endpoint death or a confirmed resolver-health replacement. */
respawn?: (reason: DaemonRespawnReason) => Promise<void | (() => void)>
}
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver'
export type DaemonRespawnReason = 'daemon_died' | 'unhealthy_resolver' | 'severed_tcc_attribution'
export type DaemonIdentityChangeEvent = {
previous: DaemonEndpointIdentity
@@ -142,6 +157,15 @@ export type DaemonIdentityChangeEvent = {
const MAX_TOMBSTONES = 1000
const MAX_CONCURRENT_CHECKPOINTS = 4
// Why a reattach deadline at all: a warm reattach is a user click, so it must never wait on a
// stalled history filesystem. Why 5s: a healthy deep-history rebuild replays at most
// DAEMON_RESTORE_SCROLLBACK_ROWS through the headless emulator in well under a second, so this
// only fires when the write path is genuinely wedged (STA-4173).
const DURABLE_HISTORY_OVERLAY_DEADLINE_MS = 5_000
// Why the background pass gets a longer one: blowing it only defers a still-dirty session to the
// next tick, so the budget should sit above any slow-but-working disk rather than churn it.
const PERIODIC_CHECKPOINT_DEADLINE_MS = 15_000
// Why far below the client's 30s default: a wedged daemon holds its socket open, so an unbounded
// probe stalls a pane mount for the full request timeout — and the owner fan-out waits on every
// adapter, so one hung daemon stalls each restoring pane. Answering "unknown" quickly is strictly
@@ -180,6 +204,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
private historyManager: HistoryManager | null
private historyReader: HistoryReader | null
private respawnFn: DaemonPtyAdapterOptions['respawn'] | null
private runtimeDir: string | null
private packagedAppVersion: string | null
private pendingRespawnAdoptionRelease: (() => void) | null = null
private respawnAdoptionClosed = false
// Why: concurrent spawn() calls hitting a dead daemon would each fork their own; this promise coalesces respawns so only the first forks and the rest await it.
@@ -223,8 +249,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
private dirtySessionVersions = new Map<string, number>()
// Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot.
private sessionsNeedingFullCheckpoint = new Set<string>()
// Why: overflow or an unpersisted drain breaks disk-to-queue continuity, so only the daemon emulator can safely re-anchor the next checkpoint.
private sessionsNeedingLiveCheckpoint = new Set<string>()
// Why: a fresh adapter may reuse deep disk history only when the checkpoint proves it ends at the daemon's preceding pending-output batch.
private sessionsNeedingContinuityCheckpoint = new Set<string>()
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
private checkpointInFlight: Promise<void> | null = null
// Why per session: exclusivity protects one session directory's tmp-write/rename, so ordering
// every session behind one tail let a single stalled checkpoint block all reattaches (STA-4173).
private checkpointQueue = new CheckpointSessionQueue()
private keepHistoryShutdowns = new Set<Promise<void>>()
private disconnectOnlyPromise: Promise<void> | null = null
// Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s.
@@ -285,6 +318,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.historyManager = opts.historyPath ? new HistoryManager(opts.historyPath) : null
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
this.respawnFn = opts.respawn ?? null
this.runtimeDir = opts.runtimeDir ?? opts.profileScope ?? null
this.packagedAppVersion = opts.packagedAppVersion === undefined ? null : opts.packagedAppVersion
this.supportsCheckpoints = this.protocolVersion >= 4
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
this.supportsProducerFlowControl = this.protocolVersion >= 19
@@ -456,6 +491,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
if (opts.isNewSession) {
await this.replaceUnhealthyMacResolverDaemonBeforeNewPty()
await this.replaceSeveredMacTccDaemonBeforeNewPty()
}
await this.ensureConnected()
@@ -752,6 +788,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
if (this.historyManager.hasWriter(sessionId)) {
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
} else if (canReanchorHistory) {
@@ -811,8 +848,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint.
this.historyManager.registerWriter(sessionId, takeRecoveryFreeze(historyRecovery, sessionId))
if (!wasAlreadyManaged) {
// Why: a previous adapter may have drained records it never persisted, so appending would leave a seq gap the reader rejects; force a full snapshot to re-anchor.
// Why: a previous adapter may have drained records it never persisted, so the first compact must prove disk-to-daemon continuity.
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.add(sessionId)
this.lastFullCheckpointAt.delete(sessionId)
}
}
@@ -831,15 +869,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
}
const isAltScreen = result.snapshot.modes.alternateScreen
const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences
const snapshotFrame = result.snapshot.snapshotAnsi
const reattachSnapshot = await this.overlayDurableRestoreSnapshot(sessionId, result.snapshot)
const reattachProviderSequence =
typeof reattachSnapshot.outputSequence === 'number'
? { value: reattachSnapshot.outputSequence, generation: 'continued' as const }
: providerSequence
const isAltScreen = reattachSnapshot.modes.alternateScreen
const snapshotPrefix = reattachSnapshot.scrollbackAnsi + reattachSnapshot.rehydrateSequences
const snapshotFrame = reattachSnapshot.snapshotAnsi
const snapshotPayload = snapshotPrefix + snapshotFrame
// Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty).
// Why known `0` is no longer dropped: the pane tracker must be able to tell
// "the app negotiated nothing" from "this reattach proved nothing".
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(
result.snapshot.modes.kittyKeyboardFlags
reattachSnapshot.modes.kittyKeyboardFlags
)
return {
id: sessionId,
@@ -849,27 +892,27 @@ export class DaemonPtyAdapter implements IPtyProvider {
...launchIdentity(),
...(providerWslDistro !== undefined ? { wslDistro: providerWslDistro } : {}),
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
snapshotCols: reattachSnapshot.cols,
snapshotRows: reattachSnapshot.rows,
// Why only for an alt frame: normal history remains safe to replay at its capture grid.
...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi
...(isAltScreen && snapshotFrame && reattachSnapshot.frameRestoreAnsi
? {
snapshotPrefixAnsi: snapshotPrefix,
snapshotFrameAnsi: snapshotFrame,
snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi
snapshotFrameRestoreAnsi: reattachSnapshot.frameRestoreAnsi
}
: {}),
...(providerSequence ? { providerSequence } : {}),
...(reattachProviderSequence ? { providerSequence: reattachProviderSequence } : {}),
...(kittyKeyboardFlags !== undefined
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
isReattach: true,
isAlternateScreen: isAltScreen,
// Why: the snapshot ANSI has no title frame; carry lastTitle beside it so main can seed title records after a relaunch.
...(result.snapshot.lastTitle ? { lastTitle: result.snapshot.lastTitle } : {}),
...(reattachSnapshot.lastTitle ? { lastTitle: reattachSnapshot.lastTitle } : {}),
// Why: carry the mid-escape tail so the renderer writes it after the reattach reset, else a split escape renders literally (#7329).
...(result.snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: result.snapshot.pendingEscapeTailAnsi }
...(reattachSnapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: reattachSnapshot.pendingEscapeTailAnsi }
: {})
}
}
@@ -1126,6 +1169,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
// Why: the !keepHistory path takes no final checkpoint, so clear sessionsNeedingFullCheckpoint here or it stays stranded (no-op under keepHistory).
this.sessionsNeedingFullCheckpoint.delete(id)
this.sessionsNeedingLiveCheckpoint.delete(id)
this.sessionsNeedingContinuityCheckpoint.delete(id)
this.lastFullCheckpointAt.delete(id)
this.stopCheckpointTimerIfIdle()
this.initialCwds.delete(id)
@@ -1217,40 +1262,125 @@ export class DaemonPtyAdapter implements IPtyProvider {
return null
}
try {
const scrollbackRows = normalizeDesktopTerminalSnapshotRows(opts.scrollbackRows)
const result = await this.client.request<GetSnapshotResult>('getSnapshot', {
sessionId: id,
...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {})
...(scrollbackRows !== undefined ? { scrollbackRows } : {})
})
const snapshot = result.snapshot
// Why: older v19 daemons lack an absolute output sequence, so their snapshot can't reconcile bytes queued on the other socket.
if (!snapshot || typeof snapshot.outputSequence !== 'number') {
return null
}
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: snapshot.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
alternateScreen: snapshot.modes.alternateScreen,
// Why known `0` is carried too: it proves the app negotiated nothing at
// this boundary, which is a different fact from a source that cannot say.
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {})
}
const restored =
this.historyManager &&
this.historyReader &&
(scrollbackRows === undefined || scrollbackRows > DAEMON_SESSION_SCROLLBACK_ROWS)
? await this.overlayDurableRestoreSnapshot(id, snapshot, scrollbackRows)
: snapshot
return this.toProviderBufferSnapshot(restored)
} catch {
return null
}
}
private toProviderBufferSnapshot(
snapshot: NonNullable<GetSnapshotResult['snapshot']>
): PtyProviderBufferSnapshot | null {
if (typeof snapshot.outputSequence !== 'number') {
return null
}
const kittyKeyboardFlags = parseTerminalKittyKeyboardFlags(snapshot.modes.kittyKeyboardFlags)
return {
data: snapshot.rehydrateSequences + snapshot.snapshotAnsi,
frameRestoreAnsi: snapshot.frameRestoreAnsi,
scrollbackAnsi: snapshot.scrollbackAnsi,
cols: snapshot.cols,
rows: snapshot.rows,
cwd: snapshot.cwd,
lastTitle: snapshot.lastTitle,
seq: snapshot.outputSequence,
source: 'headless',
oscLinks: snapshot.oscLinks,
alternateScreen: snapshot.modes.alternateScreen,
// Why known `0` is carried too: it proves the app negotiated nothing at
// this boundary, which is a different fact from a source that cannot say.
...(kittyKeyboardFlags !== undefined ? { kittyKeyboardFlags } : {}),
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
: {})
}
}
private async overlayDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyManager || !this.historyReader) {
return liveSnapshot
}
// Why turn the caller away instead of queueing: this session already has a
// compact in flight whose result a third one would only duplicate, and an
// unbounded queue is how a stalled history filesystem grows without limit.
if (this.checkpointQueue.isSaturated(sessionId)) {
return liveSnapshot
}
// Why per session with a deadline: a reattach is a user click, so it must wait
// on this session's own compact and nothing else. A blown deadline does not
// cancel that compact — it keeps running and still commits — so the fallback
// costs restore depth for this one reattach, never durable history (STA-4173).
return await this.checkpointQueue.runWithDeadline(
sessionId,
() => this.compactDurableRestoreSnapshot(sessionId, liveSnapshot, scrollbackRows),
DURABLE_HISTORY_OVERLAY_DEADLINE_MS,
liveSnapshot
)
}
private async compactDurableRestoreSnapshot(
sessionId: string,
liveSnapshot: NonNullable<GetSnapshotResult['snapshot']>,
scrollbackRows?: number
): Promise<NonNullable<GetSnapshotResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
// Why compact before reading: an independent take/append races the 5s tick, can
// seq-gap the log, and would remount a stale checkpoint over the live window.
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint.checkpoint === 'committed') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
}
if (checkpoint.checkpoint !== 'committed' || !checkpoint.snapshot) {
return checkpoint.snapshot ?? liveSnapshot
}
if (scrollbackRows === undefined || scrollbackRows >= DAEMON_RESTORE_SCROLLBACK_ROWS) {
return checkpoint.snapshot
}
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (!restoreInfo) {
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot: checkpoint.snapshot,
restoreInfo,
scrollbackRows
})
} catch (error) {
console.warn('[history] durable snapshot overlay failed:', sessionId, error)
return liveSnapshot
}
}
async clearBuffer(id: string): Promise<void> {
await this.client.request('clearScrollback', { sessionId: id })
this.markSessionDirty(id)
@@ -1418,6 +1548,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
if (historyManager.hasWriter(session.sessionId)) {
this.sessionsNeedingFullCheckpoint.add(session.sessionId)
this.sessionsNeedingContinuityCheckpoint.add(session.sessionId)
this.lastFullCheckpointAt.delete(session.sessionId)
this.markSessionDirty(session.sessionId)
}
@@ -1537,6 +1668,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.pausedProducerSessionIds.clear()
this.producerResumesOwedOnReconnect.clear()
this.stopCheckpointTimer()
@@ -1644,6 +1777,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.stopCheckpointTimer()
this.dirtySessionVersions.clear()
this.lastFullCheckpointAt.clear()
this.sessionsNeedingFullCheckpoint.clear()
this.sessionsNeedingLiveCheckpoint.clear()
this.sessionsNeedingContinuityCheckpoint.clear()
this.coldRestoreCache.clear()
this.wslDistrosBySessionId.clear()
this.pausedProducerSessionIds.clear()
@@ -2017,9 +2153,32 @@ export class DaemonPtyAdapter implements IPtyProvider {
return elapsed >= 0 && elapsed < DaemonPtyAdapter.FULL_CHECKPOINT_COOLDOWN_MS
}
private async checkpointSession(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
const run = (): Promise<'done' | 'deferred'> => this.writeSessionCheckpoint(sessionId, opts)
// Why final waits without a deadline: sleep/disconnect needs the last snapshot on disk, and
// deferring there would silently drop what the user left on screen rather than delay it.
if (opts.final) {
return await this.checkpointQueue.run(sessionId, run)
}
// Why 'deferred' is safe: the session stays dirty, so the operation that beat us to the queue
// still commits and the next tick retries this one. Nothing on disk is discarded.
if (this.checkpointQueue.isSaturated(sessionId)) {
return 'deferred'
}
return await this.checkpointQueue.runWithDeadline(
sessionId,
run,
PERIODIC_CHECKPOINT_DEADLINE_MS,
'deferred'
)
}
// Why 'deferred' exists: a full snapshot inside the cooldown is postponed and the session stays dirty for retry;
// skipping append meanwhile keeps the on-disk log a consistent (stale) prefix instead of punching a hole.
private async checkpointSession(
private async writeSessionCheckpoint(
sessionId: string,
opts: { final: boolean; teardown: boolean }
): Promise<'done' | 'deferred'> {
@@ -2038,13 +2197,17 @@ export class DaemonPtyAdapter implements IPtyProvider {
// Why take-with-snapshot not plain getSnapshot: it clears pending records in the same turn as the serialize,
// so a warm reattach won't re-append records the checkpoint already contains (double-replay on cold restore).
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: opts.teardown
teardown: opts.teardown,
forceLiveSnapshot: this.sessionsNeedingLiveCheckpoint.has(sessionId),
requireContinuityProof: this.sessionsNeedingContinuityCheckpoint.has(sessionId)
})
if (checkpoint === 'retryable') {
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return 'done'
}
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
@@ -2059,8 +2222,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
if (checkpoint === 'retryable') {
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
@@ -2083,8 +2249,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, { teardown: false })
if (checkpoint === 'retryable') {
const checkpoint = await this.takeSnapshotAndCheckpoint(sessionId, {
teardown: false,
forceLiveSnapshot: true
})
if (checkpoint.checkpoint === 'retryable') {
this.sessionsNeedingFullCheckpoint.add(sessionId)
return 'deferred'
}
@@ -2094,29 +2263,103 @@ export class DaemonPtyAdapter implements IPtyProvider {
private async takeSnapshotAndCheckpoint(
sessionId: string,
opts: { teardown: boolean }
): Promise<HistoryCheckpointResult> {
opts: {
teardown: boolean
forceLiveSnapshot?: boolean
requireContinuityProof?: boolean
}
): Promise<SnapshotCheckpointResult> {
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
sessionId,
includeSnapshot: true,
teardownSnapshot: opts.teardown
})
if (take?.snapshot && this.historyManager) {
const checkpoint = await this.historyManager.checkpoint(sessionId, take.snapshot)
if (checkpoint !== 'committed') {
// Why require drainedRecords: an older daemon still empties the pending
// queue on includeSnapshot but omits the field. Treating absence as []
// would compact stale disk history and reset the log.
const snapshot =
take.drainedRecords === undefined || opts.forceLiveSnapshot === true || take.overflowed
? take.snapshot
: await this.buildDurableHistorySnapshot(
sessionId,
take.snapshot,
[...take.drainedRecords, ...take.records],
{
pendingRecordsAreComplete: take.seq === 1,
...(opts.requireContinuityProof === true
? { requiredPreviousPendingOutputSeq: take.seq - 1 }
: {})
}
)
const checkpoint = await this.historyManager.checkpoint(sessionId, snapshot, {
pendingOutputSeq: take.seq
})
if (checkpoint === 'retryable') {
// Why take.records is dropped, not appended: the pending output this take drained went into the snapshot that
// failed to land, so appending the held tail at the next contiguous seq would splice it over that hole and
// defeat the log's seq-gap detection. A stale prefix beats an undetectable hole.
return checkpoint
this.sessionsNeedingFullCheckpoint.add(sessionId)
this.sessionsNeedingLiveCheckpoint.add(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
this.markSessionDirty(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
if (checkpoint === 'unavailable') {
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint, snapshot: take.snapshot }
}
this.lastFullCheckpointAt.set(sessionId, Date.now())
if (take.records.length > 0) {
// Why: held parser-state bytes (an incomplete shell-ready marker) aren't in the snapshot; keep them as a post-checkpoint log tail.
if (take.records.length > 0 && snapshot === take.snapshot) {
// Why: live-window fallback still lacks held parser-state bytes; keep them as a post-checkpoint log tail.
await this.historyManager.appendIncrements(sessionId, take.seq, take.records)
}
return 'committed'
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'committed', snapshot }
}
this.sessionsNeedingFullCheckpoint.delete(sessionId)
this.sessionsNeedingLiveCheckpoint.delete(sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(sessionId)
return { checkpoint: 'unavailable', snapshot: take?.snapshot ?? null }
}
private async buildDurableHistorySnapshot(
sessionId: string,
liveSnapshot: NonNullable<TakePendingOutputResult['snapshot']>,
pendingRecords: TakePendingOutputResult['records'],
opts: {
pendingRecordsAreComplete: boolean
requiredPreviousPendingOutputSeq?: number
}
): Promise<NonNullable<TakePendingOutputResult['snapshot']>> {
if (!this.historyReader) {
return liveSnapshot
}
try {
const restoreInfo = await this.historyReader.detectColdRestore(sessionId, {
ignoreCleanEnd: true,
wslDistro: this.wslDistrosBySessionId.get(sessionId)
})
if (
(!restoreInfo && !opts.pendingRecordsAreComplete) ||
(opts.requiredPreviousPendingOutputSeq !== undefined &&
restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq)
) {
console.warn('[history] durable continuity unproven; using live snapshot:', sessionId)
return liveSnapshot
}
return await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo,
pendingRecords
})
} catch (error) {
console.warn('[history] durable history rebuild failed:', sessionId, error)
return liveSnapshot
}
return 'unavailable'
}
// Why: the token read no longer throws, so audit its absence directly after an authenticated drop.
@@ -2264,6 +2507,47 @@ export class DaemonPtyAdapter implements IPtyProvider {
await this.respawnPromise
}
/** Replace a TCC-severed daemon only after its live sessions drain. */
private async replaceSeveredMacTccDaemonBeforeNewPty(): Promise<void> {
// Why no platform gate: getMacDaemonTccAttributionHealth returns 'unknown' off macOS.
if (!this.respawnFn || !this.runtimeDir) {
return
}
const health = await getMacDaemonTccAttributionHealth(
this.runtimeDir,
this.socketPath,
this.tokenPath,
this.packagedAppVersion,
this.protocolVersion
)
if (health !== 'severed') {
return
}
const daemonLiveSessionCount = await this.getDaemonLiveSessionCount()
const liveSessionCount = Math.max(this.activeSessionIds.size, daemonLiveSessionCount ?? 0)
if (daemonLiveSessionCount === null || liveSessionCount > 0) {
console.warn(
daemonLiveSessionCount === null
? '[daemon] macOS TCC attribution severed - preserving daemon because live session state could not be verified'
: `[daemon] macOS TCC attribution severed - preserving daemon because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}; restart from Manage Sessions when ready`
)
return
}
this.fanoutSyntheticExits(-1)
if (!this.respawnPromise) {
this.respawnPromise = this.doRespawn(
'[daemon] macOS TCC attribution severed - respawning daemon under the current app binary',
'severed_tcc_attribution'
).finally(() => {
this.respawnPromise = null
})
}
await this.respawnPromise
}
private async getDaemonLiveSessionCount(): Promise<number | null> {
try {
await this.client.ensureConnected()
@@ -2404,6 +2688,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
}
// Why: an exited session can't be checkpointed again; clearing its pending-full flag prevents a permanent leak.
this.sessionsNeedingFullCheckpoint.delete(event.sessionId)
this.sessionsNeedingLiveCheckpoint.delete(event.sessionId)
this.sessionsNeedingContinuityCheckpoint.delete(event.sessionId)
// Why: a reused sessionId (renderer respawns a persisted ptyId) must not inherit the dead session's snapshot cooldown.
this.lastFullCheckpointAt.delete(event.sessionId)
this.stopCheckpointTimerIfIdle()
+10 -2
View File
@@ -408,7 +408,7 @@ describe('DaemonPtyRouter', () => {
expect(current.confirmForegroundProcess).toHaveBeenCalledWith('current-session')
})
it('preserves older session owners and routes new sessions to v33', async () => {
it('preserves older session owners and routes new sessions to v34', async () => {
const current = createAdapter('current', [], undefined, PROTOCOL_VERSION)
const legacyV30 = createAdapter(
'v30',
@@ -423,26 +423,34 @@ describe('DaemonPtyRouter', () => {
STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION
)
const legacyV32 = createAdapter('v32', ['v32-session'], undefined, 32)
const router = new DaemonPtyRouter({ current, legacy: [legacyV30, legacyV31, legacyV32] })
const legacyV33 = createAdapter('v33', ['v33-session'], undefined, 33)
const router = new DaemonPtyRouter({
current,
legacy: [legacyV30, legacyV31, legacyV32, legacyV33]
})
await router.discoverLegacySessions()
await router.spawn({ sessionId: 'v30-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v31-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v32-session', cols: 80, rows: 24 })
await router.spawn({ sessionId: 'v33-session', cols: 80, rows: 24 })
const fresh = await router.spawn({ cols: 80, rows: 24 })
router.write('v30-session', 'old-v30\n')
router.write('v31-session', 'old-v31\n')
router.write('v32-session', 'old-v32\n')
router.write('v33-session', 'old-v33\n')
router.write(fresh.id, 'new\n')
expect(legacyV30.spawn).toHaveBeenCalledWith({ sessionId: 'v30-session', cols: 80, rows: 24 })
expect(legacyV31.spawn).toHaveBeenCalledWith({ sessionId: 'v31-session', cols: 80, rows: 24 })
expect(legacyV32.spawn).toHaveBeenCalledWith({ sessionId: 'v32-session', cols: 80, rows: 24 })
expect(legacyV33.spawn).toHaveBeenCalledWith({ sessionId: 'v33-session', cols: 80, rows: 24 })
expect(current.spawn).toHaveBeenCalledWith({ cols: 80, rows: 24 })
expect(legacyV30.write).toHaveBeenCalledWith('v30-session', 'old-v30\n')
expect(legacyV31.write).toHaveBeenCalledWith('v31-session', 'old-v31\n')
expect(legacyV32.write).toHaveBeenCalledWith('v32-session', 'old-v32\n')
expect(legacyV33.write).toHaveBeenCalledWith('v33-session', 'old-v33\n')
expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n')
})
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { getDaemonSocketPath } from './daemon-spawner'
import type { DaemonFileLog } from './daemon-file-log'
import { HistoryReader } from './history-reader'
import type { HistoryCheckpointResult } from './terminal-history-manager-options'
import type { SubprocessHandle } from './session'
import type { TerminalSnapshot } from './types'
const REATTACH_BUDGET_MS = 2_000
// Why above DURABLE_HISTORY_OVERLAY_DEADLINE_MS: this asserts the reattach gives up on its own
// stalled checkpoint, so the window must outlast the deadline it is proving.
const DEADLINE_BUDGET_MS = 20_000
function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) => void } {
let onData: ((data: string) => void) | undefined
let onExit: ((code: number) => void) | undefined
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData(callback) {
onData = callback
},
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
emitData(data) {
onData?.(data)
}
}
}
/** Resolves 'timed-out' instead of hanging, so a wedged reattach fails the test rather than the run. */
async function withinBudget<T>(work: Promise<T>, budgetMs: number): Promise<T | 'timed-out'> {
let timer: ReturnType<typeof setTimeout> | undefined
const deadline = new Promise<'timed-out'>((resolve) => {
timer = setTimeout(() => resolve('timed-out'), budgetMs)
})
try {
return await Promise.race([work, deadline])
} finally {
clearTimeout(timer)
}
}
describe('STA-4173 reattach isolation from a stalled checkpoint', () => {
let dir: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
let subprocesses: ReturnType<typeof createMockSubprocess>[]
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-reattach-isolation-'))
subprocesses = []
const log: DaemonFileLog = { log: () => {}, close: () => {} }
server = new DaemonServer({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
log,
spawnSubprocess: () => {
const subprocess = createMockSubprocess()
subprocesses.push(subprocess)
return subprocess
}
})
await server.start()
adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
historyPath: join(dir, 'history')
})
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
/**
* Wedges one session's checkpoint at the history-write layer and reports how many times that
* stall was actually entered, so a swallowed TypeError cannot masquerade as a stall.
*/
async function stallCheckpointFor(stalledSessionId: string): Promise<{
entered: () => number
release: () => void
}> {
const manager = adapter.getHistoryManager()
expect(manager).not.toBeNull()
const original = manager!.checkpoint.bind(manager!)
let entered = 0
let release = (): void => {}
const stalled = new Promise<void>((resolve) => {
release = resolve
})
vi.spyOn(manager!, 'checkpoint').mockImplementation(
async (
sessionId: string,
snapshot: TerminalSnapshot,
opts?: { pendingOutputSeq?: number }
): Promise<HistoryCheckpointResult> => {
if (sessionId !== stalledSessionId) {
return await original(sessionId, snapshot, opts)
}
entered += 1
await stalled
return await original(sessionId, snapshot, opts)
}
)
return { entered: () => entered, release }
}
async function spawnWithOutput(sessionId: string, output: string): Promise<string> {
const { id } = await adapter.spawn({ cols: 80, rows: 24, sessionId, cwd: '/tmp' })
subprocesses.at(-1)!.emitData(output)
return id
}
it('reattaches an unrelated session while another session checkpoint never settles', async () => {
const stalledId = await spawnWithOutput('stalled-session', 'STALLED_OUTPUT\r\n')
const healthyId = await spawnWithOutput('healthy-session', 'HEALTHY_OUTPUT\r\n')
const stall = await stallCheckpointFor(stalledId)
// Why start the stalled reattach first: it is the one that parks a checkpoint, and before the
// per-session queue that parked checkpoint owned the process-wide tail every reattach joined.
const stalledReattach = adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' })
void stalledReattach.catch(() => {})
await vi.waitFor(() => expect(stall.entered()).toBeGreaterThan(0))
const healthyReattach = await withinBudget(
adapter.spawn({ cols: 80, rows: 24, sessionId: healthyId, cwd: '/tmp' }),
REATTACH_BUDGET_MS
)
expect(healthyReattach).not.toBe('timed-out')
expect(healthyReattach).toMatchObject({ isReattach: true })
expect((healthyReattach as { snapshot: string }).snapshot).toContain('HEALTHY_OUTPUT')
// The stall must still be parked, or the healthy reattach proved nothing.
expect(stall.entered()).toBe(1)
stall.release()
await stalledReattach
})
it('falls back to the live window when the session own checkpoint blows the deadline', async () => {
const stalledId = await spawnWithOutput('deadline-session', 'DEADLINE_OUTPUT\r\n')
const stall = await stallCheckpointFor(stalledId)
const reattach = await withinBudget(
adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }),
DEADLINE_BUDGET_MS
)
expect(stall.entered()).toBeGreaterThan(0)
expect(reattach).not.toBe('timed-out')
expect(reattach).toMatchObject({ isReattach: true })
// Degraded to the daemon's live window, not empty: the deadline costs restore depth, not history.
expect((reattach as { snapshot: string }).snapshot).toContain('DEADLINE_OUTPUT')
stall.release()
}, 30_000)
it('still commits the abandoned checkpoint once the history write completes', async () => {
const stalledId = await spawnWithOutput('resumed-session', 'RESUMED_OUTPUT\r\n')
const stall = await stallCheckpointFor(stalledId)
const reattach = await withinBudget(
adapter.spawn({ cols: 80, rows: 24, sessionId: stalledId, cwd: '/tmp' }),
DEADLINE_BUDGET_MS
)
expect(reattach).not.toBe('timed-out')
stall.release()
// Why this is the whole point of not cancelling: the reattach degraded, and the durable write
// the deadline walked away from still lands on disk for the next restore.
await vi.waitFor(async () => {
const restore = await new HistoryReader(join(dir, 'history')).detectColdRestore(stalledId, {
ignoreCleanEnd: true
})
expect(`${restore?.scrollbackAnsi ?? ''}${restore?.snapshotAnsi ?? ''}`).toContain(
'RESUMED_OUTPUT'
)
})
}, 30_000)
})
@@ -0,0 +1,723 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../shared/terminal-scrollback-policy'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { buildDurableCheckpointSnapshot } from './daemon-durable-history-snapshot'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { DAEMON_SESSION_SCROLLBACK_ROWS } from './daemon-session-scrollback-window'
import { getHistorySessionDirName } from './history-paths'
import { getDaemonSocketPath } from './daemon-spawner'
import { HeadlessEmulator } from './headless-emulator'
import { HistoryManager } from './history-manager'
import { HistoryReader } from './history-reader'
import { TerminalHost } from './terminal-host'
import type { DaemonFileLog } from './daemon-file-log'
import type { PendingOutputRecord, TerminalSnapshot } from './types'
import type { SubprocessHandle } from './session'
const PREVIOUSLY_RECOVERABLE_LINE = 'LINE_01000'
const OLDEST_WRITTEN_LINE = 'LINE_00001'
const NEWEST_WRITTEN_LINE = `LINE_${String(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT).padStart(5, '0')}`
const FRESH_AFTER_CHECKPOINT = 'FRESH_AFTER_CHECKPOINT'
function numberedOutput(lineCount: number): string {
let output = ''
for (let index = 1; index <= lineCount; index += 1) {
output += `LINE_${String(index).padStart(5, '0')}\r\n`
}
return output
}
function snapshotText(snapshot: { scrollbackAnsi?: string; snapshotAnsi?: string }): string {
return `${snapshot.scrollbackAnsi ?? ''}${snapshot.snapshotAnsi ?? ''}`
}
function createMockSubprocess(): SubprocessHandle & {
emitData: (data: string) => void
} {
let onData: ((data: string) => void) | undefined
let onExit: ((code: number) => void) | undefined
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
forceKill: vi.fn(() => onExit?.(137)),
signal: vi.fn(),
onData(callback) {
onData = callback
},
onExit(callback) {
onExit = callback
},
dispose: vi.fn(),
emitData(data) {
onData?.(data)
}
}
}
function simulateAdapterCrash(adapter: DaemonPtyAdapter): void {
const internals = adapter as unknown as {
client: { disconnect: () => void }
stopCheckpointTimer: () => void
}
internals.stopCheckpointTimer()
internals.client.disconnect()
}
describe('STA-4091 previously recoverable restore depth', () => {
it('live daemon memory still drops older rows so session count cannot grow unbounded', async () => {
const subprocess = createMockSubprocess()
const host = new TerminalHost({ spawnSubprocess: () => subprocess })
try {
await host.createOrAttach({
sessionId: 'live-window',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
subprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await vi.waitFor(() => {
const snapshot = host.getSnapshot('live-window')
const text = snapshotText(snapshot ?? {})
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).not.toContain(OLDEST_WRITTEN_LINE)
expect(text).not.toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(DAEMON_SESSION_SCROLLBACK_ROWS).toBeLessThan(
DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
)
})
} finally {
await host.dispose()
}
})
describe('durable history', () => {
let dir: string
let manager: HistoryManager
let reader: HistoryReader
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-restore-depth-'))
manager = new HistoryManager(dir)
reader = new HistoryReader(dir)
await manager.openSession('restore-depth', { cwd: '/tmp', cols: 80, rows: 24 })
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('reconstructs the previously recoverable desktop depth from incremental history', async () => {
const records: PendingOutputRecord[] = [
{ kind: 'output', data: numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT) }
]
expect(await manager.appendIncrements('restore-depth', 1, records)).toBe('ok')
const restore = await reader.detectColdRestore('restore-depth')
const text = snapshotText(restore ?? {})
expect(text).toContain(OLDEST_WRITTEN_LINE)
expect(text).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).toContain(NEWEST_WRITTEN_LINE)
})
it('keeps that depth after a production compact of the live daemon window', async () => {
const live = new HeadlessEmulator({
cols: 80,
rows: 24,
scrollback: DAEMON_SESSION_SCROLLBACK_ROWS
})
try {
expect(live.writeSync(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))).toBe(true)
const liveSnapshot = live.getSnapshot()
expect(snapshotText(liveSnapshot)).not.toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(
await manager.appendIncrements('restore-depth', 1, [
{ kind: 'output', data: numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT) }
])
).toBe('ok')
const restoreInfo = await reader.detectColdRestore('restore-depth')
const durable = await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo
})
expect(await manager.checkpoint('restore-depth', durable)).toBe('committed')
const restore = await reader.detectColdRestore('restore-depth')
const text = snapshotText(restore ?? {})
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).toContain(OLDEST_WRITTEN_LINE)
expect(DAEMON_RESTORE_SCROLLBACK_ROWS).toBe(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)
} finally {
live.dispose()
}
})
it('replays pending records into restore depth when disk history is empty', async () => {
const live = new HeadlessEmulator({
cols: 80,
rows: 24,
scrollback: DAEMON_SESSION_SCROLLBACK_ROWS
})
try {
live.writeSync(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const durable = await buildDurableCheckpointSnapshot({
liveSnapshot: live.getSnapshot(),
restoreInfo: null,
pendingRecords: [
{ kind: 'output', data: numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT) }
]
})
expect(snapshotText(durable)).toContain(OLDEST_WRITTEN_LINE)
expect(snapshotText(durable)).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(snapshotText(durable)).toContain(NEWEST_WRITTEN_LINE)
} finally {
live.dispose()
}
})
it('reuses restore info when there are no pending records to replay', async () => {
const live = new HeadlessEmulator({
cols: 80,
rows: 24,
scrollback: DAEMON_SESSION_SCROLLBACK_ROWS
})
try {
expect(
await manager.appendIncrements('restore-depth', 1, [
{ kind: 'output', data: numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT) }
])
).toBe('ok')
const restoreInfo = await reader.detectColdRestore('restore-depth')
live.writeSync(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const durable = await buildDurableCheckpointSnapshot({
liveSnapshot: { ...live.getSnapshot(), outputSequence: 9 },
restoreInfo
})
expect(durable.outputSequence).toBe(9)
expect(durable.scrollbackAnsi).toBe('')
expect(durable.scrollbackLines).toBe(restoreInfo?.scrollbackLines)
expect(snapshotText(durable)).toContain(PREVIOUSLY_RECOVERABLE_LINE)
} finally {
live.dispose()
}
})
it('falls back to the live window when pending resize records are invalid', async () => {
const liveSnapshot = {
snapshotAnsi: 'live-only',
scrollbackAnsi: '',
rehydrateSequences: '',
cwd: '/tmp',
cols: 80,
rows: 24,
scrollbackLines: 0,
modes: {
bracketedPaste: false,
alternateScreen: false,
applicationCursor: false,
mouseTracking: false
},
outputSequence: 4
} as TerminalSnapshot
const durable = await buildDurableCheckpointSnapshot({
liveSnapshot,
restoreInfo: null,
pendingRecords: [{ kind: 'resize', cols: 0, rows: 24 }]
})
expect(durable).toBe(liveSnapshot)
})
})
describe('adapter remount and restart', () => {
let dir: string
let historyDir: string
let server: DaemonServer
let adapter: DaemonPtyAdapter
let lastSubprocess: ReturnType<typeof createMockSubprocess>
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), 'orca-restore-depth-adapter-'))
historyDir = join(dir, 'history')
const log: DaemonFileLog = { log: () => {}, close: () => {} }
server = new DaemonServer({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
log,
spawnSubprocess: () => {
lastSubprocess = createMockSubprocess()
return lastSubprocess
}
})
await server.start()
adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
historyPath: historyDir
})
})
afterEach(async () => {
adapter?.dispose()
await server?.shutdown()
rmSync(dir, { recursive: true, force: true })
})
it('restores the previously recoverable depth on remount snapshot', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'remount-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const snapshot = await adapter.getBufferSnapshot(id)
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).toContain(OLDEST_WRITTEN_LINE)
expect(text.split(OLDEST_WRITTEN_LINE)).toHaveLength(2)
})
it('honors a bounded remount snapshot depth', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'bounded-remount-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 24 })
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).not.toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).not.toContain(OLDEST_WRITTEN_LINE)
})
it('restores that depth after a keepHistory restart compact', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'restart-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.shutdown(id, { immediate: true, keepHistory: true })
const restore = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
const text = snapshotText(restore ?? {})
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(text).toContain(OLDEST_WRITTEN_LINE)
})
it('restores that depth on warm reattach', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'reattach-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const reattach = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: id,
cwd: '/tmp'
})
expect(reattach.isReattach).toBe(true)
expect(reattach.snapshot).toContain(NEWEST_WRITTEN_LINE)
expect(reattach.snapshot).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE)
})
it('preserves durable depth across an adapter reconnect', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'adapter-reconnect-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.disconnectOnly()
adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
historyPath: historyDir
})
const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' })
expect(reattach.isReattach).toBe(true)
expect(reattach.snapshot).toContain(NEWEST_WRITTEN_LINE)
expect(reattach.snapshot).toContain(PREVIOUSLY_RECOVERABLE_LINE)
expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE)
const restore = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restore ?? {})).toContain(OLDEST_WRITTEN_LINE)
})
it('uses the live window when durable history disappears after a prior drain', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-history-after-drain',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const internals = adapter as unknown as { checkpointDirtySessions: () => Promise<void> }
await internals.checkpointDirtySessions()
rmSync(join(historyDir, getHistorySessionDirName(id), 'checkpoint.json'))
lastSubprocess.emitData('TAIL_AFTER_HISTORY_LOSS\r\n')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const snapshot = await adapter.getBufferSnapshot(id)
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).toContain('TAIL_AFTER_HISTORY_LOSS')
expect(text).not.toContain(OLDEST_WRITTEN_LINE)
const restored = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restored ?? {})).toContain(NEWEST_WRITTEN_LINE)
expect(snapshotText(restored ?? {})).toContain('TAIL_AFTER_HISTORY_LOSS')
expect(restored?.scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
expect(warn).toHaveBeenCalledWith(
'[history] durable continuity unproven; using live snapshot:',
id
)
} finally {
warn.mockRestore()
}
})
it('preserves durable depth after an incremental append and adapter crash', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'incremental-crash-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const oldInternals = adapter as unknown as { checkpointDirtySessions: () => Promise<void> }
await oldInternals.checkpointDirtySessions()
const beforeCrash = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(beforeCrash?.pendingOutputSeq).toBe(2)
expect(snapshotText(beforeCrash ?? {})).toContain(OLDEST_WRITTEN_LINE)
simulateAdapterCrash(adapter)
adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
historyPath: historyDir
})
const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' })
expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT)
expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE)
const restored = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restored ?? {})).toContain(OLDEST_WRITTEN_LINE)
})
it('uses the live window when a crashed adapter left a pending-output gap', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'incremental-gap-depth',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const oldInternals = adapter as unknown as {
client: { request: (method: string, params: unknown) => Promise<unknown> }
}
await oldInternals.client.request('takePendingOutput', {
sessionId: id,
includeSnapshot: false
})
simulateAdapterCrash(adapter)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
adapter = new DaemonPtyAdapter({
socketPath: getDaemonSocketPath(dir),
tokenPath: join(dir, 'test.token'),
historyPath: historyDir
})
const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' })
expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT)
expect(reattach.snapshot).not.toContain(OLDEST_WRITTEN_LINE)
const restored = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restored ?? {})).not.toContain(OLDEST_WRITTEN_LINE)
expect(restored?.scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
expect(warn).toHaveBeenCalledWith(
'[history] durable continuity unproven; using live snapshot:',
id
)
})
it('falls back to the live window when durable history cannot be read', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'error-fallback',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
const internals = adapter as unknown as { historyReader: HistoryReader }
vi.spyOn(internals.historyReader, 'detectColdRestore').mockRejectedValue(
new Error('history unreadable')
)
const snapshot = await adapter.getBufferSnapshot(id)
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(NEWEST_WRITTEN_LINE)
expect(text).not.toContain(OLDEST_WRITTEN_LINE)
})
it('reanchors from the live window after pending output overflows', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'overflow-reanchor',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
const overflowLine = `BULK_${'x'.repeat(100)}\r\n`
lastSubprocess.emitData(overflowLine.repeat(20_000))
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const snapshot = await adapter.getBufferSnapshot(id)
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(FRESH_AFTER_CHECKPOINT)
const restore = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restore ?? {})).toContain(FRESH_AFTER_CHECKPOINT)
})
it('reanchors after a retryable durable checkpoint', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'checkpoint-retryable',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const internals = adapter as unknown as {
historyManager: HistoryManager
sessionsNeedingLiveCheckpoint: Set<string>
}
const checkpoint = vi
.spyOn(internals.historyManager, 'checkpoint')
.mockResolvedValueOnce('retryable')
const snapshot = await adapter.getBufferSnapshot(id)
const text = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
expect(text).toContain(FRESH_AFTER_CHECKPOINT)
expect(internals.sessionsNeedingLiveCheckpoint.has(id)).toBe(true)
const recovered = await adapter.getBufferSnapshot(id)
expect(`${recovered?.scrollbackAnsi ?? ''}${recovered?.data ?? ''}`).toContain(
FRESH_AFTER_CHECKPOINT
)
const restore = await new HistoryReader(historyDir).detectColdRestore(id, {
ignoreCleanEnd: true
})
expect(snapshotText(restore ?? {})).toContain(FRESH_AFTER_CHECKPOINT)
expect(checkpoint.mock.calls.at(-1)?.[1].scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
expect(internals.sessionsNeedingLiveCheckpoint.has(id)).toBe(false)
checkpoint.mockResolvedValueOnce('retryable')
const reattach = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: id,
cwd: '/tmp'
})
expect(reattach.isReattach).toBe(true)
expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT)
})
it('stops retrying when durable history is unavailable', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'checkpoint-unavailable',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const internals = adapter as unknown as {
client: { request: (method: string, params?: unknown) => Promise<unknown> }
historyManager: HistoryManager
sessionsNeedingFullCheckpoint: Set<string>
sessionsNeedingLiveCheckpoint: Set<string>
checkpointDirtySessions: () => Promise<void>
}
const requests = vi.spyOn(internals.client, 'request')
const checkpoint = vi
.spyOn(internals.historyManager, 'checkpoint')
.mockResolvedValue('unavailable')
const snapshot = await adapter.getBufferSnapshot(id)
expect(`${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`).toContain(
FRESH_AFTER_CHECKPOINT
)
expect(internals.sessionsNeedingFullCheckpoint.has(id)).toBe(false)
expect(internals.sessionsNeedingLiveCheckpoint.has(id)).toBe(false)
await internals.checkpointDirtySessions()
await internals.checkpointDirtySessions()
const snapshotTakes = requests.mock.calls.filter(
([method, params]) =>
method === 'takePendingOutput' &&
(params as { includeSnapshot?: boolean } | undefined)?.includeSnapshot === true
)
expect(snapshotTakes).toHaveLength(1)
expect(checkpoint).toHaveBeenCalledTimes(1)
})
it('uses the live window when an older daemon omits drained records', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'missing-drained-records',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
const internals = adapter as unknown as {
client: { request: (method: string, params?: unknown) => Promise<unknown> }
historyManager: HistoryManager
}
const request = internals.client.request.bind(internals.client)
vi.spyOn(internals.client, 'request').mockImplementation(async (method, params) => {
const result = await request(method, params)
if (
method === 'takePendingOutput' &&
(params as { includeSnapshot?: boolean } | undefined)?.includeSnapshot &&
result &&
typeof result === 'object'
) {
delete (result as { drainedRecords?: PendingOutputRecord[] }).drainedRecords
}
return result
})
const checkpoint = vi.spyOn(internals.historyManager, 'checkpoint')
const snapshot = await adapter.getBufferSnapshot(id)
expect(`${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`).toContain(
FRESH_AFTER_CHECKPOINT
)
expect(checkpoint.mock.calls.at(-1)?.[1].scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS)
})
it('uses the post-drain sequence for output produced during an overlay', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'overlay-sequence',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
const internals = adapter as unknown as {
client: { request: (method: string, params?: unknown) => Promise<unknown> }
}
const request = internals.client.request.bind(internals.client)
let injected = false
vi.spyOn(internals.client, 'request').mockImplementation(async (method, params) => {
if (
method === 'takePendingOutput' &&
(params as { includeSnapshot?: boolean } | undefined)?.includeSnapshot &&
!injected
) {
injected = true
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
}
return request(method, params)
})
const overlaid = await adapter.getBufferSnapshot(id)
const current = await adapter.getBufferSnapshot(id, { scrollbackRows: 24 })
expect(`${overlaid?.scrollbackAnsi ?? ''}${overlaid?.data ?? ''}`).toContain(
FRESH_AFTER_CHECKPOINT
)
expect(overlaid?.seq).toBe(current?.seq)
})
it('uses the post-drain sequence for output produced during warm reattach', async () => {
const { id } = await adapter.spawn({
cols: 80,
rows: 24,
sessionId: 'reattach-overlay-sequence',
cwd: '/tmp'
})
lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT))
await adapter.getBufferSnapshot(id)
const internals = adapter as unknown as {
client: { request: (method: string, params?: unknown) => Promise<unknown> }
}
const request = internals.client.request.bind(internals.client)
let injected = false
vi.spyOn(internals.client, 'request').mockImplementation(async (method, params) => {
if (
method === 'takePendingOutput' &&
(params as { includeSnapshot?: boolean } | undefined)?.includeSnapshot &&
!injected
) {
injected = true
lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`)
}
return request(method, params)
})
const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' })
const current = await adapter.getBufferSnapshot(id, { scrollbackRows: 24 })
expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT)
expect(reattach.providerSequence?.value).toBe(current?.seq)
})
})
})
@@ -0,0 +1,5 @@
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from '../../shared/terminal-scrollback-policy'
// Why this number: it is the desktop default that rebuilds restored before the live
// daemon window was flattened. Durable history keeps this depth; live RAM stays smaller.
export const DAEMON_RESTORE_SCROLLBACK_ROWS = DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT
@@ -1,10 +1,10 @@
const DAEMON_SESSION_SCROLLBACK_ENV_VAR = 'ORCA_DAEMON_SESSION_SCROLLBACK_ROWS'
// Why a flat window and not full renderer depth: retained grid is the daemon's dominant heap term and
// session count is unbounded — a host owning 100+ terminals at full depth retained ~1 GB of grid and
// was OOM-killed, taking every session it owned with it. A terminal the user has open scrolls its full
// live renderer buffer; the daemon window is only what a REBUILD (reload, remount, restart, remote
// attach) restores, and 1000 rows is the generous end of what terminal products restore there.
// Why a flat live window and not full renderer depth: retained grid is the daemon's dominant heap
// term and session count is unbounded — a host owning 100+ terminals at full depth retained ~1 GB
// of grid and was OOM-killed, taking every session it owned with it. A terminal the user has open
// scrolls its full live renderer buffer. Rebuilds (reload, remount, restart, remote attach) restore
// from durable history at DAEMON_RESTORE_SCROLLBACK_ROWS, not this live RAM window.
export const DAEMON_SESSION_SCROLLBACK_ROWS = 1000
// Why: keep any override within sane terminal bounds — 0 would lose the visible screen's context and
// huge values silently reintroduce the unbounded-retention failure this window exists to prevent.
@@ -0,0 +1,154 @@
import type * as ChildProcessModule from 'node:child_process'
import type * as FsModule from 'node:fs'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from './daemon-protocol-version'
const PS_START = 'Thu Aug 13 12:34:56 2026'
const PS_STARTED_AT_MS = Date.parse(PS_START)
const { execFileMock, execFileSyncMock, psCommandLine, psError } = vi.hoisted(() => ({
execFileMock: vi.fn(
(
_file: string,
_args: readonly string[],
_options: unknown,
callback: (error: Error | null, stdout: string, stderr: string) => void
) => callback(psError.value, psError.value ? '' : `${PS_START} ${psCommandLine.value}\n`, '')
),
execFileSyncMock: vi.fn(() => ''),
psCommandLine: { value: '' },
psError: { value: null as Error | null }
}))
vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal<typeof ChildProcessModule>()),
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof FsModule>()
return {
...actual,
readFileSync: ((path, options) => {
if (String(path) === `/proc/${process.pid}/cmdline`) {
throw new Error('procfs unavailable on macOS')
}
return actual.readFileSync(path, options)
}) as typeof actual.readFileSync
}
})
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
const { getMacDaemonTccAttributionHealth } = await import('./daemon-health')
describe('macOS daemon TCC attribution main-thread cost', () => {
let dir: string
let socketPath: string
let tokenPath: string
let spawnerExecPath: string
beforeAll(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
})
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-tcc-main-thread-test-'))
socketPath = join(dir, 'daemon.sock')
tokenPath = join(dir, 'daemon.token')
spawnerExecPath = join(dir, 'Orca')
writeFileSync(spawnerExecPath, '')
psCommandLine.value = `node daemon-entry --socket ${socketPath} --token ${tokenPath}`
psError.value = null
execFileMock.mockClear()
execFileSyncMock.mockClear()
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
afterAll(() => {
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
})
function writePidRecord(appVersion?: string): void {
writeFileSync(
join(dir, `daemon-v${PROTOCOL_VERSION}.pid`),
JSON.stringify({
pid: process.pid,
startedAtMs: PS_STARTED_AT_MS,
launchNonce: 'launch-a',
...(appVersion === undefined ? {} : { appVersion }),
spawnerExecPath
})
)
}
it('deduplicates identity inspection by daemon generation without a synchronous spawn', async () => {
writePidRecord('1.2.2')
await expect(
Promise.all([
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3'),
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
])
).resolves.toEqual(['severed', 'severed'])
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileMock).toHaveBeenCalledWith(
'ps',
['-p', String(process.pid), '-o', 'lstart=', '-o', 'command='],
expect.anything(),
expect.any(Function)
)
writePidRecord('1.2.3')
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('intact')
expect(execFileMock).toHaveBeenCalledTimes(2)
rmSync(spawnerExecPath)
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileMock).toHaveBeenCalledTimes(3)
})
it('retries an indeterminate identity inspection', async () => {
writePidRecord('1.2.2')
psError.value = new Error('ps unavailable')
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('unknown')
psError.value = null
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(execFileMock).toHaveBeenCalledTimes(2)
})
it('treats a packaged legacy pid record as a previous app generation', async () => {
writePidRecord()
await expect(
getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')
).resolves.toBe('severed')
expect(execFileMock).toHaveBeenCalledTimes(1)
expect(execFileSyncMock).not.toHaveBeenCalled()
})
})
@@ -113,7 +113,7 @@ describe('macOS daemon TCC attribution health', () => {
await withDaemonLikeProcess(async (writePidFile) => {
const spawnerPath = join(dir, 'Orca')
writeFileSync(spawnerPath, '', 'utf8')
writePidFile({ spawnerExecPath: spawnerPath })
writePidFile({ spawnerExecPath: spawnerPath, appVersion: '1.2.3' })
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'intact'
)
@@ -134,11 +134,15 @@ describe('macOS daemon TCC attribution health', () => {
})
})
it('flags legacy records only on a packaged app-version change', async () => {
it('flags missing or changed app-version metadata only for packaged builds', async () => {
if (process.platform !== 'darwin') {
return
}
await withDaemonLikeProcess(async (writePidFile) => {
writePidFile({})
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
'severed'
)
writePidFile({ appVersion: '1.2.2' })
// Updater replaced the bundle since this daemon was forked → attribution is gone.
expect(await getMacDaemonTccAttributionHealth(dir, socketPath, tokenPath, '1.2.3')).toBe(
+9 -4
View File
@@ -225,13 +225,18 @@ export class HistoryManager {
}
// Full checkpoints are rare (clean disconnect, pending-buffer overflow, log cap); the 5s tick appends increments instead.
checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<HistoryCheckpointResult> {
return this.mutations.track(sessionId, this.checkpointUntracked(sessionId, snapshot))
checkpoint(
sessionId: string,
snapshot: TerminalSnapshot,
opts?: { pendingOutputSeq?: number }
): Promise<HistoryCheckpointResult> {
return this.mutations.track(sessionId, this.checkpointUntracked(sessionId, snapshot, opts))
}
private async checkpointUntracked(
sessionId: string,
snapshot: TerminalSnapshot
snapshot: TerminalSnapshot,
opts?: { pendingOutputSeq?: number }
): Promise<HistoryCheckpointResult> {
if (this.disabledSessions.has(sessionId)) {
return 'unavailable'
@@ -244,7 +249,7 @@ export class HistoryManager {
try {
// Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV).
// The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path.
const checkpoint = await writer.checkpoint(snapshot)
const checkpoint = await writer.checkpoint(snapshot, opts)
if (checkpoint.result === 'retryable') {
this.onWriteError?.(sessionId, checkpoint.error)
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { stat } from 'node:fs/promises'
import type { TerminalCheckpointFile } from './types'
import { getHistorySessionDirName } from './history-paths'
import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log'
import { DAEMON_RESTORE_SCROLLBACK_ROWS } from './daemon-restore-scrollback-depth'
import { HeadlessEmulator } from './headless-emulator'
import { PrioritySemaphore } from './priority-semaphore'
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
@@ -280,6 +281,7 @@ export class HistoryReader {
const emulator = new HeadlessEmulator({
cols: checkpoint?.cols ?? meta.cols,
rows: checkpoint?.rows ?? meta.rows,
scrollback: DAEMON_RESTORE_SCROLLBACK_ROWS,
wslDistro
})
const replay = new ColdRestoreReplayWriter(emulator)
@@ -315,9 +317,10 @@ export class HistoryReader {
}
}
const snapshot = emulator.getSnapshot()
const lastBatch = log.batches.at(-1)!
return {
restoreInfo: coldRestoreInfoFromSnapshot(
snapshot,
{ ...snapshot, pendingOutputSeq: lastBatch.seq },
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
meta
),
+14
View File
@@ -500,6 +500,19 @@ describe('Session', () => {
])
})
it('exposes drained output beside an includeSnapshot take without changing records', () => {
createSession()
subprocess.simulateData('kept-for-durable-history\r\n')
const taken = session.takePendingOutput(true)
expect(taken?.records).toEqual([])
expect(taken?.drainedRecords).toEqual([
{ kind: 'output', data: 'kept-for-durable-history\r\n' }
])
expect(taken?.snapshot).toBeTruthy()
})
it('keeps held marker-prefix bytes during live take-with-snapshot', () => {
createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 })
session.write('codex\n')
@@ -510,6 +523,7 @@ describe('Session', () => {
vi.advanceTimersByTime(30)
expect(taken?.records).toEqual([])
expect(taken?.drainedRecords).toEqual([])
expect(taken?.snapshot).toBeTruthy()
expect(session.shellState).toBe('ready' satisfies ShellReadyState)
expect(subprocess.written).toEqual(['codex\n'])
+1
View File
@@ -501,6 +501,7 @@ export class Session {
? [{ kind: 'output', data: releasedHeldBytes }]
: []
: records,
...(includeSnapshot ? { drainedRecords: records } : {}),
seq: this.pendingOutputSeq,
overflowed,
snapshot: includeSnapshot ? this.getSnapshot() : null
+12 -1
View File
@@ -11,6 +11,10 @@ import {
isPowerShellExecutableName
} from '../powershell-osc133-bootstrap'
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
import {
getFishCodexShellLaunchPreflight,
getPosixCodexShellLaunchPreflight
} from '../pty/codex-shell-launch-preflight'
import {
getFishShellReadyInitCommand,
getZshEnvTemplate,
@@ -118,6 +122,7 @@ __orca_restore_agent_teams_path
${getPosixOmpShellWrapper()}
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getPosixCodexShellLaunchPreflight()}
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
# status when the foreground command exits — mirrors the zsh daemon wrapper.
# Without this, bash users (default on most Linux distros) keep a stuck
@@ -230,6 +235,7 @@ if [[ ! -o login ]]; then
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getPosixCodexShellLaunchPreflight()}
fi
__orca_osc133_precmd() {
local exit_code=$?
@@ -285,6 +291,7 @@ __orca_restore_agent_teams_path
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
${getPosixOmpShellWrapper()}
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
${getPosixCodexShellLaunchPreflight()}
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
${getZshFinalZdotdirRestoreBlock()}
`
@@ -398,7 +405,11 @@ function getWrappedShellLaunchConfig(
// Why: mirrors local-pty-shell-ready.ts; markerless fish stays unwrapped.
if (shellName === 'fish' && options.emitReadyMarker) {
return {
args: ['-l', '-C', getFishShellReadyInitCommand(SHELL_READY_MARKER)],
args: [
'-l',
'-C',
`${getFishShellReadyInitCommand(SHELL_READY_MARKER)}\n${getFishCodexShellLaunchPreflight()}`
],
env: { ORCA_SHELL_READY_MARKER: '1' },
supportsReadyMarker: true
}
@@ -5,6 +5,7 @@ import { HeadlessEmulator } from './headless-emulator'
type CheckpointMetadata = {
cwd: string | null
generation: number
pendingOutputSeq?: number
checkpointedAt: string
}
@@ -27,6 +28,9 @@ function checkpointFile(
scrollbackLines: snapshot.scrollbackLines,
...(snapshot.lastTitle ? { lastTitle: snapshot.lastTitle } : {}),
generation: metadata.generation,
...(metadata.pendingOutputSeq !== undefined
? { pendingOutputSeq: metadata.pendingOutputSeq }
: {}),
checkpointedAt: metadata.checkpointedAt
}
}
@@ -43,6 +43,8 @@ function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFi
isTerminalModes(checkpoint.modes) &&
isNonNegativeSafeInteger(checkpoint.scrollbackLines) &&
(checkpoint.generation === undefined || isNonNegativeSafeInteger(checkpoint.generation)) &&
(checkpoint.pendingOutputSeq === undefined ||
isNonNegativeSafeInteger(checkpoint.pendingOutputSeq)) &&
typeof checkpoint.checkpointedAt === 'string' &&
(checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) &&
(checkpoint.pendingEscapeTailAnsi === undefined ||
@@ -10,6 +10,8 @@ export type ColdRestoreInfo = {
cwd: string
cols: number
rows: number
scrollbackLines?: number
pendingOutputSeq?: number
modes: TerminalModes
pendingEscapeTailAnsi?: string
lastTitle?: string
@@ -22,6 +24,8 @@ type RestoredSnapshot = {
rehydrateSequences: string
cols: number
rows: number
scrollbackLines?: number
pendingOutputSeq?: number
modes: TerminalModes
pendingEscapeTailAnsi?: string
lastTitle?: string
@@ -43,6 +47,8 @@ export function coldRestoreInfoFromSnapshot(
cwd: cwd ?? meta.cwd,
cols: snapshot.cols,
rows: snapshot.rows,
scrollbackLines: snapshot.scrollbackLines,
pendingOutputSeq: snapshot.pendingOutputSeq,
modes: snapshot.modes,
...(snapshot.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi }
@@ -59,7 +59,8 @@ export class TerminalHistorySessionWriter {
}
async checkpoint(
snapshot: TerminalSnapshot
snapshot: TerminalSnapshot,
opts?: { pendingOutputSeq?: number }
): Promise<{ result: 'committed' } | { result: 'retryable'; error: Error }> {
// Why: snapshot.cwd is null until OSC-7; preserve meta.json's usable cwd for cold restore.
const effectiveCwd = snapshot.cwd ?? this.readMeta()?.cwd ?? null
@@ -72,6 +73,9 @@ export class TerminalHistorySessionWriter {
{
cwd: effectiveCwd,
generation,
...(opts?.pendingOutputSeq !== undefined
? { pendingOutputSeq: opts.pendingOutputSeq }
: {}),
checkpointedAt: new Date().toISOString()
},
this.checkpointMaxBytes
+3
View File
@@ -282,6 +282,9 @@ export type TakePendingOutputRequest = {
export type TakePendingOutputResult = {
records: PendingOutputRecord[]
/** Drained pending queue. Absent on older daemons. includeSnapshot still
* keeps `records` as held-only so mixed-version adapters do not double-replay. */
drainedRecords?: PendingOutputRecord[]
/** Monotonic per-session batch sequence. The history log stores it so the
* cold-restore reader can detect a lost batch (gap) and discard the log
* instead of replaying a stream with missing bytes. */
+1 -1
View File
@@ -1,4 +1,4 @@
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { isWslShellName } from '../../shared/local-windows-terminal-runtime'
import { getDefaultWslDistro, parseWslPath } from '../wsl'
import { parsePtySessionId } from './pty-session-id'
@@ -0,0 +1,71 @@
import {
getEphemeralVmRecipeResultConnection,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
import type { ProvisionEphemeralVmRuntimeArgs } from './ephemeral-vm-runtime-service'
import {
runEphemeralVmRecipeCleanup,
type EphemeralVmRecipeContext
} from './ephemeral-vm-recipe-runner'
type FailedStart = {
context: EphemeralVmRecipeContext
recipeResult: EphemeralVmRecipeResult
}
export async function cleanupFailedEphemeralVmStart(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<void> {
const cleanupError = await getCleanupError(args, start)
if (cleanupError === null) {
return
}
const now = args.now ?? Date.now()
const connection = getEphemeralVmRecipeResultConnection(start.recipeResult)
upsertEphemeralVmRuntime(args.userDataPath, {
id: start.context.instanceId ?? start.context.recipeId,
recipeId: args.recipe.id,
recipe: args.recipe,
...(args.repoId ? { repoId: args.repoId } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.workspaceId ? { workspaceId: args.workspaceId } : {}),
...(args.workspaceName ? { workspaceName: args.workspaceName } : {}),
status: 'cleanup_failed',
connectionMode: connection.type,
cleanupStatus: args.recipe.destroyDisabled ? 'disabled' : 'failed',
...(args.recipe.destroyDisabled ? { cleanupDisabled: true } : {}),
cleanupLastAttemptAt: now,
cleanupLastError: cleanupError,
createdAt: now,
updatedAt: now,
recipeResult: start.recipeResult
})
}
async function getCleanupError(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<string | null> {
try {
const cleanup = await runEphemeralVmRecipeCleanup({
repoPath: args.repoPath,
recipe: args.recipe,
context: start.context,
recipeResult: start.recipeResult,
signal: args.signal,
onStdout: args.onStdout,
onStderr: args.onStderr
})
if (cleanup.ok && !cleanup.skipped) {
return null
}
return cleanup.ok
? 'Destroy is disabled for this recipe.'
: (cleanup.error ?? 'Destroy failed.')
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
@@ -42,6 +42,42 @@ function nodeCommand(scriptPath: string): string {
}
describe('runEphemeralVmRecipeStart', () => {
it.each([
{ checkoutMode: undefined, expected: 1 },
{ checkoutMode: 'provisioned-root' as const, expected: 2 }
])(
'advertises result schema $expected for checkout mode $checkoutMode',
async ({ checkoutMode, expected }) => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
writeFileSync(
scriptPath,
[
'console.log(JSON.stringify({',
' schemaVersion: Number(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION),',
' ...(process.env.ORCA_RECIPE_RESULT_SCHEMA_VERSION === "2"',
' ? { checkoutMode: "provisioned-root" }',
' : {}),',
` pairingCode: ${JSON.stringify(makePairingCode())},`,
" projectRoot: '/workspace/repo'",
'}))'
].join('\n')
)
const result = await runEphemeralVmRecipeStart({
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode,
create: nodeCommand(scriptPath)
}
})
expect(result).toMatchObject({ ok: true, result: { schemaVersion: expected } })
}
)
it('runs a recipe from the repo root and parses its JSON result', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
@@ -107,6 +143,43 @@ describe('runEphemeralVmRecipeStart', () => {
})
})
it('requires the versioned result handshake for provisioned-root recipes', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
writeFileSync(
scriptPath,
[
'console.log(JSON.stringify({',
' schemaVersion: Number(process.env.RESULT_VERSION),',
' ...(process.env.RESULT_VERSION === "2" ? { checkoutMode: "provisioned-root" } : {}),',
` pairingCode: ${JSON.stringify(makePairingCode())},`,
" projectRoot: '/workspace/repo'",
'}))'
].join('\n')
)
const recipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root' as const,
create: nodeCommand(scriptPath)
}
await expect(
runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '1' } })
).resolves.toMatchObject({
ok: false,
error:
'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".',
recipeResult: { schemaVersion: 1 }
})
await expect(
runEphemeralVmRecipeStart({ repoPath, recipe, env: { RESULT_VERSION: '2' } })
).resolves.toMatchObject({
ok: true,
result: { schemaVersion: 2, checkoutMode: 'provisioned-root' }
})
})
it('returns a process failure when the recipe exits nonzero', async () => {
const repoPath = makeRepo()
const scriptPath = join(repoPath, 'start.js')
+19
View File
@@ -0,0 +1,19 @@
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
import {
getEphemeralVmRecipeResultCheckoutMode,
getEphemeralVmRecipeResultProjectRoot,
type EphemeralVmRecipeResult
} from '../shared/ephemeral-vm-recipes'
export function provisionedRootChangedDuringResume(
previous: EphemeralVmRecipeResult,
resumed: EphemeralVmRecipeResult
): boolean {
if (getEphemeralVmRecipeResultCheckoutMode(previous) !== 'provisioned-root') {
return false
}
return (
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(previous)) !==
normalizeRuntimePathForComparison(getEphemeralVmRecipeResultProjectRoot(resumed))
)
}
@@ -0,0 +1,25 @@
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
export function attachEphemeralVmRuntimeToWorkspace(args: {
userDataPath: string
runtimeId: string
workspaceId: string
}): EphemeralVmRuntimeRecord {
const runtime = listEphemeralVmRuntimes(args.userDataPath).find(
(entry) => entry.id === args.runtimeId
)
if (!runtime) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (['cleanup_pending', 'cleanup_failed', 'cleaned'].includes(runtime.status)) {
throw new Error(`Cannot attach cleaned ephemeral VM runtime: ${args.runtimeId}`)
}
return updateEphemeralVmRuntimeStatus(args.userDataPath, args.runtimeId, {
status: 'running',
workspaceId: args.workspaceId
})
}
+209 -5
View File
@@ -1,12 +1,16 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
import { listEphemeralVmRuntimes } from '../shared/ephemeral-vm-runtime-store'
import {
listEphemeralVmRuntimes,
upsertEphemeralVmRuntime
} from '../shared/ephemeral-vm-runtime-store'
import {
cleanupEphemeralVmRuntime,
provisionEphemeralVmRuntime
provisionEphemeralVmRuntime,
resumeEphemeralVmRuntime
} from './ephemeral-vm-runtime-service'
import type { OrcaVmRecipe } from '../shared/types'
@@ -76,6 +80,7 @@ describe('ephemeral VM runtime service', () => {
' const payload = JSON.parse(input)',
' if (payload.recipeResult.projectRoot !== "/workspace/repo") process.exit(12)',
' if (!payload.recipeResult.userData.providerResourceId) process.exit(13)',
" require('fs').appendFileSync('cleanup-count.txt', 'x')",
' console.error(`cleanup:${payload.instanceId}`)',
'})'
].join('\n')
@@ -118,13 +123,17 @@ describe('ephemeral VM runtime service', () => {
})
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([provisioned.runtime])
const cleanup = await cleanupEphemeralVmRuntime({
const cleanupArgs = {
userDataPath,
repoPath,
recipe,
runtimeId: provisioned.runtime.id,
now: 2_000
})
}
const [cleanup] = await Promise.all([
cleanupEphemeralVmRuntime(cleanupArgs),
cleanupEphemeralVmRuntime(cleanupArgs)
])
expect(cleanup).toMatchObject({
ok: true,
@@ -136,6 +145,13 @@ describe('ephemeral VM runtime service', () => {
cleanupLastAttemptAt: 2_000
}
})
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
await expect(cleanupEphemeralVmRuntime(cleanupArgs)).resolves.toMatchObject({
ok: true,
runtime: { status: 'cleaned' }
})
expect(readFileSync(join(repoPath, 'cleanup-count.txt'), 'utf8')).toBe('x')
})
it('does not persist a runtime when recipe output cannot be parsed', async () => {
@@ -162,4 +178,192 @@ describe('ephemeral VM runtime service', () => {
})
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('destroys a provisioned resource when its checkout handshake is incompatible', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
writeFileSync(cleanupPath, "require('fs').writeFileSync('cleanup-ran.txt', 'yes')")
const provisioned = await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
})
expect(provisioned).toMatchObject({
ok: false,
start: {
error:
'Provisioned-root recipes must return schemaVersion 2 with checkoutMode "provisioned-root".'
}
})
expect(readFileSync(join(repoPath, 'cleanup-ran.txt'), 'utf8')).toBe('yes')
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([])
})
it('persists failed cleanup after an incompatible checkout handshake', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
const cleanupPath = join(repoPath, 'cleanup.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
writeFileSync(cleanupPath, 'process.exit(1)')
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroy: nodeCommand(cleanupPath)
}
const provisioned = await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe,
repoId: 'repo-1',
workspaceName: 'Fix Login Race',
now: 1_000
})
expect(provisioned.ok).toBe(false)
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
recipe,
repoId: 'repo-1',
workspaceName: 'Fix Login Race',
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastAttemptAt: 1_000,
cleanupLastError: expect.any(String)
})
])
})
it('persists incompatible resources when destroy is disabled', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const startPath = join(repoPath, 'start.js')
writeFileSync(
startPath,
`console.log(${JSON.stringify(
JSON.stringify({
schemaVersion: 1,
pairingCode: makePairingCode(),
projectRoot: '/workspace/repo'
})
)})`
)
await provisionEphemeralVmRuntime({
userDataPath,
repoPath,
recipe: {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: nodeCommand(startPath),
destroyDisabled: true
}
})
expect(listEphemeralVmRuntimes(userDataPath)).toEqual([
expect.objectContaining({
status: 'cleanup_failed',
cleanupStatus: 'disabled',
cleanupDisabled: true,
cleanupLastError: 'Destroy is disabled for this recipe.'
})
])
})
it('rejects a provisioned root that moves during resume', async () => {
const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-')
const repoPath = makeDir('orca-ephemeral-vm-service-repo-')
const resumePath = join(repoPath, 'resume.js')
writeFileSync(
resumePath,
[
'console.log(JSON.stringify({',
' schemaVersion: 2,',
' checkoutMode: "provisioned-root",',
' connection: {',
' type: "ssh",',
' projectRoot: "/workspace/moved",',
' target: { label: "VM", host: "host", port: 22, username: "orca" }',
' }',
'}))'
].join('\n')
)
const recipe: OrcaVmRecipe = {
id: 'cloud-sandbox',
name: 'Cloud Sandbox',
checkoutMode: 'provisioned-root',
create: 'unused',
resume: nodeCommand(resumePath),
destroyDisabled: true
}
upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: recipe.id,
recipe,
status: 'suspended',
connectionMode: 'ssh',
cleanupStatus: 'disabled',
cleanupDisabled: true,
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 2,
checkoutMode: 'provisioned-root',
connection: {
type: 'ssh',
projectRoot: '/workspace/original',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
}
})
const resumed = await resumeEphemeralVmRuntime({
userDataPath,
repoPath,
recipe,
runtimeId: 'runtime-1'
})
expect(resumed).toMatchObject({
ok: false,
error: 'The provisioned workspace root changed while the runtime was suspended.',
runtime: {
status: 'resume_failed',
recipeResult: { connection: { projectRoot: '/workspace/original' } }
}
})
})
})
+47 -1
View File
@@ -15,6 +15,8 @@ import {
type EphemeralVmRecipeStartFailure,
type EphemeralVmRecipeStartSuccess
} from './ephemeral-vm-recipe-runner'
import { cleanupFailedEphemeralVmStart } from './ephemeral-vm-failed-start-cleanup'
import { provisionedRootChangedDuringResume } from './ephemeral-vm-resume-integrity'
export type ProvisionEphemeralVmRuntimeArgs = {
userDataPath: string
@@ -92,6 +94,8 @@ export type ResumeEphemeralVmRuntimeResult =
error: string
}
const cleanupInFlight = new Map<string, Promise<CleanupEphemeralVmRuntimeResult>>()
export async function provisionEphemeralVmRuntime(
args: ProvisionEphemeralVmRuntimeArgs
): Promise<ProvisionEphemeralVmRuntimeResult> {
@@ -112,6 +116,12 @@ export async function provisionEphemeralVmRuntime(
onStderr: args.onStderr
})
if (!start.ok) {
if (start.recipeResult) {
await cleanupFailedEphemeralVmStart(args, {
context: start.context,
recipeResult: start.recipeResult
})
}
return { ok: false, start }
}
@@ -137,7 +147,27 @@ export async function provisionEphemeralVmRuntime(
return { ok: true, start, runtime }
}
export async function cleanupEphemeralVmRuntime(
export function cleanupEphemeralVmRuntime(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const key = `${args.userDataPath}\0${args.runtimeId}`
const existing = cleanupInFlight.get(key)
if (existing) {
return existing
}
const cleanup = cleanupEphemeralVmRuntimeOnce(args)
cleanupInFlight.set(key, cleanup)
const forget = (): void => {
if (cleanupInFlight.get(key) === cleanup) {
cleanupInFlight.delete(key)
}
}
void cleanup.then(forget, forget)
return cleanup
}
async function cleanupEphemeralVmRuntimeOnce(
args: CleanupEphemeralVmRuntimeArgs
): Promise<CleanupEphemeralVmRuntimeResult> {
const existing = listEphemeralVmRuntimes(args.userDataPath).find(
@@ -146,6 +176,13 @@ export async function cleanupEphemeralVmRuntime(
if (!existing) {
throw new Error(`Unknown ephemeral VM runtime: ${args.runtimeId}`)
}
if (existing.status === 'cleaned') {
return {
ok: true,
runtime: existing,
skipped: existing.cleanupStatus === 'disabled'
}
}
const now = args.now ?? Date.now()
const running = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
@@ -245,6 +282,15 @@ export async function resumeEphemeralVmRuntime(
return { ok: false, runtime: failed, error: resume.error }
}
if (!resume.skipped && provisionedRootChangedDuringResume(existing.recipeResult, resume.result)) {
const error = 'The provisioned workspace root changed while the runtime was suspended.'
const failed = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'resume_failed',
updatedAt: Date.now()
})
return { ok: false, runtime: failed, error }
}
const runtime = updateEphemeralVmRuntimeStatus(args.userDataPath, existing.id, {
status: 'running',
...(!resume.skipped ? { recipeResult: resume.result } : {}),
@@ -0,0 +1,160 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
import { removeEphemeralVmRuntimeSshTarget } from './ephemeral-vm-runtime-ssh-cleanup'
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
function createRuntime(
userDataPath: string,
status: 'cleaned' | 'cleanup_failed',
overrides: Partial<EphemeralVmRuntimeRecord> = {}
): EphemeralVmRuntimeRecord {
return upsertEphemeralVmRuntime(userDataPath, {
id: 'runtime-1',
recipeId: 'cloud-sandbox',
status,
cleanupStatus: status === 'cleaned' ? 'succeeded' : 'failed',
connectionMode: 'ssh',
sshTargetId: 'runtime-ssh-1',
createdAt: 1,
updatedAt: 1,
recipeResult: {
schemaVersion: 1,
connection: {
type: 'ssh',
projectRoot: '/workspace/repo',
target: { label: 'VM', host: 'host', port: 22, username: 'orca' }
}
},
...overrides
})
}
it('retains the hidden target identity when removal fails', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ssh-cleanup-'))
tempDirs.push(userDataPath)
await expect(
removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime: createRuntime(userDataPath, 'cleanup_failed'),
removeTarget: vi.fn().mockRejectedValue(new Error('store unavailable'))
})
).resolves.toMatchObject({
status: 'cleanup_failed',
sshTargetId: 'runtime-ssh-1',
cleanupLastError: 'Failed to remove the hidden SSH target.'
})
})
it('keeps provider cleanup retryable until target removal succeeds', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ssh-cleanup-'))
tempDirs.push(userDataPath)
const failed = await removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime: createRuntime(userDataPath, 'cleaned'),
removeTarget: vi.fn().mockRejectedValue(new Error('store unavailable'))
})
expect(failed).toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'succeeded',
sshTargetId: 'runtime-ssh-1'
})
await expect(
removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime: failed,
removeTarget: vi.fn().mockResolvedValue(undefined)
})
).resolves.toMatchObject({
status: 'cleaned',
cleanupStatus: 'succeeded',
sshTargetId: undefined
})
})
it('preserves the provider destroy error after target removal', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ssh-cleanup-'))
tempDirs.push(userDataPath)
const runtime = createRuntime(userDataPath, 'cleanup_failed', {
cleanupStatus: 'failed',
cleanupLastError: 'Destroy failed.'
})
await expect(
removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime,
removeTarget: vi.fn().mockResolvedValue(undefined)
})
).resolves.toMatchObject({
status: 'cleanup_failed',
cleanupStatus: 'failed',
cleanupLastError: 'Destroy failed.',
sshTargetId: undefined
})
})
it('does not let a stale concurrent failure regress completed cleanup', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ssh-cleanup-'))
tempDirs.push(userDataPath)
const runtime = createRuntime(userDataPath, 'cleaned')
let rejectStaleRemoval!: (error: Error) => void
const staleRemoval = removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime,
removeTarget: vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectStaleRemoval = reject
})
)
})
await Promise.resolve()
await removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime,
removeTarget: vi.fn().mockResolvedValue(undefined)
})
rejectStaleRemoval(new Error('stale removal failed'))
const cleaned = await staleRemoval
expect(cleaned).toMatchObject({
status: 'cleaned',
cleanupStatus: 'succeeded'
})
expect(cleaned).not.toHaveProperty('sshTargetId')
})
it('repairs completed cleanup records that already lost their target id', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-vm-ssh-cleanup-'))
tempDirs.push(userDataPath)
const runtime = createRuntime(userDataPath, 'cleanup_failed', {
cleanupStatus: 'succeeded',
sshTargetId: undefined
})
await expect(
removeEphemeralVmRuntimeSshTarget({
userDataPath,
runtime,
removeTarget: vi.fn()
})
).resolves.toMatchObject({
status: 'cleaned',
cleanupStatus: 'succeeded',
sshTargetId: undefined
})
})
@@ -0,0 +1,58 @@
import {
listEphemeralVmRuntimes,
updateEphemeralVmRuntimeStatus
} from '../shared/ephemeral-vm-runtime-store'
import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes'
export async function removeEphemeralVmRuntimeSshTarget(args: {
userDataPath: string
runtime: EphemeralVmRuntimeRecord
removeTarget: (targetId: string) => Promise<void>
}): Promise<EphemeralVmRuntimeRecord> {
const current = getCurrentRuntime(args.userDataPath, args.runtime)
if (!current.sshTargetId) {
return finishCompletedCleanup(args.userDataPath, current)
}
try {
await args.removeTarget(current.sshTargetId)
} catch {
const latest = getCurrentRuntime(args.userDataPath, current)
if (!latest.sshTargetId) {
return finishCompletedCleanup(args.userDataPath, latest)
}
return updateEphemeralVmRuntimeStatus(args.userDataPath, latest.id, {
status: 'cleanup_failed',
cleanupLastError: 'Failed to remove the hidden SSH target.'
})
}
const latest = getCurrentRuntime(args.userDataPath, current)
const status = latest.cleanupStatus === 'succeeded' ? 'cleaned' : latest.status
return updateEphemeralVmRuntimeStatus(args.userDataPath, latest.id, {
status,
...(status === 'cleaned' ? { cleanupLastError: null } : {}),
connectionMode: null,
sshTargetId: null
})
}
function getCurrentRuntime(
userDataPath: string,
fallback: EphemeralVmRuntimeRecord
): EphemeralVmRuntimeRecord {
return listEphemeralVmRuntimes(userDataPath).find((entry) => entry.id === fallback.id) ?? fallback
}
function finishCompletedCleanup(
userDataPath: string,
runtime: EphemeralVmRuntimeRecord
): EphemeralVmRuntimeRecord {
if (runtime.cleanupStatus !== 'succeeded' || runtime.status === 'cleaned') {
return runtime
}
return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, {
status: 'cleaned',
cleanupLastError: null,
connectionMode: null,
sshTargetId: null
})
}
+1 -1
View File
@@ -46,7 +46,7 @@ import {
removeSafeUntrackedDiscardTargets
} from '../../shared/git-discard-path-safety'
import { readBranchCompareHead } from '../../shared/git-branch-compare-head'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
import { getLargeDiffRenderLimit } from '../../shared/large-diff-render-limit'
import { InFlightPromiseDedupe, stableInFlightKey } from '../../shared/in-flight-promise-dedupe'
+3 -3
View File
@@ -5,7 +5,7 @@ import {
branchHasNoUnmergedChangesWithLazyTargetRefresh,
getBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { withSpan } from '../observability/tracer'
import { withWorktreeRemoveStageSpan } from '../observability/instrumentation'
import {
@@ -20,8 +20,8 @@ import type {
LocalBaseRefUpdateSuggestion,
RemoveWorktreeResult
} from '../../shared/types'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal'
import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodule-removal'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal'
import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal'
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { parseWslUncPath } from '../../shared/wsl-paths'
+1 -1
View File
@@ -12,7 +12,7 @@
* uses stable field labels ("Token scopes:", "(GITHUB_TOKEN)", etc.).
*/
import { ghExecFileAsync } from '../git/runner'
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github-auth-types'
import type { GhAuthDiagnostic, GhAuthAccount } from '../../shared/github/auth-types'
// Required scopes for ProjectV2 GraphQL access in Orca. `project` is the
// scope that gates ProjectV2 reads/writes; the others are needed for the
@@ -118,7 +118,7 @@ import {
_resetMergeQueueCacheForTests,
_resetOwnerRepoCache
} from './client'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+1 -1
View File
@@ -112,7 +112,7 @@ import {
_resetMergeQueueCacheForTests,
_resetOwnerRepoCache
} from './client'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
+5 -5
View File
@@ -28,10 +28,10 @@ import {
normalizeHostedReviewBaseRef,
normalizeHostedReviewHeadRef
} from '../../shared/hosted-review-refs'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github-pr-merge-methods'
import { normalizeGitHubPRMergeMethodSettings } from '../../shared/github/pull-request-merge-methods'
import { summarizeProviderChecks } from '../../shared/provider-check-summary'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../shared/github-api-availability'
import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github/work-items-query-bounds'
import { classifyGitHubUnavailable } from '../../shared/github/api-availability'
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
@@ -91,7 +91,7 @@ import {
type GitHubRepoExecOptions,
type GitHubApiRepository
} from './github-api-repository'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key'
export { _resetOwnerRepoCache } from './gh-utils'
export {
getIssue,
@@ -127,7 +127,7 @@ import {
import {
GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS,
GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE
} from '../../shared/github-check-details-deadline'
} from '../../shared/github/check-details-deadline'
import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack'
type GhExecOptions = GitHubRepoExecOptions & { signal?: AbortSignal }
+1 -1
View File
@@ -38,7 +38,7 @@ import {
__resetLocalGitConfigSignatureCacheForTests,
readLocalGitConfigSignature
} from './local-git-config-signature'
import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github-work-items-query-bounds'
import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github/work-items-query-bounds'
describe('github owner/repo resolution', () => {
beforeEach(() => {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types'
import {
githubRepoIdentityKey,
isDefaultGitHubHost
} from '../../shared/github-repository-identity-key'
} from '../../shared/github/repository-identity-key'
import {
getOwnerRepoForRemote,
ghRepoExecOptions,
@@ -1,5 +1,5 @@
import type { IssueSourcePreference } from '../../shared/types'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
import { githubRepoIdentityKey } from '../../shared/github/repository-identity-key'
import {
getOwnerRepoForRemote,
type LocalGitExecOptions,

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