mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import type {
|
||||
LinearMcpIssueListRequest,
|
||||
LinearMcpIssueListResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
} from '../../shared/linear/agent-access'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
getOptionalNonNegativeIntegerFlag,
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
LinearMcpIssueListResult,
|
||||
LinearProjectListResult,
|
||||
LinearSearchResult
|
||||
} from '../shared/linear-agent-access'
|
||||
} from '../shared/linear/agent-access'
|
||||
import {
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { LinearSaveIssueRequest } from '../shared/linear-agent-access'
|
||||
import type { LinearSaveIssueRequest } from '../shared/linear/agent-access'
|
||||
import {
|
||||
getOptionalNullableNumberFlag,
|
||||
getOptionalStringFlag,
|
||||
|
||||
@@ -87,7 +87,6 @@ export class RuntimeClient {
|
||||
}
|
||||
: {}
|
||||
const envelope = {
|
||||
expectedRuntimeId: options?.expectedRuntimeId,
|
||||
orchestrationCapability: options?.orchestrationCapability,
|
||||
orchestrationContractVersion: method.startsWith('orchestration.')
|
||||
? ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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
|
||||
),
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -501,6 +501,7 @@ export class Session {
|
||||
? [{ kind: 'output', data: releasedHeldBytes }]
|
||||
: []
|
||||
: records,
|
||||
...(includeSnapshot ? { drainedRecords: records } : {}),
|
||||
seq: this.pendingOutputSeq,
|
||||
overflowed,
|
||||
snapshot: includeSnapshot ? this.getSnapshot() : null
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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' } }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user