mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Merge remote-tracking branch 'origin/main' into fix14974d-work
# Conflicts: # src/main/providers/ssh-pty-provider.test.ts
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComputerActionResult } from '../shared/runtime-types'
|
||||
import { formatComputerAction } from './computer-format'
|
||||
|
||||
describe('formatComputerAction', () => {
|
||||
it('does not treat legacy action results without metadata as completed', () => {
|
||||
const result: ComputerActionResult = {
|
||||
snapshot: {
|
||||
id: 'snap-1',
|
||||
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
|
||||
window: { title: 'Finder', id: 42, width: 800, height: 600 },
|
||||
coordinateSpace: 'window',
|
||||
treeText: 'tree',
|
||||
elementCount: 5,
|
||||
focusedElementId: null
|
||||
},
|
||||
screenshot: null,
|
||||
screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' }
|
||||
}
|
||||
|
||||
const output = formatComputerAction('click', result)
|
||||
|
||||
expect(output).toContain('Click attempted, unverified (verification metadata unavailable)')
|
||||
expect(output).toContain('Inspect with the command above')
|
||||
expect(output).not.toContain('Click completed')
|
||||
})
|
||||
|
||||
it('does not treat accessibility actions without verification as completed', () => {
|
||||
const result: ComputerActionResult = {
|
||||
snapshot: {
|
||||
id: 'snap-1',
|
||||
app: { name: 'Finder', bundleId: 'com.apple.finder', pid: 100 },
|
||||
window: { title: 'Finder', id: 42, width: 800, height: 600 },
|
||||
coordinateSpace: 'window',
|
||||
treeText: 'tree',
|
||||
elementCount: 5,
|
||||
focusedElementId: null
|
||||
},
|
||||
screenshot: null,
|
||||
screenshotStatus: { state: 'skipped', reason: 'no_screenshot_flag' },
|
||||
action: {
|
||||
path: 'accessibility',
|
||||
actionName: 'click',
|
||||
targetWindowId: 42
|
||||
}
|
||||
}
|
||||
|
||||
const output = formatComputerAction('click', result)
|
||||
|
||||
expect(output).toContain(
|
||||
'Click attempted via accessibility, unverified (accessibility action unasserted)'
|
||||
)
|
||||
expect(output).toContain('Inspect with the command above')
|
||||
expect(output).not.toContain('Click completed')
|
||||
})
|
||||
})
|
||||
+12
-16
@@ -207,7 +207,7 @@ export function formatComputerAction(
|
||||
const path = result.action?.path ? ` via ${result.action.path}` : ''
|
||||
const verification = formatActionVerification(result.action)
|
||||
const followUpCommand = formatComputerFollowUpCommand(result, target)
|
||||
const unverified = isUnverifiedComputerAction(result.action)
|
||||
const unverified = result.action?.verification?.state !== 'verified'
|
||||
const outcome = unverified ? 'attempted' : 'completed'
|
||||
const screenshotFailure = formatComputerActionScreenshotFailure(result)
|
||||
const inspectTail = unverified
|
||||
@@ -254,16 +254,19 @@ function formatComputerFollowUpCommand(
|
||||
return args.join(' ')
|
||||
}
|
||||
|
||||
const UNVERIFIED_ACTION_REASONS: Record<ComputerActionMetadata['path'], string> = {
|
||||
accessibility: 'accessibility action unasserted',
|
||||
clipboard: 'clipboard paste',
|
||||
synthetic: 'synthetic input'
|
||||
}
|
||||
|
||||
function formatActionVerification(action: ComputerActionMetadata | undefined): string {
|
||||
const verification = action?.verification
|
||||
if (!action) {
|
||||
return ', unverified (verification metadata unavailable)'
|
||||
}
|
||||
const verification = action.verification
|
||||
if (!verification) {
|
||||
if (action?.path === 'synthetic') {
|
||||
return ', unverified (synthetic input)'
|
||||
}
|
||||
if (action?.path === 'clipboard') {
|
||||
return ', unverified (clipboard paste)'
|
||||
}
|
||||
return ''
|
||||
return `, unverified (${UNVERIFIED_ACTION_REASONS[action.path]})`
|
||||
}
|
||||
if (verification.state === 'verified') {
|
||||
return `, verified ${verification.property}`
|
||||
@@ -271,13 +274,6 @@ function formatActionVerification(action: ComputerActionMetadata | undefined): s
|
||||
return `, unverified (${verification.reason.replaceAll('_', ' ')})`
|
||||
}
|
||||
|
||||
function isUnverifiedComputerAction(action: ComputerActionMetadata | undefined): boolean {
|
||||
if (action?.verification) {
|
||||
return action.verification.state === 'unverified'
|
||||
}
|
||||
return action?.path === 'synthetic' || action?.path === 'clipboard'
|
||||
}
|
||||
|
||||
function formatComputerActionScreenshotFailure(result: ComputerActionResult): string {
|
||||
if (result.screenshotStatus.state !== 'failed') {
|
||||
return ''
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listEnvironmentsMock, resolveEnvironmentMock, getDefaultUserDataPathMock } = vi.hoisted(
|
||||
() => ({
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
resolveEnvironmentMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data')
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: vi.fn(),
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: resolveEnvironmentMock
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', () => ({
|
||||
getDefaultUserDataPath: getDefaultUserDataPathMock
|
||||
}))
|
||||
|
||||
import {
|
||||
hostFilterMatchesHostId,
|
||||
parseHostFlag,
|
||||
resolveHostFlagEnvironmentId
|
||||
} from './execution-host-flag'
|
||||
import { parseExecutionHostId } from '../shared/execution-host'
|
||||
|
||||
const NO_SELECTION = { pairingCode: null, environmentSelector: null }
|
||||
|
||||
function flags(entries: Record<string, string | boolean>): Map<string, string | boolean> {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
function environment(id: string, name = id) {
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
describe('parseHostFlag', () => {
|
||||
it('returns undefined when --host is absent', () => {
|
||||
expect(parseHostFlag(flags({}))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a --host flag with no value', () => {
|
||||
expect(() => parseHostFlag(flags({ host: true }))).toThrow('Missing value for --host')
|
||||
})
|
||||
|
||||
it.each(['runtime:', 'ssh:', 'nonsense'])('rejects the unparseable host id %s', (value) => {
|
||||
expect(() => parseHostFlag(flags({ host: value }))).toThrow(`Invalid --host value: ${value}`)
|
||||
})
|
||||
|
||||
it('parses the three supported host kinds', () => {
|
||||
expect(parseHostFlag(flags({ host: 'local' }))?.kind).toBe('local')
|
||||
expect(parseHostFlag(flags({ host: 'ssh:box-1' }))?.kind).toBe('ssh')
|
||||
expect(parseHostFlag(flags({ host: 'runtime:env-1' }))).toEqual({
|
||||
kind: 'runtime',
|
||||
id: 'runtime:env-1',
|
||||
environmentId: 'env-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHostFlagEnvironmentId', () => {
|
||||
it('keeps the current connection for local and ssh hosts', async () => {
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'local' }), NO_SELECTION)
|
||||
).resolves.toBe(null)
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'ssh:box-1' }), NO_SELECTION)
|
||||
).resolves.toBe(null)
|
||||
expect(listEnvironmentsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a paired runtime host to that environment', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), NO_SELECTION)
|
||||
).resolves.toBe('env-1')
|
||||
})
|
||||
|
||||
it('rejects a runtime host id that no paired environment owns', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-missing' }), NO_SELECTION)
|
||||
).rejects.toThrow('no paired environment has id env-missing')
|
||||
})
|
||||
|
||||
it('matches by environment id only, never by environment name', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
|
||||
).rejects.toThrow('no paired environment has id gpu')
|
||||
})
|
||||
|
||||
it('decodes percent-encoded environment ids', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env one')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env%20one' }), NO_SELECTION)
|
||||
).resolves.toBe('env one')
|
||||
})
|
||||
|
||||
it('refuses a runtime host alongside --pairing-code', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
pairingCode: 'orca://pair?x',
|
||||
environmentSelector: null
|
||||
})
|
||||
).rejects.toThrow('not both')
|
||||
})
|
||||
|
||||
it('refuses a runtime host that disagrees with --environment', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
||||
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'other', label: '--environment' }
|
||||
})
|
||||
).rejects.toThrow('name different Orca servers')
|
||||
})
|
||||
|
||||
it('names the ambient variable when ORCA_ENVIRONMENT is the conflicting selector', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1')])
|
||||
resolveEnvironmentMock.mockReturnValue(environment('env-2'))
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'staging', label: 'ORCA_ENVIRONMENT' }
|
||||
})
|
||||
).rejects.toThrow('ORCA_ENVIRONMENT staging name different Orca servers')
|
||||
})
|
||||
|
||||
it('hands an agent the known environment ids to retry with', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:gpu' }), NO_SELECTION)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
data: { knownEnvironments: [{ id: 'env-1', name: 'gpu' }] }
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts --environment naming the same server as the runtime host', async () => {
|
||||
listEnvironmentsMock.mockReturnValue([environment('env-1', 'gpu')])
|
||||
resolveEnvironmentMock.mockReturnValue(environment('env-1', 'gpu'))
|
||||
|
||||
await expect(
|
||||
resolveHostFlagEnvironmentId(flags({ host: 'runtime:env-1' }), {
|
||||
pairingCode: null,
|
||||
environmentSelector: { value: 'gpu', label: '--environment' }
|
||||
})
|
||||
).resolves.toBe('env-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hostFilterMatchesHostId', () => {
|
||||
const runtimeHost = parseExecutionHostId('runtime:env-1')!
|
||||
const localHost = parseExecutionHostId('local')!
|
||||
const sshHost = parseExecutionHostId('ssh:box-1')!
|
||||
|
||||
it('matches the identical host id', () => {
|
||||
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-1')).toBe(true)
|
||||
expect(hostFilterMatchesHostId(localHost, 'local')).toBe(true)
|
||||
expect(hostFilterMatchesHostId(sshHost, 'ssh:box-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats the routed runtime own local rows as that runtime', () => {
|
||||
expect(hostFilterMatchesHostId(runtimeHost, 'local')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not widen local or ssh filters', () => {
|
||||
expect(hostFilterMatchesHostId(localHost, 'runtime:env-1')).toBe(false)
|
||||
expect(hostFilterMatchesHostId(sshHost, 'local')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match a different runtime host', () => {
|
||||
expect(hostFilterMatchesHostId(runtimeHost, 'runtime:env-2')).toBe(false)
|
||||
expect(hostFilterMatchesHostId(runtimeHost, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ParsedExecutionHost
|
||||
} from '../shared/execution-host'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
|
||||
export type HostFlagRoutingSelection = {
|
||||
pairingCode: string | null
|
||||
// Why: an ambient ORCA_ENVIRONMENT counts as a selection too, so carry the label to
|
||||
// name the real source in the conflict message.
|
||||
environmentSelector: { value: string; label: string } | null
|
||||
}
|
||||
|
||||
export function parseHostFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): ParsedExecutionHost | undefined {
|
||||
if (!flags.has('host')) {
|
||||
return undefined
|
||||
}
|
||||
const raw = flags.get('host')
|
||||
if (typeof raw !== 'string' || raw.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing value for --host')
|
||||
}
|
||||
const parsed = parseExecutionHostId(raw)
|
||||
if (!parsed) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Invalid --host value: ${raw}. Expected local, ssh:<target-id>, or runtime:<environment-id>.`
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// Why: `runtime:<environment-id>` ids are minted by this machine's pairing store, so they
|
||||
// only name a machine relative to it. Filtering or mutating the locally connected runtime
|
||||
// with one answers for the wrong host, and an id that matches nothing is indistinguishable
|
||||
// from a real host with no rows — so resolve the id here and send the command to it.
|
||||
export async function resolveHostFlagEnvironmentId(
|
||||
flags: Map<string, string | boolean>,
|
||||
selection: HostFlagRoutingSelection
|
||||
): Promise<string | null> {
|
||||
const host = parseHostFlag(flags)
|
||||
if (host?.kind !== 'runtime') {
|
||||
return null
|
||||
}
|
||||
const [{ listEnvironments, resolveEnvironment }, { getDefaultUserDataPath }] = await Promise.all([
|
||||
import('./runtime/environments.js'),
|
||||
import('./runtime-client.js')
|
||||
])
|
||||
const userDataPath = getDefaultUserDataPath()
|
||||
const known = listEnvironments(userDataPath)
|
||||
const environment = known.find((candidate) => candidate.id === host.environmentId)
|
||||
if (!environment) {
|
||||
// Why: `runtime:<id>` is a host id that also appears in stored rows, so it resolves by id
|
||||
// only — unlike --environment, which also accepts a name. Say so, and hand back the ids an
|
||||
// agent can retry with instead of making it scrape the sentence.
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown Orca server in --host ${host.id}: no paired environment has id ${host.environmentId}. --host runtime:<id> matches environment ids only, never names. Run \`orca environment list\` to see paired servers, or pass --host local.`,
|
||||
{
|
||||
knownEnvironments: known.map((candidate) => ({ id: candidate.id, name: candidate.name })),
|
||||
nextSteps: [
|
||||
'Run `orca environment list` to see paired Orca servers.',
|
||||
'Use the environment id, not its name: --host runtime:<environment-id>.',
|
||||
'Use --host local to target this machine.'
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
if (selection.pairingCode) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--host ${host.id} already selects a paired Orca server; use either --host runtime:<id> or --pairing-code, not both.`
|
||||
)
|
||||
}
|
||||
if (selection.environmentSelector) {
|
||||
const selected = resolveEnvironment(userDataPath, selection.environmentSelector.value)
|
||||
if (selected.id !== environment.id) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--host ${host.id} and ${selection.environmentSelector.label} ${selection.environmentSelector.value} name different Orca servers.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return environment.id
|
||||
}
|
||||
|
||||
// Why: a runtime stamps its own setups `local` when they were made on the box and
|
||||
// `runtime:<id>` when a paired client made them. Once --host routed the command to that
|
||||
// runtime both spellings mean the same machine, so a host filter has to accept both.
|
||||
export function hostFilterMatchesHostId(
|
||||
filter: ParsedExecutionHost,
|
||||
candidateHostId: string | null | undefined
|
||||
): boolean {
|
||||
const candidate = normalizeExecutionHostId(candidateHostId)
|
||||
if (candidate === filter.id) {
|
||||
return true
|
||||
}
|
||||
return filter.kind === 'runtime' && candidate === LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
@@ -473,7 +473,7 @@ describe('formatComputerAction', () => {
|
||||
)
|
||||
expect(output).toContain('5 visible elements in current window')
|
||||
expect(output).toContain(
|
||||
'Use the --json result or rerun state before choosing the next element index.'
|
||||
'Inspect with the command above or use the --json result before assuming it worked.'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
@@ -39,11 +39,18 @@ describe('absolute file CLI paths', () => {
|
||||
vi.restoreAllMocks()
|
||||
callMock.mockReset()
|
||||
process.exitCode = undefined
|
||||
// Why: a contributor running this suite inside WSL inherits a real
|
||||
// WSL_DISTRO_NAME, which would flip the rewrite on for every case below.
|
||||
vi.stubEnv('WSL_DISTRO_NAME', '')
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('reproduces the issue positional WSL command without invalid_relative_path', async () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('reproduces the issue positional command without invalid_relative_path', async () => {
|
||||
const issuePath = '/root/orca/workspaces/xxx/xxx/xxx.ts'
|
||||
callMock.mockImplementation(async (method: string, params: { relativePath?: string }) => {
|
||||
if (method === 'worktree.list') {
|
||||
@@ -78,6 +85,167 @@ describe('absolute file CLI paths', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WSL UNC worktree roots', () => {
|
||||
const uncRoot = '//wsl.localhost/Ubuntu/root/orca/workspaces/xxx'
|
||||
|
||||
function mockWorktreeShow(rootPath: string): void {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: buildWorktree(rootPath, 'feature') }),
|
||||
okFixture('req_open', {
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'xxx/xxx.ts',
|
||||
kind: 'text',
|
||||
opened: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function expectOpenedWith(relativePath: string): void {
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'files.open', {
|
||||
worktree: 'id:wt-1',
|
||||
relativePath
|
||||
})
|
||||
}
|
||||
|
||||
async function openPath(path: string, cwd = '/tmp'): Promise<void> {
|
||||
await main(['file', 'open', '--path', path, '--worktree', 'id:wt-1'], cwd)
|
||||
}
|
||||
|
||||
it('relativizes a Linux path typed inside the distro against the UNC root', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('relativizes against a legacy backslash wsl$ root', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow('\\\\wsl$\\Ubuntu\\root\\orca\\workspaces\\xxx')
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('does not double-prefix a UNC path the user already typed', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath(`${uncRoot}/xxx/xxx.ts`)
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
// Why: backslash is a legal Linux filename character, but reads as a separator
|
||||
// once the path is UNC — rewriting `a\b.ts` would open `a/b.ts` instead.
|
||||
it('leaves a Linux path containing a backslash for the runtime guard', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/a\\b.ts')
|
||||
|
||||
expectOpenedWith('/root/orca/workspaces/xxx/a\\b.ts')
|
||||
})
|
||||
|
||||
it('leaves a Linux path from another distro for the runtime guard', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Debian')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
})
|
||||
|
||||
// Why: WSL_DISTRO_NAME is also set for a plain Linux CLI inside the distro, where
|
||||
// roots are POSIX; prefixing there would strand every absolute path (#11393 regression).
|
||||
it('keeps relativizing POSIX roots while WSL_DISTRO_NAME is set', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow('/root/orca/workspaces/xxx')
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('does not relativize a sibling directory that merely shares the root prefix', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx-2/xxx.ts')
|
||||
|
||||
expectOpenedWith('/root/orca/workspaces/xxx-2/xxx.ts')
|
||||
})
|
||||
|
||||
// Why: Windows folds the distro segment, so only its case may differ from the root.
|
||||
it('matches when only the distro case differs', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('leaves a Linux path whose own case does not match the root', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/Workspaces/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('/root/orca/Workspaces/xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('leaves a Windows drive-letter workspace alone while WSL_DISTRO_NAME is set', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
mockWorktreeShow('C:\\Users\\me\\repo')
|
||||
|
||||
await openPath('C:/Users/me/repo/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
// Why: WSL_DISTRO_NAME only arrives if interop forwards it, but the launcher
|
||||
// always sets ORCA_CLI_CWD, which reaches the handler as the invocation cwd.
|
||||
it('falls back to the distro named by a UNC cwd', async () => {
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath(
|
||||
'/root/orca/workspaces/xxx/xxx/xxx.ts',
|
||||
'\\\\wsl.localhost\\Ubuntu\\root\\orca\\workspaces\\xxx\\xxx'
|
||||
)
|
||||
|
||||
expectOpenedWith('xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('does not rewrite when the CLI is not running under WSL', async () => {
|
||||
mockWorktreeShow(uncRoot)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
|
||||
expectOpenedWith('/root/orca/workspaces/xxx/xxx/xxx.ts')
|
||||
})
|
||||
|
||||
it('rejects the UNC worktree root itself typed as a Linux path', async () => {
|
||||
vi.stubEnv('WSL_DISTRO_NAME', 'Ubuntu')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: buildWorktree(uncRoot, 'feature') })
|
||||
)
|
||||
|
||||
await openPath('/root/orca/workspaces/xxx')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'The selected worktree root is a directory, not a file-open target.'
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('relativizes absolute file diff paths', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GitStatusEntry, GitStatusResult } from '../../shared/git-status-types'
|
||||
import type { RuntimeFileOpenResult, RuntimeWorktreeRecord } from '../../shared/runtime-types'
|
||||
import { isRuntimePathAbsolute, relativePathInsideRoot } from '../../shared/cross-platform-path'
|
||||
import { isWslUncPath, parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
@@ -46,6 +47,33 @@ async function getFileWorktreeSelector({ flags, cwd, client }: HandlerContext):
|
||||
return await resolveCurrentWorktreeSelector(cwd, client)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: a WSL workspace stores its worktree root as a Windows UNC path while the
|
||||
* user types the Linux path they see inside the distro, so the two never match
|
||||
* (#11393). Only the distro the caller is sitting in can name that Linux path, and
|
||||
* only a UNC root wants the rewrite — a POSIX root already matches, so rewriting
|
||||
* it would strand every absolute path.
|
||||
*
|
||||
* Backslash is a legal Linux filename character but a separator once the path
|
||||
* reads as UNC, so `a\b.ts` would relativize to a different file, `a/b.ts`.
|
||||
* Such a path has no Windows spelling; leave it to fail the match instead.
|
||||
*/
|
||||
function toWorktreeRootPathFlavor(rootPath: string, cwd: string, path: string): string {
|
||||
// Why: WSL_DISTRO_NAME reaches this process only if interop forwards it, but the
|
||||
// WSL launcher always sets ORCA_CLI_CWD, and its UNC form names the distro itself.
|
||||
const distro = process.env.WSL_DISTRO_NAME || parseWslUncPath(cwd)?.distro
|
||||
if (
|
||||
!distro ||
|
||||
!path.startsWith('/') ||
|
||||
path.startsWith('//') ||
|
||||
path.includes('\\') ||
|
||||
!isWslUncPath(rootPath)
|
||||
) {
|
||||
return path
|
||||
}
|
||||
return toWindowsWslPath(path, distro)
|
||||
}
|
||||
|
||||
async function resolveFilePath(
|
||||
ctx: HandlerContext,
|
||||
worktree: string,
|
||||
@@ -58,7 +86,12 @@ async function resolveFilePath(
|
||||
const result = await ctx.client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree
|
||||
})
|
||||
const relativePath = relativePathInsideRoot(result.result.worktree.path, path)
|
||||
|
||||
const rootPath = result.result.worktree.path
|
||||
const relativePath = relativePathInsideRoot(
|
||||
rootPath,
|
||||
toWorktreeRootPathFlavor(rootPath, ctx.cwd, path)
|
||||
)
|
||||
if (relativePath === '') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { printResult } from '../format'
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
async function formatSend(result: unknown): Promise<string> {
|
||||
callMock.mockReset().mockResolvedValueOnce({ result })
|
||||
await ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map([
|
||||
['from', 'term_sender'],
|
||||
['to', 'term_recipient'],
|
||||
['subject', 'ping']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/workspace',
|
||||
json: false
|
||||
} as never)
|
||||
const printCall = vi.mocked(printResult).mock.calls.at(-1)
|
||||
const formatter = printCall?.[2] as (value: unknown) => string
|
||||
return formatter(result)
|
||||
}
|
||||
|
||||
it('prints a live terminal-only delivery limitation', async () => {
|
||||
const line = await formatSend({
|
||||
message: { id: 'msg_1' },
|
||||
warnings: [
|
||||
{
|
||||
code: 'legacy_terminal_recipient',
|
||||
recipient: 'term_recipient',
|
||||
message: 'term_recipient is live now, but its mailbox is not restart-durable.'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(line).toBe(
|
||||
'Sent msg_1\nWarning: term_recipient is live now, but its mailbox is not restart-durable.'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows partial fan-out omissions without hiding delivered recipients', async () => {
|
||||
const line = await formatSend({
|
||||
messages: [{ id: 'msg_1' }],
|
||||
recipients: 1,
|
||||
warnings: [
|
||||
{
|
||||
code: 'recipient_unreachable',
|
||||
recipient: 'term_gone',
|
||||
message: 'Terminal term_gone has no live pane or durable mailbox.'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(line).toBe(
|
||||
'Sent 1 messages to 1 recipients\nWarning: Terminal term_gone has no live pane or durable mailbox.'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints delivery limitations on relayed receipts', async () => {
|
||||
const line = await formatSend({
|
||||
relay: {
|
||||
messageId: 'relay_1',
|
||||
sequence: 1,
|
||||
dispatchId: 'ctx_remote',
|
||||
destination: 'worker',
|
||||
accepted: true
|
||||
},
|
||||
warnings: [
|
||||
{
|
||||
code: 'legacy_terminal_recipient',
|
||||
recipient: 'term_remote',
|
||||
message: 'term_remote is reachable through a compatibility address.'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(line).toBe(
|
||||
'Queued relay_1 for worker Dispatch ctx_remote\nWarning: term_remote is reachable through a compatibility address.'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a canonical receipt unchanged', async () => {
|
||||
await expect(formatSend({ message: { id: 'msg_1' } })).resolves.toBe('Sent msg_1')
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
// `worker-show` text output must keep "nobody is waiting" apart from "nobody looked".
|
||||
import { beforeEach, describe, 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'
|
||||
import { printResult } from '../format'
|
||||
|
||||
function renderedLine(result: unknown): string {
|
||||
const [value, , render] = vi.mocked(printResult).mock.calls[0] as [
|
||||
unknown,
|
||||
boolean,
|
||||
(value: unknown) => string
|
||||
]
|
||||
expect(value).toBe(result)
|
||||
return render(value)
|
||||
}
|
||||
|
||||
async function showWorker(observation: unknown): Promise<string> {
|
||||
const result = {
|
||||
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' },
|
||||
worker: { state: 'ready', stage: 'dispatch_input', agent_terminal_handle: 'term_1' },
|
||||
...(observation === undefined ? {} : { observation })
|
||||
}
|
||||
callMock.mockResolvedValue(result)
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-show']({
|
||||
flags: new Map<string, string | boolean>([['dispatch', 'ctx_1']]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
return renderedLine(result)
|
||||
}
|
||||
|
||||
describe('orchestration worker-show interactive wait output', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
vi.mocked(printResult).mockReset()
|
||||
})
|
||||
|
||||
it('names the prompt when a worker is waiting on a human', async () => {
|
||||
const line = await showWorker({
|
||||
status: 'live',
|
||||
exactWorker: true,
|
||||
agentWait: { source: 'prompt-text', reason: 'agent-approval-prompt', since: 1 }
|
||||
})
|
||||
|
||||
expect(line).toContain('Waiting on a human: agent-approval-prompt (via prompt-text)')
|
||||
})
|
||||
|
||||
it('says none when the pane was evaluated and nothing was waiting', async () => {
|
||||
const line = await showWorker({ status: 'live', exactWorker: true, agentWait: null })
|
||||
|
||||
expect(line).toContain('Interactive wait: none')
|
||||
})
|
||||
|
||||
it('says unknown when the field is absent, not none', async () => {
|
||||
// An older host, or a worker whose identity could not be verified. Printing the same
|
||||
// thing as an evaluated `null` is the false negative this field exists to remove.
|
||||
const line = await showWorker({ status: 'identity_changed', exactWorker: false })
|
||||
|
||||
expect(line).toContain('Interactive wait: unknown (not evaluated)')
|
||||
expect(line).not.toContain('Interactive wait: none')
|
||||
})
|
||||
|
||||
it('says unknown when the host sent no observation at all', async () => {
|
||||
const line = await showWorker(undefined)
|
||||
|
||||
expect(line).toContain('Interactive wait: unknown (not evaluated)')
|
||||
})
|
||||
})
|
||||
@@ -100,9 +100,23 @@ type LifecycleSendResult =
|
||||
| { action: 'settled'; outcome: 'succeeded' | 'failed'; duplicate?: boolean }
|
||||
| { action: 'rejected'; code: string; reason: string }
|
||||
|
||||
type SendRecipientWarning = {
|
||||
code: string
|
||||
recipient: string
|
||||
message: string
|
||||
}
|
||||
|
||||
type OrchestrationSendResult =
|
||||
| { message: { id: string; run_id?: string }; lifecycle?: LifecycleSendResult }
|
||||
| { messages: { id: string }[]; recipients: number }
|
||||
| {
|
||||
message: { id: string; run_id?: string }
|
||||
lifecycle?: LifecycleSendResult
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
| {
|
||||
messages: { id: string }[]
|
||||
recipients: number
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
| {
|
||||
relay: {
|
||||
messageId: string
|
||||
@@ -112,6 +126,7 @@ type OrchestrationSendResult =
|
||||
accepted: true
|
||||
}
|
||||
lifecycle?: LifecycleSendResult
|
||||
warnings?: SendRecipientWarning[]
|
||||
}
|
||||
|
||||
function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' {
|
||||
@@ -598,16 +613,25 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
throw new RuntimeClientError(result.result.lifecycle.code, result.result.lifecycle.reason)
|
||||
}
|
||||
printResult(result, json, (r) => {
|
||||
const warnings = 'warnings' in r ? (r.warnings ?? []) : []
|
||||
const withWarnings = (line: string): string =>
|
||||
warnings.length > 0
|
||||
? [line, ...warnings.map((warning) => `Warning: ${warning.message}`)].join('\n')
|
||||
: line
|
||||
if ('message' in r) {
|
||||
return `Sent ${r.message.id}`
|
||||
return withWarnings(`Sent ${r.message.id}`)
|
||||
}
|
||||
if ('relay' in r) {
|
||||
if (r.relay.destination === 'worker') {
|
||||
return `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}`
|
||||
return withWarnings(
|
||||
`Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}`
|
||||
)
|
||||
}
|
||||
return `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})`
|
||||
return withWarnings(
|
||||
`Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})`
|
||||
)
|
||||
}
|
||||
return `Sent ${r.messages.length} messages to ${r.recipients} recipients`
|
||||
return withWarnings(`Sent ${r.messages.length} messages to ${r.recipients} recipients`)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -915,15 +939,23 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string }
|
||||
worker: { state: string; stage: string; agent_terminal_handle: string | null }
|
||||
observation?: { agentWait?: { source: string; reason?: string } | null }
|
||||
}>('orchestration.workerShow', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
)
|
||||
printResult(result, json, (value) => {
|
||||
const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
// Why a whole line and not a suffix: this is the one state where the lane is healthy
|
||||
// and still needs a person, so it must not read as another status token. An absent
|
||||
// field is unknown, which must not print the same as an evaluated "none".
|
||||
if (value.observation === undefined || !('agentWait' in value.observation)) {
|
||||
return `${base}\nInteractive wait: unknown (not evaluated)`
|
||||
}
|
||||
const wait = value.observation.agentWait
|
||||
return wait
|
||||
? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})`
|
||||
: `${base}\nInteractive wait: none`
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration worker-read': async ({ flags, client, json }) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult
|
||||
} from '../../shared/project-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { RepoKind } from '../../shared/repo-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
@@ -21,10 +22,19 @@ import {
|
||||
formatProjectList,
|
||||
printResult
|
||||
} from '../format'
|
||||
import { hostFilterMatchesHostId, parseHostFlag } from '../execution-host-flag'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function getRequiredHostId(flags: Map<string, string | boolean>): ExecutionHostId {
|
||||
const host = parseHostFlag(flags)
|
||||
if (!host) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --host')
|
||||
}
|
||||
return host.id
|
||||
}
|
||||
|
||||
function getOptionalRepoKind(flags: Map<string, string | boolean>): RepoKind | undefined {
|
||||
const kind = getOptionalStringFlag(flags, 'kind')
|
||||
if (kind === undefined) {
|
||||
@@ -43,12 +53,12 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'project setups': async ({ flags, client, json }) => {
|
||||
const projectFilter = getOptionalStringFlag(flags, 'project')
|
||||
const hostFilter = getOptionalStringFlag(flags, 'host')
|
||||
const hostFilter = parseHostFlag(flags)
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setups = result.result.setups.filter(
|
||||
(setup) =>
|
||||
(projectFilter === undefined || setup.projectId === projectFilter) &&
|
||||
(hostFilter === undefined || setup.hostId === hostFilter)
|
||||
(hostFilter === undefined || hostFilterMatchesHostId(hostFilter, setup.hostId))
|
||||
)
|
||||
printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList)
|
||||
},
|
||||
@@ -56,7 +66,7 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
const rawPath = getRequiredStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupExistingFolderArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'],
|
||||
hostId: getRequiredHostId(flags),
|
||||
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name')
|
||||
@@ -71,7 +81,7 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
const rawDestination = getRequiredStringFlag(flags, 'destination')
|
||||
const args: ProjectHostSetupCloneArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'],
|
||||
hostId: getRequiredHostId(flags),
|
||||
url: getRequiredStringFlag(flags, 'url'),
|
||||
destination: resolveRepoPathArgument(
|
||||
rawDestination,
|
||||
@@ -91,7 +101,7 @@ export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupCreateArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'],
|
||||
hostId: getRequiredHostId(flags),
|
||||
setupId: getOptionalStringFlag(flags, 'setup-id'),
|
||||
path:
|
||||
path === undefined
|
||||
|
||||
@@ -277,8 +277,20 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
},
|
||||
'worktree rm': async ({ flags, client, cwd, json }) => {
|
||||
const worktree = await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
const resolved = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
|
||||
worktree
|
||||
})
|
||||
const hostId = resolved.result.worktree.hostId
|
||||
if (!hostId) {
|
||||
throw new RuntimeClientError(
|
||||
'worktree_host_unresolved',
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
|
||||
)
|
||||
}
|
||||
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
worktree,
|
||||
hostId,
|
||||
force: flags.get('force') === true,
|
||||
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
|
||||
allowUnverifiedPtyStop: flags.get('force') === true,
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('child_process', async () => {
|
||||
|
||||
import { main } from './index'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
@@ -123,6 +123,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
|
||||
it('resolves project and host flags for automation create', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
@@ -180,6 +181,7 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu')
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
|
||||
@@ -43,7 +43,7 @@ vi.mock('child_process', async () => {
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
@@ -103,7 +103,8 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(callMock).toHaveBeenCalledWith('project.list')
|
||||
})
|
||||
|
||||
it('filters project host setups locally after fetching setup compatibility state', async () => {
|
||||
it('routes a runtime host filter to that paired server and keeps its own local rows', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
@@ -142,9 +143,106 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu')
|
||||
expect(callMock).toHaveBeenCalledWith('projectHostSetup.list')
|
||||
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-remote')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-local')
|
||||
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-local')
|
||||
})
|
||||
|
||||
it('keeps --host local a filter on the selected environment rather than a second selector', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'prod')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
setups: [
|
||||
{
|
||||
id: 'setup-on-box',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-on-box',
|
||||
path: '/srv/orca',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'setup-by-client',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:prod',
|
||||
repoId: 'repo-by-client',
|
||||
path: '/srv/orca-2',
|
||||
displayName: 'Orca',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['project', 'setups', '--environment', 'prod', '--host', 'local'], '/tmp/repo')
|
||||
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(undefined, 'prod')
|
||||
expect(logSpy.mock.calls[0]?.[0]).toContain('setup-on-box')
|
||||
expect(logSpy.mock.calls[0]?.[0]).not.toContain('setup-by-client')
|
||||
})
|
||||
|
||||
it('rejects a runtime host id that no paired server owns instead of answering empty', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['project', 'setups', '--host', 'runtime:not-a-real-env', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
const printed = [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
|
||||
expect(printed).toContain('no paired environment has id not-a-real-env')
|
||||
// An agent reads the code and the retry candidates, not the prose.
|
||||
expect(JSON.parse(printed).error.code).toBe('invalid_argument')
|
||||
expect(JSON.parse(printed).error.data.knownEnvironments).toEqual([])
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('rejects a malformed --host value before contacting any runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['project', 'setups', '--host', 'runtime:', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Invalid --host value: runtime:'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('refuses a runtime host id alongside an unrelated --pairing-code connection', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['project', 'setups', '--host', 'runtime:gpu', '--pairing-code', 'remote-runtime', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'use either --host runtime:<id> or --pairing-code, not both'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('sets up an existing project folder with a path resolved against the local cli cwd', async () => {
|
||||
@@ -213,6 +311,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
|
||||
it('rejects remote project setup relative paths instead of resolving against client cwd', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
@@ -227,8 +326,6 @@ describe('orca cli worktree awareness', () => {
|
||||
'runtime:gpu',
|
||||
'--path',
|
||||
'./orca',
|
||||
'--pairing-code',
|
||||
'remote-runtime',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
@@ -377,6 +474,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
|
||||
it('creates independent project host setup metadata through the project-first runtime API', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setup_create', {
|
||||
|
||||
@@ -83,6 +83,31 @@ export async function createChildProcessModuleMock(spawnMock: Mock) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a paired environment so `--host runtime:<id>` routes there instead of being rejected. */
|
||||
export function pairRuntimeEnvironment(listEnvironmentsMock: Mock, id: string, name = id): void {
|
||||
listEnvironmentsMock.mockReturnValue([
|
||||
{
|
||||
id,
|
||||
name,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [
|
||||
{
|
||||
id: `ws-${id}`,
|
||||
kind: 'websocket',
|
||||
label: 'WebSocket',
|
||||
endpoint: 'ws://127.0.0.1:6768',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'pk'
|
||||
}
|
||||
],
|
||||
preferredEndpointId: `ws-${id}`
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
/** Installs the env-var save/restore + mock-reset hooks shared by the CLI worktree-awareness suites. */
|
||||
export function useWorktreeAwarenessEnvironment(mocks: WorktreeAwarenessMocks): void {
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
|
||||
@@ -42,7 +42,7 @@ vi.mock('child_process', async () => {
|
||||
|
||||
import { main } from './index'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
@@ -86,6 +86,7 @@ describe('orca cli worktree awareness', () => {
|
||||
})
|
||||
|
||||
it('resolves project and host flags to the matching repo for worktree.create', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'gpu')
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_project_setups', {
|
||||
@@ -140,6 +141,7 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, 'gpu')
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'projectHostSetup.list')
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-gpu',
|
||||
|
||||
+31
-6
@@ -88,27 +88,52 @@ describe('command aliases dispatch to the canonical handler', () => {
|
||||
})
|
||||
|
||||
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
||||
okFixture('req', { removed: true })
|
||||
)
|
||||
|
||||
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1', force: true })
|
||||
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }),
|
||||
okFixture('req', { removed: true })
|
||||
)
|
||||
|
||||
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1' })
|
||||
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when worktree removal cannot resolve a host', async () => {
|
||||
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
try {
|
||||
await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(callMock).toHaveBeenCalledTimes(1)
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' })
|
||||
} finally {
|
||||
process.exitCode = priorExitCode
|
||||
}
|
||||
})
|
||||
|
||||
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
||||
|
||||
|
||||
+27
-6
@@ -9,6 +9,7 @@ import {
|
||||
validateCommandAndFlags
|
||||
} from './args'
|
||||
import { dispatch } from './dispatch'
|
||||
import { resolveHostFlagEnvironmentId } from './execution-host-flag'
|
||||
import { reportCliError } from './format'
|
||||
import { printHelp } from './help'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
@@ -89,10 +90,34 @@ export async function main(
|
||||
const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath)
|
||||
const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code')
|
||||
const environmentSelector = ignoreRemoteSelection ? null : parsed.flags.get('environment')
|
||||
// Why: --host runtime:<id> names a paired server, not a filter over this
|
||||
// runtime's rows, so it has to pick the connection before the client exists.
|
||||
// An ambient ORCA_ENVIRONMENT is checked for disagreement too — silently
|
||||
// retargeting a mutation to another server is the bug this flag already had.
|
||||
// An ambient pairing code cannot be resolved to an id to compare, so the
|
||||
// explicit flag simply wins there.
|
||||
const hostEnvironmentId = ignoreRemoteSelection
|
||||
? null
|
||||
: await resolveHostFlagEnvironmentId(parsed.flags, {
|
||||
pairingCode: typeof pairingCode === 'string' ? pairingCode : null,
|
||||
environmentSelector:
|
||||
typeof environmentSelector === 'string'
|
||||
? { value: environmentSelector, label: '--environment' }
|
||||
: process.env.ORCA_ENVIRONMENT
|
||||
? { value: process.env.ORCA_ENVIRONMENT, label: 'ORCA_ENVIRONMENT' }
|
||||
: null
|
||||
})
|
||||
// Why: pass `null` (not `undefined`) when remote selection is suppressed
|
||||
// so the RuntimeClient default parameter does not re-activate the
|
||||
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
|
||||
// that must run locally (environment / serve).
|
||||
const suppressed = ignoreRemoteSelection ? null : undefined
|
||||
// An explicit --host runtime:<id> outranks an ambient pairing code or environment.
|
||||
const remotePairingCode =
|
||||
hostEnvironmentId !== null ? null : typeof pairingCode === 'string' ? pairingCode : suppressed
|
||||
const remoteEnvironment =
|
||||
hostEnvironmentId ??
|
||||
(typeof environmentSelector === 'string' ? environmentSelector : suppressed)
|
||||
let client: RuntimeClient | undefined
|
||||
await dispatch(parsed.commandPath, {
|
||||
flags: parsed.flags,
|
||||
@@ -101,12 +126,8 @@ export async function main(
|
||||
client ??= new RuntimeClientClass(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
|
||||
typeof environmentSelector === 'string'
|
||||
? environmentSelector
|
||||
: ignoreRemoteSelection
|
||||
? null
|
||||
: undefined
|
||||
remotePairingCode,
|
||||
remoteEnvironment
|
||||
)
|
||||
return client
|
||||
},
|
||||
|
||||
@@ -48,22 +48,26 @@ describe('RuntimeClient module-graph deferral', () => {
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
// Why: the whole point of the change. These five modules load on EVERY
|
||||
// Why: the whole point of the change. These six modules load on EVERY
|
||||
// invocation, so a value-import of the barrel from any of them drags the
|
||||
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
|
||||
it.each(['args.ts', 'flags.ts', 'dispatch.ts', 'format.ts', 'selectors.ts'])(
|
||||
'%s imports error classes from ./runtime/types, not the barrel',
|
||||
(file) => {
|
||||
const source = readFileSync(join(CLI_DIR, file), 'utf8')
|
||||
const valueImports = source
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'"))
|
||||
for (const line of valueImports) {
|
||||
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
|
||||
}
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
it.each([
|
||||
'args.ts',
|
||||
'flags.ts',
|
||||
'dispatch.ts',
|
||||
'format.ts',
|
||||
'selectors.ts',
|
||||
'execution-host-flag.ts'
|
||||
])('%s imports error classes from ./runtime/types, not the barrel', (file) => {
|
||||
const source = readFileSync(join(CLI_DIR, file), 'utf8')
|
||||
const valueImports = source
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('import ') && line.includes("'./runtime-client'"))
|
||||
for (const line of valueImports) {
|
||||
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
|
||||
}
|
||||
)
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
})
|
||||
|
||||
it('index.ts has no eager value-import of the runtime client', () => {
|
||||
const source = readFileSync(join(CLI_DIR, 'index.ts'), 'utf8')
|
||||
|
||||
@@ -56,6 +56,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
|
||||
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
|
||||
'Use --project with --host, or --project-host-setup, to run on a specific project host setup.',
|
||||
'--host runtime:<environment-id> targets that paired Orca server; use the id from `orca environment list`, not the environment name.',
|
||||
'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.',
|
||||
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.',
|
||||
'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.',
|
||||
|
||||
@@ -111,6 +111,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'By default, Orca records the new worktree as a child of the caller context when it can infer one from the Orca terminal or current directory.',
|
||||
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
|
||||
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
|
||||
'--host runtime:<environment-id> creates on that paired Orca server; use the id from `orca environment list`, not the environment name.',
|
||||
'For related work, use the inferred parent or pass --parent-worktree active, folder:<id>, or worktree:<worktreeId> to make the relationship explicit. Worktree ids are the full <repo-id>::<path> values returned by `orca worktree list --json`.',
|
||||
'Use --no-parent when the new worktree should be independent of the current context.',
|
||||
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.',
|
||||
@@ -124,7 +125,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
examples: [
|
||||
'orca worktree create --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --json',
|
||||
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
|
||||
'orca worktree create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --name benchmark --json',
|
||||
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
|
||||
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name folder-child --parent-worktree folder:<folderId> --json',
|
||||
|
||||
@@ -42,7 +42,11 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-show'],
|
||||
summary: 'Inspect one supervised worker Dispatch',
|
||||
usage: 'orca orchestration worker-show --dispatch <dispatch_id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch']
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch'],
|
||||
notes: [
|
||||
'A Dispatch created by orchestration dispatch is shown as unsupervised and reports the exact adopted terminal when its identity is still provable.',
|
||||
'observation.agentWait names a worker parked on a prompt only a human can answer, with the evidence that proved it (hook, prompt-text, or title). Null means Orca looked and found no wait. An absent field means it never looked — an older host, an unverifiable worker identity, an unreadable pane, or an agent probe that did not answer in time — and never means the worker is not waiting. A waiting worker is healthy, not failed.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'worker-read'],
|
||||
@@ -52,6 +56,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'source', 'cursor', 'limit'],
|
||||
notes: [
|
||||
'The default auto source uses an exact hook-reported transcript when available and otherwise returns labeled terminal output.',
|
||||
'A Dispatch created by orchestration dispatch reads from its adopted terminal with worker status unsupervised.',
|
||||
'A returned cursor is pinned to the exact source; start a fresh read if Orca reports source_changed.'
|
||||
]
|
||||
},
|
||||
@@ -82,6 +87,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
|
||||
notes: [
|
||||
'Post-completion cleanup for a settled (succeeded or failed) worker; closes only the exact coordinator-owned agent terminal of that worker.',
|
||||
'A settled Dispatch created by orchestration dispatch has no owned terminal resource and is reported retained without process action.',
|
||||
'An inspectable output archive is preserved before the terminal closes, so worker-read still returns output afterwards.',
|
||||
'Never closes setup terminals, configured tabs, reused or pre-existing terminals, user-taken-over terminals, or unproven identities.',
|
||||
'Idempotent: repeating the call reports already_released. Only release_unknown exits 1; retained, release_pending, and already_released exit 0.'
|
||||
@@ -95,6 +101,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'],
|
||||
notes: [
|
||||
'Records a durable user-requested exception; a later explicit worker-release clears it and releases the terminal.',
|
||||
'A settled Dispatch created by orchestration dispatch has no owned terminal resource and is reported retained without process action.',
|
||||
'Performs no process or filesystem action.'
|
||||
]
|
||||
},
|
||||
@@ -105,7 +112,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'],
|
||||
notes: [
|
||||
'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.'
|
||||
'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.',
|
||||
'Context-only Dispatches created by orchestration dispatch are included as unsupervised with terminal state retained.'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,11 +14,17 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
summary: 'List project host setups',
|
||||
usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'],
|
||||
notes: ['A setup means a project is available on a host at a concrete filesystem path.'],
|
||||
notes: [
|
||||
'A setup means a project is available on a host at a concrete filesystem path.',
|
||||
'--host runtime:<environment-id> runs the command on that paired Orca server instead of filtering this runtime; unknown environment ids are rejected rather than answered with an empty list.',
|
||||
'Run `orca environment list` to see the environment ids that runtime:<environment-id> accepts. It matches ids only, never environment names.',
|
||||
"A routed --host runtime:<id> also lists that server's own local-stamped setups, because both spellings name the machine the command reached."
|
||||
],
|
||||
examples: [
|
||||
'orca project setups',
|
||||
'orca project setups --project github:stablyai/orca',
|
||||
'orca project setups --host local'
|
||||
'orca project setups --host local',
|
||||
'orca project setups --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -29,11 +35,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
|
||||
notes: [
|
||||
'For remote runtimes, --path must be an absolute path on the remote server.',
|
||||
'--host runtime:<environment-id> targets that paired Orca server; use the id from `orca environment list`, not the environment name.',
|
||||
'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --path /home/me/orca --kind git --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -44,11 +51,12 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'],
|
||||
notes: [
|
||||
'For remote runtimes, --destination must be an absolute parent directory on the remote server.',
|
||||
'--host runtime:<environment-id> targets that paired Orca server; use the id from `orca environment list`, not the environment name.',
|
||||
'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src',
|
||||
'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json'
|
||||
'orca project setup-clone --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --url https://github.com/stablyai/orca.git --destination /srv --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -71,10 +79,11 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'Creates setup metadata without registering a repo compatibility record.',
|
||||
'--host runtime:<environment-id> targets that paired Orca server; use the id from `orca environment list`, not the environment name.',
|
||||
'Use setup-existing-folder when Orca should import and manage an actual checkout path now.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json'
|
||||
'orca project setup-create --project github:stablyai/orca --host runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3 --state setting-up --method provisioned --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,10 +113,23 @@ export function formatTerminalShow(result: { terminal: RuntimeTerminalShow }): s
|
||||
`ptyId: ${terminal.ptyId ?? 'none'}`,
|
||||
`connected: ${terminal.connected}`,
|
||||
`writable: ${terminal.writable}`,
|
||||
// Why listed above the preview: the preview is where a reader would otherwise have to
|
||||
// spot the prompt by eye, which is the work this line exists to remove.
|
||||
`agentWait: ${formatAgentWait(terminal.agentWait)}`,
|
||||
`preview: ${terminal.preview || '<empty>'}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatAgentWait(agentWait: RuntimeTerminalShow['agentWait']): string {
|
||||
if (agentWait === undefined) {
|
||||
return 'unknown (not evaluated)'
|
||||
}
|
||||
if (!agentWait) {
|
||||
return 'none'
|
||||
}
|
||||
return `${agentWait.reason ?? 'interactive prompt'} (via ${agentWait.source})`
|
||||
}
|
||||
|
||||
export function formatTerminalRead(result: { terminal: RuntimeTerminalRead }): string {
|
||||
const terminal = result.terminal
|
||||
const oldestCursor =
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { normalizeExecutionHostId, type ParsedExecutionHost } from '../shared/execution-host'
|
||||
import type { ProjectHostSetup } from '../shared/project-types'
|
||||
import { hostFilterMatchesHostId, parseHostFlag } from './execution-host-flag'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
@@ -48,34 +50,44 @@ export async function resolveProjectCreateRepoSelector(
|
||||
return (await resolveProjectCreateTarget(flags, client))?.repoSelector
|
||||
}
|
||||
|
||||
// Why: the routed runtime can hold both an exact `runtime:<id>` row and a `local` row for the
|
||||
// same project; the exact stamp is the one the caller named, so it wins the selection.
|
||||
function findReadySetupOnHost(
|
||||
setups: readonly ProjectHostSetup[],
|
||||
projectId: string | undefined,
|
||||
host: ParsedExecutionHost | undefined
|
||||
): ProjectHostSetup | undefined {
|
||||
const candidates = setups.filter((candidate) => candidate.projectId === projectId)
|
||||
if (!host) {
|
||||
return candidates[0]
|
||||
}
|
||||
return (
|
||||
candidates.find((candidate) => normalizeExecutionHostId(candidate.hostId) === host.id) ??
|
||||
candidates.find((candidate) => hostFilterMatchesHostId(host, candidate.hostId))
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveProjectCreateTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<ProjectCreateTarget | undefined> {
|
||||
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
|
||||
const projectId = getPresentStringFlag(flags, 'project')
|
||||
const hostId = getPresentStringFlag(flags, 'host')
|
||||
if (!projectHostSetupId && !projectId && !hostId) {
|
||||
const host = parseHostFlag(flags)
|
||||
if (!projectHostSetupId && !projectId && !host) {
|
||||
return undefined
|
||||
}
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setup = result.result.setups.find((candidate) => {
|
||||
if (candidate.setupState !== 'ready') {
|
||||
return false
|
||||
}
|
||||
if (projectHostSetupId) {
|
||||
return candidate.id === projectHostSetupId
|
||||
}
|
||||
return (
|
||||
candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId)
|
||||
)
|
||||
})
|
||||
const ready = result.result.setups.filter((candidate) => candidate.setupState === 'ready')
|
||||
const setup = projectHostSetupId
|
||||
? ready.find((candidate) => candidate.id === projectHostSetupId)
|
||||
: findReadySetupOnHost(ready, projectId, host)
|
||||
if (!setup) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
projectHostSetupId
|
||||
? `Project host setup is not ready or was not found: ${projectHostSetupId}`
|
||||
: `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}`
|
||||
: `Project is not set up on the selected host: ${projectId}${host ? ` on ${host.id}` : ''}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Shell wrapper snapshots
|
||||
|
||||
Generated test fixtures. **Do not edit by hand.**
|
||||
|
||||
Each `.txt` here is the byte-exact content of one shell startup file Orca writes
|
||||
into a pane's wrapper `ZDOTDIR`, captured per transport:
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| `local-*` | local PTY |
|
||||
| `daemon-*` | daemon / SSH host |
|
||||
| `relay-*` | relay overlay |
|
||||
|
||||
Owned by [`../../shell-wrapper-generated-file-snapshot.test.ts`](../../shell-wrapper-generated-file-snapshot.test.ts),
|
||||
which drives the real wrapper entry points, reads the files back off disk and
|
||||
compares them here. The temp wrapper root is normalized to `<WRAPPER_ROOT>` —
|
||||
the only path-dependent bytes in the output.
|
||||
|
||||
## Why these exist
|
||||
|
||||
The three zsh generators were once copy-pasted and drifted, so a fix landed in
|
||||
one transport and silently missed the other two. These pin all three at once, so
|
||||
drift shows up as a reviewable shell diff instead of hiding in a TypeScript
|
||||
template literal.
|
||||
|
||||
## Regenerating
|
||||
|
||||
Run the test and review the resulting diff:
|
||||
|
||||
```sh
|
||||
npx vitest run --config config/vitest.config.ts src/main/shell-wrapper-generated-file-snapshot.test.ts
|
||||
```
|
||||
|
||||
⚠️ `toMatchFileSnapshot` **rewrites these files on mismatch when run locally** —
|
||||
only CI fails. So a passing local run proves nothing on its own: always
|
||||
`git diff` this directory afterwards, and treat any change as a real behavior
|
||||
change to what users' shells execute until you have explained it.
|
||||
@@ -0,0 +1,351 @@
|
||||
# Orca daemon bash shell-ready wrapper
|
||||
_orca_shell_features=",${ORCA_SHELL_FEATURES:-},"
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
# Why a plain variable: the channel is consumed and destroyed in these first
|
||||
# lines, so nothing this shell later spawns can see or inherit the selection.
|
||||
__orca_ready_marker=""
|
||||
__orca_has_feature ready && __orca_ready_marker=1
|
||||
unset _orca_shell_features
|
||||
unset -f __orca_has_feature
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
source "$HOME/.bash_login"
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
|
||||
# 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
|
||||
# 'working' spinner after the CLI exits without a Stop/SessionEnd hook.
|
||||
__orca_initializing_wrapper=1
|
||||
__orca_osc133_precmd() {
|
||||
local exit_code=$?
|
||||
__orca_in_prompt_command=1
|
||||
if [[ -n "${__orca_in_command:-}" ]]; then
|
||||
printf "\033]133;D;%s\007" "$exit_code"
|
||||
unset __orca_in_command
|
||||
fi
|
||||
printf "\033]133;A\007"
|
||||
# Why: emit the shell-ready marker here (not a trailing PROMPT_COMMAND entry)
|
||||
# so a framework that must be last in PROMPT_COMMAND — bash-preexec — is not
|
||||
# displaced by one of Orca's own hooks.
|
||||
[[ -n "$__orca_ready_marker" ]] && printf "\033]777;orca-shell-ready\007"
|
||||
return "$exit_code"
|
||||
}
|
||||
__orca_osc133_preexec() {
|
||||
if [[ -n "${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then
|
||||
unset __orca_initial_prompt
|
||||
__orca_in_legacy_prompt_wrapper=1
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}${__orca_initial_prompt:-}${__orca_in_prompt_dispatch:-}${__orca_in_legacy_prompt_wrapper:-}${__orca_in_prompt_command:-}" ]]; then
|
||||
[[ -z "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}" ]] || return 0
|
||||
if [[ -n "${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then
|
||||
unset __orca_initial_prompt; return 0
|
||||
fi
|
||||
if [[ -n "${__orca_in_prompt_dispatch:-}" ]]; then
|
||||
[[ -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then
|
||||
case "$BASH_COMMAND" in
|
||||
'(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_epilogue) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
elif [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -z "${__orca_in_legacy_prompt_wrapper:-}" || -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ -n "${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "${FUNCNAME[1]:-}" in
|
||||
__orca_osc133_*|__orca_restore_prompt_status|__bp_*) return 0 ;;
|
||||
esac
|
||||
case "$BASH_COMMAND" in
|
||||
__orca_osc133_precmd|__orca_osc133_epilogue) return 0 ;;
|
||||
# The prefix is only special while bash-preexec prompt hooks run.
|
||||
__bp_*) [[ -n "${__orca_in_prompt_command:-}" ]] && return 0 ;;
|
||||
esac
|
||||
__orca_run_user_debug_trap
|
||||
# Why: a framework (bash-preexec/starship) may replace our DEBUG trap at the
|
||||
# first prompt; __orca_osc133_epilogue re-takes it each prompt and stores the
|
||||
# framework's trap here, so the framework's own preexec still runs while our
|
||||
# command-start C survives its re-arm.
|
||||
if [[ -n "${__orca_chained_debug_trap:-}" ]]; then
|
||||
eval "$__orca_chained_debug_trap" || true
|
||||
fi
|
||||
[[ -z "${__orca_in_prompt_command:-}" ]] || return 0
|
||||
# Why: a chained trap can invoke us more than once for a single command, so
|
||||
# emit C only on the first fire (the __orca_in_command gate), and never for a
|
||||
# prompt-time hook — ours or bash-preexec's __bp_* helpers.
|
||||
[[ -z "${__orca_in_command:-}" ]] || return 0
|
||||
printf "\033]133;C\007"
|
||||
__orca_in_command=1
|
||||
}
|
||||
# Why: adopt the latest user trap before Orca retakes lifecycle ownership.
|
||||
__orca_osc133_epilogue() {
|
||||
unset __orca_in_prompt_command
|
||||
__orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
}
|
||||
__orca_normalize_prompt_command_part() {
|
||||
local __orca_value="$1" __orca_output_name="$2" __orca_character __orca_chunk
|
||||
local __orca_value_length=${#1} __orca_suffix_length=0 __orca_backslash_length=0
|
||||
local __orca_output_length __orca_scan_start
|
||||
while (( __orca_value_length - __orca_suffix_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_value_length - __orca_suffix_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!$' \t\n;']*) break ;;
|
||||
*) __orca_suffix_length=$(( __orca_suffix_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_suffix_length < __orca_value_length )); do
|
||||
__orca_character="${__orca_value: -__orca_suffix_length - 1:1}"
|
||||
case "$__orca_character" in
|
||||
' '|$'\t'|$'\n'|';') __orca_suffix_length=$(( __orca_suffix_length + 1 )) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
while (( __orca_output_length - __orca_backslash_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_output_length - __orca_backslash_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!\\]*) break ;;
|
||||
*) __orca_backslash_length=$(( __orca_backslash_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_backslash_length < __orca_output_length )); do
|
||||
__orca_character="${__orca_value:__orca_output_length - __orca_backslash_length - 1:1}"
|
||||
[[ "$__orca_character" == '\' ]] || break
|
||||
__orca_backslash_length=$(( __orca_backslash_length + 1 ))
|
||||
done
|
||||
# Preserve the first separator when an odd backslash run escapes it.
|
||||
if (( __orca_suffix_length > 0 && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_suffix_length=$(( __orca_suffix_length - 1 ))
|
||||
__orca_backslash_length=0
|
||||
fi
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
__orca_value="${__orca_value:0:__orca_output_length}"
|
||||
# Bash 4.4-5.0 scalar prompt evaluation preserves an odd terminal backslash.
|
||||
if (( __orca_suffix_length == 0 && ((BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 0)) && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_value="$__orca_value\\"
|
||||
fi
|
||||
printf -v "$__orca_output_name" '%s' "$__orca_value"
|
||||
}
|
||||
__orca_restore_prompt_status() {
|
||||
return "$1"
|
||||
}
|
||||
__orca_update_user_debug_trap() {
|
||||
local __orca_debug_trap_spec="$1" __orca_unchanged_debug_trap_spec="$2"
|
||||
local __orca_debug_trap_command
|
||||
[[ "$__orca_debug_trap_spec" != "$__orca_unchanged_debug_trap_spec" ]] || return 0
|
||||
[[ "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]] || return 0
|
||||
if [[ -z "$__orca_debug_trap_spec" ]]; then
|
||||
__orca_user_debug_trap=""
|
||||
unset __orca_chained_debug_trap
|
||||
return 0
|
||||
fi
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
unset __orca_chained_debug_trap
|
||||
}
|
||||
__orca_run_user_debug_trap() {
|
||||
if [[ -n "${__orca_user_debug_trap:-}" ]]; then
|
||||
eval "$__orca_user_debug_trap" || true
|
||||
fi
|
||||
}
|
||||
__orca_adopt_outer_debug_trap() {
|
||||
local __orca_debug_trap_spec="${__orca_outer_debug_trap_spec:-}"
|
||||
unset __orca_outer_debug_trap_spec
|
||||
__orca_update_user_debug_trap "$__orca_debug_trap_spec" "trap -- '__orca_osc133_preexec' DEBUG"
|
||||
}
|
||||
__orca_run_prompt_command_array() {
|
||||
local __orca_exit_code="${__orca_prompt_status:-$?}" __orca_prompt_part __orca_prompt_index __orca_user_count
|
||||
local __orca_suffix_part
|
||||
local __orca_final_prompt_command
|
||||
local __orca_in_prompt_dispatch=1 __orca_dispatching_user_prompt_command=""
|
||||
unset __orca_prompt_status
|
||||
__orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
for __orca_prompt_part in "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
__orca_user_count=0
|
||||
for __orca_prompt_part in "${__orca_prompt_command_array[@]+"${__orca_prompt_command_array[@]}"}"; do
|
||||
__orca_user_count=$(( __orca_user_count + 1 ))
|
||||
done
|
||||
for (( __orca_prompt_index = 0; __orca_prompt_index + 1 < __orca_user_count; __orca_prompt_index++ )); do
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_prompt_index]}"
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
done
|
||||
if (( __orca_user_count > 0 )); then
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_user_count - 1]}"
|
||||
# Why: keep the final user hook and Orca suffixes in one status-preserving eval.
|
||||
__orca_final_prompt_command='eval "$__orca_prompt_part"'
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
__orca_final_prompt_command+=$'\n'"$__orca_suffix_part"
|
||||
done
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_final_prompt_command"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_final_prompt_command"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
else
|
||||
for __orca_prompt_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return "$__orca_exit_code"
|
||||
}
|
||||
__orca_finish_legacy_prompt_dispatch() {
|
||||
local __orca_suffix_part
|
||||
if [[ -n "${__orca_in_prompt_command:-}" ]]; then
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
eval "$__orca_suffix_part"
|
||||
done
|
||||
fi
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_in_legacy_prompt_wrapper
|
||||
}
|
||||
__orca_normalize_prompt_command() {
|
||||
[[ -z "${__orca_prompt_command_normalized:-}" ]] || return 0
|
||||
local __orca_prompt_part
|
||||
local -a __orca_normalized=()
|
||||
for __orca_prompt_part in "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}"; do
|
||||
__orca_normalize_prompt_command_part "$__orca_prompt_part" __orca_prompt_part
|
||||
[[ -n "$__orca_prompt_part" ]] && __orca_normalized+=("$__orca_prompt_part")
|
||||
done
|
||||
__orca_prompt_command_normalized=1
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_array=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
__orca_prompt_command_prefix=()
|
||||
__orca_prompt_command_suffix=()
|
||||
unset PROMPT_COMMAND
|
||||
# Why: PID scope distinguishes legacy prompt dispatch from ordinary user command text.
|
||||
__orca_prompt_status_variable="__orca_prompt_status_$$"
|
||||
__orca_prompt_status_capture_command="$__orca_prompt_status_variable=\$?"
|
||||
__orca_prompt_status_value="\${$__orca_prompt_status_variable}"
|
||||
PROMPT_COMMAND="$__orca_prompt_status_capture_command; __orca_prompt_status=$__orca_prompt_status_value"'; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace; __orca_run_prompt_command_array; __orca_finish_legacy_prompt_dispatch'
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("$command" "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_prefix=("$command" "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}")
|
||||
fi
|
||||
}
|
||||
__orca_append_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND+=("$command")
|
||||
else
|
||||
__orca_prompt_command_suffix+=("$command")
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command "__orca_osc133_precmd"
|
||||
__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture'
|
||||
__orca_append_prompt_command "__orca_osc133_epilogue"
|
||||
__orca_had_functrace=""
|
||||
[[ -o functrace ]] && __orca_had_functrace=1
|
||||
set +T
|
||||
__orca_debug_trap_spec="$(trap -p DEBUG)"
|
||||
[[ -z "$__orca_had_functrace" ]] || set -T
|
||||
if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
fi
|
||||
unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace
|
||||
unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
||||
unset __orca_prompt_command_normalized
|
||||
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
|
||||
# commands as a foreground command and emits a fake C/D before the first prompt.
|
||||
__orca_initial_prompt=1
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_initializing_wrapper
|
||||
@@ -0,0 +1,10 @@
|
||||
function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# Orca daemon zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zlogin"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
@@ -0,0 +1,32 @@
|
||||
# Orca daemon zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -f "$_orca_home/.zprofile" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zprofile"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$_orca_home"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -0,0 +1,230 @@
|
||||
# Orca daemon zsh shell-ready wrapper
|
||||
typeset -ga _orca_shell_features
|
||||
_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}})
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
# Why stricter for an inherited value: Orca can be launched from a terminal that
|
||||
# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh
|
||||
# startup file at all is not the user's config root whoever wrote it.
|
||||
__orca_resolve_inherited_config_dir() {
|
||||
__orca_resolve_user_config_dir "${1:-}"
|
||||
[[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0
|
||||
local _orca_startup_file
|
||||
for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do
|
||||
[[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0
|
||||
done
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
}
|
||||
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
|
||||
# file is generated with a Windows path but sourced via /mnt/c, so the baked
|
||||
# literal is unusable there and ZDOTDIR must be restored from this value.
|
||||
# Derive it from the file being sourced (%x, zsh's internal script name) rather
|
||||
# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8
|
||||
# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username
|
||||
# such as a Korean login), which would make the self-check below fail and fall
|
||||
# back to the unusable baked literal, so the user's .zshrc never loads (#8003).
|
||||
# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the
|
||||
# rare shell where %x prompt expansion yields nothing.
|
||||
_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"
|
||||
if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then
|
||||
_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"
|
||||
fi
|
||||
while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do
|
||||
_orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}"
|
||||
done
|
||||
_orca_zshenv_path=""
|
||||
|
||||
# Normalize fallback and source roots before reading user .zshenv so nested
|
||||
# Orca PTYs never source another Orca wrapper recursively.
|
||||
__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_user_zdotdir="$_orca_resolved_config_dir"
|
||||
__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
|
||||
_orca_zshenv_source_dir="$_orca_resolved_config_dir"
|
||||
unset ORCA_ZSHENV_SOURCE_DIR
|
||||
|
||||
# Why: source at wrapper top level, not in a function/subshell, so .zshenv
|
||||
# exports, functions, path/fpath typesets, and zsh options keep normal scope.
|
||||
unset ZDOTDIR
|
||||
if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then
|
||||
_orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv"
|
||||
fi
|
||||
if [[ -n "${_orca_zshenv_path:-}" ]]; then
|
||||
source "${_orca_zshenv_path}"
|
||||
fi
|
||||
|
||||
_orca_discovered_zdotdir="${ZDOTDIR:-}"
|
||||
|
||||
while [[ "${_orca_discovered_zdotdir}" == */ ]]; do
|
||||
_orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}"
|
||||
done
|
||||
|
||||
case "${_orca_discovered_zdotdir}" in
|
||||
*[![:space:]]*) ;;
|
||||
*) _orca_discovered_zdotdir="" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then
|
||||
[[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2
|
||||
_orca_discovered_zdotdir=""
|
||||
fi
|
||||
|
||||
# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just
|
||||
# exported is the user's by construction, whatever it happens to contain.
|
||||
__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"
|
||||
export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"
|
||||
unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir
|
||||
|
||||
if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
unset _orca_zshenv_path
|
||||
|
||||
# Why: use :- after user .zshenv — a pathological unset under set -u must not
|
||||
# abort the wrapper; empty falls through to the baked-literal branch.
|
||||
if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then
|
||||
export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"
|
||||
else
|
||||
export ZDOTDIR='<WRAPPER_ROOT>/zsh'
|
||||
fi
|
||||
unset _orca_wrapper_zdotdir_self
|
||||
|
||||
__orca_shell_epilogue() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
# precmd_functions is a fatal error that returns from the whole epilogue
|
||||
# (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes
|
||||
# the 1-based feature lookup drop the first selected feature.
|
||||
emulate -L zsh
|
||||
(( $+_orca_epilogue_done )) && return 0
|
||||
typeset -g _orca_epilogue_done=1
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
fi
|
||||
if [[ -n "${ORCA_HISTFILE:-}" ]]; then
|
||||
HISTFILE="$ORCA_HISTFILE"
|
||||
builtin unset ORCA_HISTFILE
|
||||
elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then
|
||||
# Why also when Orca injected nothing: /etc/zshrc derived this from Orca's
|
||||
# wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the
|
||||
# user's real history would be invisible — the plain #11044 bug, with no
|
||||
# per-worktree scoping involved. Matching the exact clobbered value means a
|
||||
# HISTFILE the user set deliberately is never touched.
|
||||
HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history"
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
__orca_osc133_precmd() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${__orca_in_command:-}" ]]; then
|
||||
printf "\033]133;D;%s\007" "$exit_code"
|
||||
unset __orca_in_command
|
||||
fi
|
||||
printf "\033]133;A\007"
|
||||
}
|
||||
__orca_osc133_preexec() {
|
||||
printf "\033]133;C\007"
|
||||
# Why typeset -g: a plain assignment here creates a global inside a function,
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
typeset -g __orca_in_command=1
|
||||
}
|
||||
# Why: prepend so Orca captures $? before user prompt hooks can overwrite it.
|
||||
precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]})
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
fi
|
||||
if __orca_has_feature ready; then
|
||||
# Why: capture the prior zle-line-init so the marker chains to it. On a
|
||||
# re-source we are already the bound widget, so keep the function captured
|
||||
# the first time instead of clobbering it to empty (which would silently
|
||||
# drop the user's widget on every prompt after the second source). Only
|
||||
# user-defined widgets are chainable as plain functions; builtin/completion
|
||||
# forms (rare for zle-line-init) are left unchained.
|
||||
if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then
|
||||
:
|
||||
elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then
|
||||
__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"
|
||||
else
|
||||
__orca_prev_line_init_fn=""
|
||||
fi
|
||||
__orca_prompt_mark() {
|
||||
printf "\033]777;orca-shell-ready\007"
|
||||
# Why: call the prior hook as a plain function, not an aliased widget, so
|
||||
# $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers.
|
||||
if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then
|
||||
"${__orca_prev_line_init_fn}" "$@"
|
||||
fi
|
||||
}
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
# Why: after Orca's last wrapper file has loaded, the interactive shell should
|
||||
# expose the same ZDOTDIR a normal zsh startup would expose.
|
||||
export ZDOTDIR="$_orca_resolved_config_dir"
|
||||
unset _orca_home _orca_resolved_config_dir
|
||||
unset _orca_shell_features
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Orca daemon zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zshrc"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
fi
|
||||
@@ -0,0 +1,345 @@
|
||||
# Orca bash shell-ready wrapper
|
||||
_orca_shell_features=",${ORCA_SHELL_FEATURES:-},"
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
# Why a plain variable: the channel is consumed and destroyed in these first
|
||||
# lines, so nothing this shell later spawns can see or inherit the selection.
|
||||
__orca_ready_marker=""
|
||||
__orca_has_feature ready && __orca_ready_marker=1
|
||||
unset _orca_shell_features
|
||||
unset -f __orca_has_feature
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
source "$HOME/.bash_login"
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~). Without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
# Why: preserve bash's normal login-shell contract. Many users already source
|
||||
# ~/.bashrc from ~/.bash_profile; forcing ~/.bashrc again here would duplicate
|
||||
# PATH edits, hooks, and prompt init in Orca startup-command shells.
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after profile scripts.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
|
||||
# Why: emit OSC 133 C/D so terminal-command-lifecycle can drop stale agent
|
||||
# status when the foreground command (e.g. an interrupted Claude/Codex CLI)
|
||||
# exits — mirrors the zsh wrapper. Without this, bash users (default on most
|
||||
# Linux distros) keep a stuck 'working' spinner for up to 30 min after the
|
||||
# CLI exits without sending a Stop/SessionEnd hook.
|
||||
__orca_initializing_wrapper=1
|
||||
__orca_osc133_precmd() {
|
||||
local exit_code=$?
|
||||
__orca_in_prompt_command=1
|
||||
if [[ -n "${__orca_in_command:-}" ]]; then
|
||||
printf "\033]133;D;%s\007" "$exit_code"
|
||||
unset __orca_in_command
|
||||
fi
|
||||
printf "\033]133;A\007"
|
||||
return "$exit_code"
|
||||
}
|
||||
__orca_osc133_prompt_done() {
|
||||
unset __orca_in_prompt_command
|
||||
__orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
}
|
||||
__orca_osc133_preexec() {
|
||||
if [[ -n "${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then
|
||||
unset __orca_initial_prompt
|
||||
__orca_in_legacy_prompt_wrapper=1
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}${__orca_initial_prompt:-}${__orca_in_prompt_dispatch:-}${__orca_in_legacy_prompt_wrapper:-}${__orca_in_prompt_command:-}" ]]; then
|
||||
[[ -z "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}" ]] || return 0
|
||||
if [[ -n "${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then
|
||||
unset __orca_initial_prompt; return 0
|
||||
fi
|
||||
if [[ -n "${__orca_in_prompt_dispatch:-}" ]]; then
|
||||
[[ -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then
|
||||
case "$BASH_COMMAND" in
|
||||
'(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
elif [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -z "${__orca_in_legacy_prompt_wrapper:-}" || -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ -n "${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac
|
||||
case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac
|
||||
__orca_run_user_debug_trap
|
||||
[[ -z "${__orca_in_prompt_command:-}" ]] || return 0
|
||||
[[ -z "${__orca_in_command:-}" ]] || return 0
|
||||
# Why: bash DEBUG fires for every simple command, including PROMPT_COMMAND
|
||||
# bodies and chained traps can call us repeatedly for one command.
|
||||
printf "\033]133;C\007"
|
||||
__orca_in_command=1
|
||||
}
|
||||
# Why: prepend so we capture $? before the user's PROMPT_COMMAND chain mutates it.
|
||||
__orca_normalize_prompt_command_part() {
|
||||
local __orca_value="$1" __orca_output_name="$2" __orca_character __orca_chunk
|
||||
local __orca_value_length=${#1} __orca_suffix_length=0 __orca_backslash_length=0
|
||||
local __orca_output_length __orca_scan_start
|
||||
while (( __orca_value_length - __orca_suffix_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_value_length - __orca_suffix_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!$' \t\n;']*) break ;;
|
||||
*) __orca_suffix_length=$(( __orca_suffix_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_suffix_length < __orca_value_length )); do
|
||||
__orca_character="${__orca_value: -__orca_suffix_length - 1:1}"
|
||||
case "$__orca_character" in
|
||||
' '|$'\t'|$'\n'|';') __orca_suffix_length=$(( __orca_suffix_length + 1 )) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
while (( __orca_output_length - __orca_backslash_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_output_length - __orca_backslash_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!\\]*) break ;;
|
||||
*) __orca_backslash_length=$(( __orca_backslash_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_backslash_length < __orca_output_length )); do
|
||||
__orca_character="${__orca_value:__orca_output_length - __orca_backslash_length - 1:1}"
|
||||
[[ "$__orca_character" == '\' ]] || break
|
||||
__orca_backslash_length=$(( __orca_backslash_length + 1 ))
|
||||
done
|
||||
# Preserve the first separator when an odd backslash run escapes it.
|
||||
if (( __orca_suffix_length > 0 && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_suffix_length=$(( __orca_suffix_length - 1 ))
|
||||
__orca_backslash_length=0
|
||||
fi
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
__orca_value="${__orca_value:0:__orca_output_length}"
|
||||
# Bash 4.4-5.0 scalar prompt evaluation preserves an odd terminal backslash.
|
||||
if (( __orca_suffix_length == 0 && ((BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 0)) && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_value="$__orca_value\\"
|
||||
fi
|
||||
printf -v "$__orca_output_name" '%s' "$__orca_value"
|
||||
}
|
||||
__orca_restore_prompt_status() {
|
||||
return "$1"
|
||||
}
|
||||
__orca_update_user_debug_trap() {
|
||||
local __orca_debug_trap_spec="$1" __orca_unchanged_debug_trap_spec="$2"
|
||||
local __orca_debug_trap_command
|
||||
[[ "$__orca_debug_trap_spec" != "$__orca_unchanged_debug_trap_spec" ]] || return 0
|
||||
[[ "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]] || return 0
|
||||
if [[ -z "$__orca_debug_trap_spec" ]]; then
|
||||
__orca_user_debug_trap=""
|
||||
unset __orca_chained_debug_trap
|
||||
return 0
|
||||
fi
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
unset __orca_chained_debug_trap
|
||||
}
|
||||
__orca_run_user_debug_trap() {
|
||||
if [[ -n "${__orca_user_debug_trap:-}" ]]; then
|
||||
eval "$__orca_user_debug_trap" || true
|
||||
fi
|
||||
}
|
||||
__orca_adopt_outer_debug_trap() {
|
||||
local __orca_debug_trap_spec="${__orca_outer_debug_trap_spec:-}"
|
||||
unset __orca_outer_debug_trap_spec
|
||||
__orca_update_user_debug_trap "$__orca_debug_trap_spec" "trap -- '__orca_osc133_preexec' DEBUG"
|
||||
}
|
||||
__orca_run_prompt_command_array() {
|
||||
local __orca_exit_code="${__orca_prompt_status:-$?}" __orca_prompt_part __orca_prompt_index __orca_user_count
|
||||
local __orca_suffix_part
|
||||
local __orca_final_prompt_command
|
||||
local __orca_in_prompt_dispatch=1 __orca_dispatching_user_prompt_command=""
|
||||
unset __orca_prompt_status
|
||||
__orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
for __orca_prompt_part in "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
__orca_user_count=0
|
||||
for __orca_prompt_part in "${__orca_prompt_command_array[@]+"${__orca_prompt_command_array[@]}"}"; do
|
||||
__orca_user_count=$(( __orca_user_count + 1 ))
|
||||
done
|
||||
for (( __orca_prompt_index = 0; __orca_prompt_index + 1 < __orca_user_count; __orca_prompt_index++ )); do
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_prompt_index]}"
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
done
|
||||
if (( __orca_user_count > 0 )); then
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_user_count - 1]}"
|
||||
# Why: keep the final user hook and Orca suffixes in one status-preserving eval.
|
||||
__orca_final_prompt_command='eval "$__orca_prompt_part"'
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
__orca_final_prompt_command+=$'\n'"$__orca_suffix_part"
|
||||
done
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_final_prompt_command"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_final_prompt_command"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
else
|
||||
for __orca_prompt_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return "$__orca_exit_code"
|
||||
}
|
||||
__orca_finish_legacy_prompt_dispatch() {
|
||||
local __orca_suffix_part
|
||||
if [[ -n "${__orca_in_prompt_command:-}" ]]; then
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
eval "$__orca_suffix_part"
|
||||
done
|
||||
fi
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_in_legacy_prompt_wrapper
|
||||
}
|
||||
__orca_normalize_prompt_command() {
|
||||
[[ -z "${__orca_prompt_command_normalized:-}" ]] || return 0
|
||||
local __orca_prompt_part
|
||||
local -a __orca_normalized=()
|
||||
for __orca_prompt_part in "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}"; do
|
||||
__orca_normalize_prompt_command_part "$__orca_prompt_part" __orca_prompt_part
|
||||
[[ -n "$__orca_prompt_part" ]] && __orca_normalized+=("$__orca_prompt_part")
|
||||
done
|
||||
__orca_prompt_command_normalized=1
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_array=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
__orca_prompt_command_prefix=()
|
||||
__orca_prompt_command_suffix=()
|
||||
unset PROMPT_COMMAND
|
||||
# Why: PID scope distinguishes legacy prompt dispatch from ordinary user command text.
|
||||
__orca_prompt_status_variable="__orca_prompt_status_$$"
|
||||
__orca_prompt_status_capture_command="$__orca_prompt_status_variable=\$?"
|
||||
__orca_prompt_status_value="\${$__orca_prompt_status_variable}"
|
||||
PROMPT_COMMAND="$__orca_prompt_status_capture_command; __orca_prompt_status=$__orca_prompt_status_value"'; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace; __orca_run_prompt_command_array; __orca_finish_legacy_prompt_dispatch'
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("$command" "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_prefix=("$command" "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}")
|
||||
fi
|
||||
}
|
||||
__orca_append_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND+=("$command")
|
||||
else
|
||||
__orca_prompt_command_suffix+=("$command")
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command "__orca_osc133_precmd"
|
||||
# Why: append the marker through PROMPT_COMMAND so it fires after the login
|
||||
# startup files have rebuilt the prompt, without re-running user rc files.
|
||||
if [[ -n "$__orca_ready_marker" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "\033]777;orca-shell-ready\007"
|
||||
}
|
||||
__orca_append_prompt_command "__orca_prompt_mark"
|
||||
fi
|
||||
__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture'
|
||||
__orca_append_prompt_command "__orca_osc133_prompt_done"
|
||||
__orca_had_functrace=""
|
||||
[[ -o functrace ]] && __orca_had_functrace=1
|
||||
set +T
|
||||
__orca_debug_trap_spec="$(trap -p DEBUG)"
|
||||
[[ -z "$__orca_had_functrace" ]] || set -T
|
||||
if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
fi
|
||||
unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace
|
||||
unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
||||
unset __orca_prompt_command_normalized
|
||||
# Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile
|
||||
# commands as a foreground command and emits a fake C/D before the first prompt.
|
||||
__orca_initial_prompt=1
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_initializing_wrapper
|
||||
@@ -0,0 +1,10 @@
|
||||
function __orca_shell_ready_marker --on-event fish_prompt
|
||||
builtin printf "\033]777;orca-shell-ready\007"
|
||||
functions -e __orca_shell_ready_marker
|
||||
end
|
||||
if test -n "$ORCA_CODEX_LAUNCH_PREFLIGHT"; and test (type -t codex 2>/dev/null) = file
|
||||
function codex
|
||||
command "$ORCA_CODEX_LAUNCH_PREFLIGHT" agent hooks prepare-codex >/dev/null 2>&1; or true
|
||||
command codex $argv
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# Orca zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zlogin"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
@@ -0,0 +1,32 @@
|
||||
# Orca zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -f "$_orca_home/.zprofile" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zprofile"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$_orca_home"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -0,0 +1,230 @@
|
||||
# Orca zsh shell-ready wrapper
|
||||
typeset -ga _orca_shell_features
|
||||
_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}})
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
# Why stricter for an inherited value: Orca can be launched from a terminal that
|
||||
# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh
|
||||
# startup file at all is not the user's config root whoever wrote it.
|
||||
__orca_resolve_inherited_config_dir() {
|
||||
__orca_resolve_user_config_dir "${1:-}"
|
||||
[[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0
|
||||
local _orca_startup_file
|
||||
for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do
|
||||
[[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0
|
||||
done
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
}
|
||||
# Why: capture the runtime wrapper dir before it is unset below. On WSL this
|
||||
# file is generated with a Windows path but sourced via /mnt/c, so the baked
|
||||
# literal is unusable there and ZDOTDIR must be restored from this value.
|
||||
# Derive it from the file being sourced (%x, zsh's internal script name) rather
|
||||
# than the env-imported $ZDOTDIR: zsh corrupts environment values whose UTF-8
|
||||
# bytes fall in its 0x84-0x9D token range (e.g. a non-ASCII Windows username
|
||||
# such as a Korean login), which would make the self-check below fail and fall
|
||||
# back to the unusable baked literal, so the user's .zshrc never loads (#8003).
|
||||
# %x is not subject to that corruption; keep $ZDOTDIR as a fallback for the
|
||||
# rare shell where %x prompt expansion yields nothing.
|
||||
_orca_wrapper_zdotdir_self="${${(%):-%x}:h}"
|
||||
if [[ -z "${_orca_wrapper_zdotdir_self:-}" ]]; then
|
||||
_orca_wrapper_zdotdir_self="${ZDOTDIR:-}"
|
||||
fi
|
||||
while [[ "${_orca_wrapper_zdotdir_self:-}" == */ ]]; do
|
||||
_orca_wrapper_zdotdir_self="${_orca_wrapper_zdotdir_self%/}"
|
||||
done
|
||||
_orca_zshenv_path=""
|
||||
|
||||
# Normalize fallback and source roots before reading user .zshenv so nested
|
||||
# Orca PTYs never source another Orca wrapper recursively.
|
||||
__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_user_zdotdir="$_orca_resolved_config_dir"
|
||||
__orca_resolve_inherited_config_dir "${ORCA_ZSHENV_SOURCE_DIR:-$HOME}"
|
||||
_orca_zshenv_source_dir="$_orca_resolved_config_dir"
|
||||
unset ORCA_ZSHENV_SOURCE_DIR
|
||||
|
||||
# Why: source at wrapper top level, not in a function/subshell, so .zshenv
|
||||
# exports, functions, path/fpath typesets, and zsh options keep normal scope.
|
||||
unset ZDOTDIR
|
||||
if [[ -n "${_orca_zshenv_source_dir:-}" && -f "${_orca_zshenv_source_dir}/.zshenv" ]]; then
|
||||
_orca_zshenv_path="${_orca_zshenv_source_dir}/.zshenv"
|
||||
fi
|
||||
if [[ -n "${_orca_zshenv_path:-}" ]]; then
|
||||
source "${_orca_zshenv_path}"
|
||||
fi
|
||||
|
||||
_orca_discovered_zdotdir="${ZDOTDIR:-}"
|
||||
|
||||
while [[ "${_orca_discovered_zdotdir}" == */ ]]; do
|
||||
_orca_discovered_zdotdir="${_orca_discovered_zdotdir%/}"
|
||||
done
|
||||
|
||||
case "${_orca_discovered_zdotdir}" in
|
||||
*[![:space:]]*) ;;
|
||||
*) _orca_discovered_zdotdir="" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${_orca_discovered_zdotdir}" && ! -d "${_orca_discovered_zdotdir}" ]]; then
|
||||
[[ "${ORCA_DEBUG:-0}" == "1" ]] && echo "[orca-shell-ready] Discovered ZDOTDIR '${_orca_discovered_zdotdir}' does not exist, falling back" >&2
|
||||
_orca_discovered_zdotdir=""
|
||||
fi
|
||||
|
||||
# Why only the ownership check here: a ZDOTDIR the user's own .zshenv just
|
||||
# exported is the user's by construction, whatever it happens to contain.
|
||||
__orca_resolve_user_config_dir "${_orca_discovered_zdotdir:-${_orca_user_zdotdir:-$HOME}}"
|
||||
export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"
|
||||
unset _orca_user_zdotdir _orca_zshenv_source_dir _orca_discovered_zdotdir
|
||||
|
||||
if [[ -n "${_orca_zshenv_path:-}" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$ORCA_ORIG_ZDOTDIR"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
unset _orca_zshenv_path
|
||||
|
||||
# Why: use :- after user .zshenv — a pathological unset under set -u must not
|
||||
# abort the wrapper; empty falls through to the baked-literal branch.
|
||||
if [[ -n "${_orca_wrapper_zdotdir_self:-}" && -f "${_orca_wrapper_zdotdir_self:-}/.zshenv" ]]; then
|
||||
export ZDOTDIR="${_orca_wrapper_zdotdir_self:-}"
|
||||
else
|
||||
export ZDOTDIR='<WRAPPER_ROOT>/zsh'
|
||||
fi
|
||||
unset _orca_wrapper_zdotdir_self
|
||||
|
||||
__orca_shell_epilogue() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
# precmd_functions is a fatal error that returns from the whole epilogue
|
||||
# (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes
|
||||
# the 1-based feature lookup drop the first selected feature.
|
||||
emulate -L zsh
|
||||
(( $+_orca_epilogue_done )) && return 0
|
||||
typeset -g _orca_epilogue_done=1
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: ~/.zshrc can export the user's default OpenCode config after spawn.
|
||||
__orca_restore_agent_teams_path() {
|
||||
[[ -n "${ORCA_AGENT_TEAMS_SHIM_DIR:-}" ]] || return 0
|
||||
case "$PATH" in
|
||||
"${ORCA_AGENT_TEAMS_SHIM_DIR}"|"${ORCA_AGENT_TEAMS_SHIM_DIR}:"*) return 0 ;;
|
||||
esac
|
||||
export PATH="${ORCA_AGENT_TEAMS_SHIM_DIR}:$PATH"
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="${ORCA_CODEX_HOME}"
|
||||
# Why: a typed alias expands inside the shell, after pane launch prep.
|
||||
__orca_codex_binary="$(command -v codex 2>/dev/null || :)"
|
||||
if [[ -n "${ORCA_CODEX_LAUNCH_PREFLIGHT:-}" && -n "${__orca_codex_binary:-}" && -x "${__orca_codex_binary}" ]]; then
|
||||
codex() {
|
||||
"${ORCA_CODEX_LAUNCH_PREFLIGHT}" agent hooks prepare-codex >/dev/null 2>&1 || :
|
||||
command codex "$@"
|
||||
}
|
||||
fi
|
||||
unset __orca_codex_binary
|
||||
fi
|
||||
if [[ -n "${ORCA_HISTFILE:-}" ]]; then
|
||||
HISTFILE="$ORCA_HISTFILE"
|
||||
builtin unset ORCA_HISTFILE
|
||||
elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then
|
||||
# Why also when Orca injected nothing: /etc/zshrc derived this from Orca's
|
||||
# wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the
|
||||
# user's real history would be invisible — the plain #11044 bug, with no
|
||||
# per-worktree scoping involved. Matching the exact clobbered value means a
|
||||
# HISTFILE the user set deliberately is never touched.
|
||||
HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history"
|
||||
fi
|
||||
if __orca_has_feature markers; then
|
||||
__orca_osc133_precmd() {
|
||||
local exit_code=$?
|
||||
if [[ -n "${__orca_in_command:-}" ]]; then
|
||||
printf "\033]133;D;%s\007" "$exit_code"
|
||||
unset __orca_in_command
|
||||
fi
|
||||
printf "\033]133;A\007"
|
||||
}
|
||||
__orca_osc133_preexec() {
|
||||
printf "\033]133;C\007"
|
||||
# Why typeset -g: a plain assignment here creates a global inside a function,
|
||||
# which prints a warning above every command under warn_create_global.
|
||||
typeset -g __orca_in_command=1
|
||||
}
|
||||
# Why: prepend so Orca captures $? before user prompt hooks can overwrite it.
|
||||
precmd_functions=(__orca_osc133_precmd ${precmd_functions[@]})
|
||||
preexec_functions=(__orca_osc133_preexec ${preexec_functions[@]})
|
||||
fi
|
||||
if __orca_has_feature ready; then
|
||||
# Why: capture the prior zle-line-init so the marker chains to it. On a
|
||||
# re-source we are already the bound widget, so keep the function captured
|
||||
# the first time instead of clobbering it to empty (which would silently
|
||||
# drop the user's widget on every prompt after the second source). Only
|
||||
# user-defined widgets are chainable as plain functions; builtin/completion
|
||||
# forms (rare for zle-line-init) are left unchained.
|
||||
if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then
|
||||
:
|
||||
elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then
|
||||
__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"
|
||||
else
|
||||
__orca_prev_line_init_fn=""
|
||||
fi
|
||||
__orca_prompt_mark() {
|
||||
printf "\033]777;orca-shell-ready\007"
|
||||
# Why: call the prior hook as a plain function, not an aliased widget, so
|
||||
# $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers.
|
||||
if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then
|
||||
"${__orca_prev_line_init_fn}" "$@"
|
||||
fi
|
||||
}
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
# Why: after Orca's last wrapper file has loaded, the interactive shell should
|
||||
# expose the same ZDOTDIR a normal zsh startup would expose.
|
||||
export ZDOTDIR="$_orca_resolved_config_dir"
|
||||
unset _orca_home _orca_resolved_config_dir
|
||||
unset _orca_shell_features
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Orca zsh shell-ready wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ "$_orca_home" != "$ZDOTDIR" && -o interactive && -f "$_orca_home/.zshrc" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zshrc"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
fi
|
||||
@@ -0,0 +1,326 @@
|
||||
# Orca relay bash overlay wrapper
|
||||
_orca_shell_features=",${ORCA_SHELL_FEATURES:-},"
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { [[ "$_orca_shell_features" == *",$1,"* ]]; }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
# Why a plain variable: the channel is consumed and destroyed in these first
|
||||
# lines, so nothing this shell later spawns can see or inherit the selection.
|
||||
__orca_ready_marker=""
|
||||
__orca_has_feature ready && __orca_ready_marker=1
|
||||
unset _orca_shell_features
|
||||
unset -f __orca_has_feature
|
||||
[[ -f /etc/profile ]] && source /etc/profile
|
||||
if [[ -f "$HOME/.bash_profile" ]]; then
|
||||
source "$HOME/.bash_profile"
|
||||
elif [[ -f "$HOME/.bash_login" ]]; then
|
||||
source "$HOME/.bash_login"
|
||||
elif [[ -f "$HOME/.profile" ]]; then
|
||||
source "$HOME/.profile"
|
||||
fi
|
||||
# Why: enable bracketed paste so Orca can deliver a multiline startup prompt as
|
||||
# a single literal paste (ESC[200~…ESC[201~); without it, older readline builds
|
||||
# treat each embedded newline as Enter and mangle the prompt into PS2
|
||||
# continuation. Modern readline defaults this on; force it for the rest.
|
||||
[[ $- == *i* ]] && bind 'set enable-bracketed-paste on' 2>/dev/null
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
|
||||
if [[ -n "${ORCA_HISTFILE:-}" ]]; then
|
||||
HISTFILE="$ORCA_HISTFILE"
|
||||
builtin unset ORCA_HISTFILE
|
||||
elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then
|
||||
# Why also when Orca injected nothing: /etc/zshrc derived this from Orca's
|
||||
# wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the
|
||||
# user's real history would be invisible — the plain #11044 bug, with no
|
||||
# per-worktree scoping involved. Matching the exact clobbered value means a
|
||||
# HISTFILE the user set deliberately is never touched.
|
||||
HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history"
|
||||
fi
|
||||
# Why: SSH bash sessions need the same command lifecycle markers as local
|
||||
# bash so agent rows stop showing "working" when the foreground command exits.
|
||||
__orca_initializing_wrapper=1
|
||||
__orca_osc133_precmd() {
|
||||
local exit_code=$?
|
||||
__orca_in_prompt_command=1
|
||||
if [[ -n "${__orca_in_command:-}" ]]; then
|
||||
printf "\033]133;D;%s\007" "$exit_code"
|
||||
unset __orca_in_command
|
||||
fi
|
||||
printf "\033]133;A\007"
|
||||
return "$exit_code"
|
||||
}
|
||||
__orca_osc133_prompt_done() {
|
||||
unset __orca_in_prompt_command; __orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
}
|
||||
__orca_osc133_preexec() {
|
||||
if [[ -n "${__orca_prompt_status_capture_command:-}" && "$BASH_COMMAND" == "$__orca_prompt_status_capture_command" ]]; then
|
||||
unset __orca_initial_prompt
|
||||
__orca_in_legacy_prompt_wrapper=1
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}${__orca_initial_prompt:-}${__orca_in_prompt_dispatch:-}${__orca_in_legacy_prompt_wrapper:-}${__orca_in_prompt_command:-}" ]]; then
|
||||
[[ -z "${__orca_initializing_wrapper:-}${__orca_in_debug_capture:-}" ]] || return 0
|
||||
if [[ -n "${__orca_initial_prompt:-}" && "$BASH_COMMAND" == "__orca_osc133_precmd" ]]; then
|
||||
unset __orca_initial_prompt; return 0
|
||||
fi
|
||||
if [[ -n "${__orca_in_prompt_dispatch:-}" ]]; then
|
||||
[[ -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" ]]; then
|
||||
case "$BASH_COMMAND" in
|
||||
'(( __orca_exit_code == 0 ))'|'__orca_restore_prompt_status "$__orca_exit_code"'|'eval "$__orca_prompt_part"'|'eval "$__orca_final_prompt_command"'|__orca_dispatching_user_prompt_command=*|__orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;;
|
||||
esac
|
||||
fi
|
||||
elif [[ "${FUNCNAME[1]:-}" == "__orca_run_prompt_command_array" || "$BASH_COMMAND" == "__orca_run_prompt_command_array" ]]; then
|
||||
return 0
|
||||
fi
|
||||
[[ -z "${__orca_in_legacy_prompt_wrapper:-}" || -n "${__orca_dispatching_user_prompt_command:-}" ]] || return 0
|
||||
if [[ -n "${__orca_in_prompt_command:-}" && "$BASH_COMMAND" == "__orca_in_debug_capture=1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "${FUNCNAME[1]:-}" in __orca_osc133_*|__orca_prompt_mark|__orca_restore_prompt_status) return 0 ;; esac
|
||||
case "$BASH_COMMAND" in __orca_osc133_precmd|__orca_osc133_prompt_done|__orca_prompt_mark) return 0 ;; esac
|
||||
__orca_run_user_debug_trap
|
||||
[[ -z "${__orca_in_prompt_command:-}" ]] || return 0
|
||||
[[ -z "${__orca_in_command:-}" ]] || return 0
|
||||
printf "\033]133;C\007"
|
||||
__orca_in_command=1
|
||||
}
|
||||
__orca_normalize_prompt_command_part() {
|
||||
local __orca_value="$1" __orca_output_name="$2" __orca_character __orca_chunk
|
||||
local __orca_value_length=${#1} __orca_suffix_length=0 __orca_backslash_length=0
|
||||
local __orca_output_length __orca_scan_start
|
||||
while (( __orca_value_length - __orca_suffix_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_value_length - __orca_suffix_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!$' \t\n;']*) break ;;
|
||||
*) __orca_suffix_length=$(( __orca_suffix_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_suffix_length < __orca_value_length )); do
|
||||
__orca_character="${__orca_value: -__orca_suffix_length - 1:1}"
|
||||
case "$__orca_character" in
|
||||
' '|$'\t'|$'\n'|';') __orca_suffix_length=$(( __orca_suffix_length + 1 )) ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
while (( __orca_output_length - __orca_backslash_length >= 1024 )); do
|
||||
__orca_scan_start=$(( __orca_output_length - __orca_backslash_length - 1024 ))
|
||||
__orca_chunk="${__orca_value:__orca_scan_start:1024}"
|
||||
case "$__orca_chunk" in
|
||||
*[!\\]*) break ;;
|
||||
*) __orca_backslash_length=$(( __orca_backslash_length + 1024 )) ;;
|
||||
esac
|
||||
done
|
||||
while (( __orca_backslash_length < __orca_output_length )); do
|
||||
__orca_character="${__orca_value:__orca_output_length - __orca_backslash_length - 1:1}"
|
||||
[[ "$__orca_character" == '\' ]] || break
|
||||
__orca_backslash_length=$(( __orca_backslash_length + 1 ))
|
||||
done
|
||||
# Preserve the first separator when an odd backslash run escapes it.
|
||||
if (( __orca_suffix_length > 0 && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_suffix_length=$(( __orca_suffix_length - 1 ))
|
||||
__orca_backslash_length=0
|
||||
fi
|
||||
__orca_output_length=$(( ${#__orca_value} - __orca_suffix_length ))
|
||||
__orca_value="${__orca_value:0:__orca_output_length}"
|
||||
# Bash 4.4-5.0 scalar prompt evaluation preserves an odd terminal backslash.
|
||||
if (( __orca_suffix_length == 0 && ((BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 0)) && __orca_backslash_length % 2 == 1 )); then
|
||||
__orca_value="$__orca_value\\"
|
||||
fi
|
||||
printf -v "$__orca_output_name" '%s' "$__orca_value"
|
||||
}
|
||||
__orca_restore_prompt_status() {
|
||||
return "$1"
|
||||
}
|
||||
__orca_update_user_debug_trap() {
|
||||
local __orca_debug_trap_spec="$1" __orca_unchanged_debug_trap_spec="$2"
|
||||
local __orca_debug_trap_command
|
||||
[[ "$__orca_debug_trap_spec" != "$__orca_unchanged_debug_trap_spec" ]] || return 0
|
||||
[[ "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]] || return 0
|
||||
if [[ -z "$__orca_debug_trap_spec" ]]; then
|
||||
__orca_user_debug_trap=""
|
||||
unset __orca_chained_debug_trap
|
||||
return 0
|
||||
fi
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
unset __orca_chained_debug_trap
|
||||
}
|
||||
__orca_run_user_debug_trap() {
|
||||
if [[ -n "${__orca_user_debug_trap:-}" ]]; then
|
||||
eval "$__orca_user_debug_trap" || true
|
||||
fi
|
||||
}
|
||||
__orca_adopt_outer_debug_trap() {
|
||||
local __orca_debug_trap_spec="${__orca_outer_debug_trap_spec:-}"
|
||||
unset __orca_outer_debug_trap_spec
|
||||
__orca_update_user_debug_trap "$__orca_debug_trap_spec" "trap -- '__orca_osc133_preexec' DEBUG"
|
||||
}
|
||||
__orca_run_prompt_command_array() {
|
||||
local __orca_exit_code="${__orca_prompt_status:-$?}" __orca_prompt_part __orca_prompt_index __orca_user_count
|
||||
local __orca_suffix_part
|
||||
local __orca_final_prompt_command
|
||||
local __orca_in_prompt_dispatch=1 __orca_dispatching_user_prompt_command=""
|
||||
unset __orca_prompt_status
|
||||
__orca_adopt_outer_debug_trap
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
for __orca_prompt_part in "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
__orca_user_count=0
|
||||
for __orca_prompt_part in "${__orca_prompt_command_array[@]+"${__orca_prompt_command_array[@]}"}"; do
|
||||
__orca_user_count=$(( __orca_user_count + 1 ))
|
||||
done
|
||||
for (( __orca_prompt_index = 0; __orca_prompt_index + 1 < __orca_user_count; __orca_prompt_index++ )); do
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_prompt_index]}"
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
done
|
||||
if (( __orca_user_count > 0 )); then
|
||||
__orca_prompt_part="${__orca_prompt_command_array[__orca_user_count - 1]}"
|
||||
# Why: keep the final user hook and Orca suffixes in one status-preserving eval.
|
||||
__orca_final_prompt_command='eval "$__orca_prompt_part"'
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
__orca_final_prompt_command+=$'\n'"$__orca_suffix_part"
|
||||
done
|
||||
__orca_dispatching_user_prompt_command=1
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_final_prompt_command"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_final_prompt_command"
|
||||
fi
|
||||
__orca_dispatching_user_prompt_command=""
|
||||
else
|
||||
for __orca_prompt_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
if (( __orca_exit_code == 0 )); then
|
||||
eval "$__orca_prompt_part"
|
||||
else
|
||||
__orca_restore_prompt_status "$__orca_exit_code" || eval "$__orca_prompt_part"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return "$__orca_exit_code"
|
||||
}
|
||||
__orca_finish_legacy_prompt_dispatch() {
|
||||
local __orca_suffix_part
|
||||
if [[ -n "${__orca_in_prompt_command:-}" ]]; then
|
||||
for __orca_suffix_part in "${__orca_prompt_command_suffix[@]+"${__orca_prompt_command_suffix[@]}"}"; do
|
||||
eval "$__orca_suffix_part"
|
||||
done
|
||||
fi
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_in_legacy_prompt_wrapper
|
||||
}
|
||||
__orca_normalize_prompt_command() {
|
||||
[[ -z "${__orca_prompt_command_normalized:-}" ]] || return 0
|
||||
local __orca_prompt_part
|
||||
local -a __orca_normalized=()
|
||||
for __orca_prompt_part in "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}"; do
|
||||
__orca_normalize_prompt_command_part "$__orca_prompt_part" __orca_prompt_part
|
||||
[[ -n "$__orca_prompt_part" ]] && __orca_normalized+=("$__orca_prompt_part")
|
||||
done
|
||||
__orca_prompt_command_normalized=1
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_array=("${__orca_normalized[@]+"${__orca_normalized[@]}"}")
|
||||
__orca_prompt_command_prefix=()
|
||||
__orca_prompt_command_suffix=()
|
||||
unset PROMPT_COMMAND
|
||||
# Why: PID scope distinguishes legacy prompt dispatch from ordinary user command text.
|
||||
__orca_prompt_status_variable="__orca_prompt_status_$$"
|
||||
__orca_prompt_status_capture_command="$__orca_prompt_status_variable=\$?"
|
||||
__orca_prompt_status_value="\${$__orca_prompt_status_variable}"
|
||||
PROMPT_COMMAND="$__orca_prompt_status_capture_command; __orca_prompt_status=$__orca_prompt_status_value"'; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace; __orca_run_prompt_command_array; __orca_finish_legacy_prompt_dispatch'
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND=("$command" "${PROMPT_COMMAND[@]+"${PROMPT_COMMAND[@]}"}")
|
||||
else
|
||||
__orca_prompt_command_prefix=("$command" "${__orca_prompt_command_prefix[@]+"${__orca_prompt_command_prefix[@]}"}")
|
||||
fi
|
||||
}
|
||||
__orca_append_prompt_command() {
|
||||
local command="$1"
|
||||
__orca_normalize_prompt_command
|
||||
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
|
||||
PROMPT_COMMAND+=("$command")
|
||||
else
|
||||
__orca_prompt_command_suffix+=("$command")
|
||||
fi
|
||||
}
|
||||
__orca_prepend_prompt_command "__orca_osc133_precmd"
|
||||
# Why: SSH startup commands are renderer-delivered; emit the same internal
|
||||
# readiness marker as local shells only when that delivery mode asks for it.
|
||||
if [[ -n "$__orca_ready_marker" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "\033]777;orca-shell-ready\007"
|
||||
}
|
||||
__orca_append_prompt_command "__orca_prompt_mark"
|
||||
fi
|
||||
__orca_append_prompt_command '__orca_in_debug_capture=1; __orca_prompt_had_functrace=""; if [[ -o functrace ]]; then __orca_prompt_had_functrace=1; set +T; fi; __orca_outer_debug_trap_spec="$(trap -p DEBUG)"; [[ -z "$__orca_prompt_had_functrace" ]] || set -T; unset __orca_prompt_had_functrace __orca_in_debug_capture'
|
||||
__orca_append_prompt_command "__orca_osc133_prompt_done"
|
||||
__orca_had_functrace=""
|
||||
[[ -o functrace ]] && __orca_had_functrace=1
|
||||
set +T
|
||||
__orca_debug_trap_spec="$(trap -p DEBUG)"
|
||||
[[ -z "$__orca_had_functrace" ]] || set -T
|
||||
if [[ -n "$__orca_debug_trap_spec" && "$__orca_debug_trap_spec" != "trap -- '__orca_osc133_preexec' DEBUG" ]]; then
|
||||
__orca_debug_trap_command="${__orca_debug_trap_spec#trap -- }"
|
||||
__orca_debug_trap_command="${__orca_debug_trap_command% DEBUG}"
|
||||
eval "__orca_user_debug_trap=$__orca_debug_trap_command"
|
||||
fi
|
||||
unset __orca_debug_trap_spec __orca_debug_trap_command __orca_had_functrace
|
||||
unset -f __orca_normalize_prompt_command_part __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command
|
||||
unset __orca_prompt_command_normalized
|
||||
# Why: arm DEBUG after wrapper setup so the relay rcfile itself does not emit
|
||||
# fake command-start/end markers before the first prompt.
|
||||
__orca_initial_prompt=1
|
||||
trap '__orca_osc133_preexec' DEBUG
|
||||
unset __orca_initializing_wrapper
|
||||
@@ -0,0 +1,21 @@
|
||||
# Orca relay zsh overlay wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -o interactive && -f "$_orca_home/.zlogin" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zlogin"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
@@ -0,0 +1,32 @@
|
||||
# Orca relay zsh overlay wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -f "$_orca_home/.zprofile" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zprofile"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ -f "$_orca_home/.zprofile" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$_orca_home"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -0,0 +1,131 @@
|
||||
# Orca relay zsh overlay wrapper
|
||||
typeset -ga _orca_shell_features
|
||||
_orca_shell_features=(${(s:,:)${ORCA_SHELL_FEATURES:-}})
|
||||
builtin unset ORCA_SHELL_FEATURES
|
||||
__orca_has_feature() { (( ${_orca_shell_features[(Ie)$1]} )) }
|
||||
__orca_has_feature identity && printf "\033]777;orca-shell-start:%s\007" "$$"
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
# Why stricter for an inherited value: Orca can be launched from a terminal that
|
||||
# already pointed ZDOTDIR at its own wrapper dir, and a directory holding no zsh
|
||||
# startup file at all is not the user's config root whoever wrote it.
|
||||
__orca_resolve_inherited_config_dir() {
|
||||
__orca_resolve_user_config_dir "${1:-}"
|
||||
[[ "$_orca_resolved_config_dir" == "$HOME" ]] && return 0
|
||||
local _orca_startup_file
|
||||
for _orca_startup_file in .zshenv .zshrc .zprofile .zlogin; do
|
||||
[[ -r "$_orca_resolved_config_dir/$_orca_startup_file" ]] && return 0
|
||||
done
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
}
|
||||
__orca_resolve_inherited_config_dir "${ORCA_ORIG_ZDOTDIR:-$HOME}"
|
||||
export ORCA_ORIG_ZDOTDIR="$_orca_resolved_config_dir"
|
||||
[[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && source "$ORCA_ORIG_ZDOTDIR/.zshenv"
|
||||
__orca_resolve_user_config_dir "${ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
export ORCA_USER_ZDOTDIR="$_orca_resolved_config_dir"
|
||||
|
||||
if [[ -f "$ORCA_ORIG_ZDOTDIR/.zshenv" ]] && [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null; then
|
||||
case "$(emulate 2>/dev/null)" in
|
||||
sh|ksh)
|
||||
export ZDOTDIR="$ORCA_USER_ZDOTDIR"
|
||||
# Why unset: an ORCA_HISTFILE no wrapper file will ever consume is
|
||||
# inherited by everything this pane spawns, including a nested Orca.
|
||||
builtin unset ORCA_HISTFILE _orca_shell_features _orca_home _orca_resolved_config_dir _orca_wrapper_zdotdir_self
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir 2>/dev/null
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
export ZDOTDIR='<WRAPPER_ROOT>/zsh'
|
||||
|
||||
__orca_shell_epilogue() {
|
||||
# Why first: this body runs after the user's own config, so it would otherwise
|
||||
# inherit whatever options that config left set. Under NO_UNSET an unset
|
||||
# precmd_functions is a fatal error that returns from the whole epilogue
|
||||
# (skipping the ready widget and the ZDOTDIR restore), and KSH_ARRAYS makes
|
||||
# the 1-based feature lookup drop the first selected feature.
|
||||
emulate -L zsh
|
||||
(( $+_orca_epilogue_done )) && return 0
|
||||
typeset -g _orca_epilogue_done=1
|
||||
if __orca_has_feature overlay; then
|
||||
# Why: remote startup files can re-export user defaults after relay spawn.
|
||||
[[ -n "${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
[[ -n "${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="${ORCA_MIMOCODE_HOME}"
|
||||
[[ -n "${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
|
||||
# Why: OMP does not auto-load Orca's managed status extension; wrap only
|
||||
# interactive launch invocations so subcommands such as `omp config` keep
|
||||
# their normal argv shape.
|
||||
__orca_omp_should_skip_extension() {
|
||||
case "${1:-}" in
|
||||
help|--help|-h|--version|-v) return 0 ;;
|
||||
__complete|acp|agents|auth-broker|auth-gateway|bench|commit|completions|config|dry-balance|gallery|grep|grievances|install|join|models|plugin|read|say|search|setup|shell|ssh|stats|tiny-models|token|ttsr|update|usage|worktree|q|wt) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
__orca_omp() {
|
||||
local __orca_use_extension=1
|
||||
__orca_omp_should_skip_extension "${1:-}" && __orca_use_extension=0
|
||||
if [[ $__orca_use_extension -eq 1 && -n "${ORCA_OMP_STATUS_EXTENSION:-}" && -f "${ORCA_OMP_STATUS_EXTENSION}" ]]; then
|
||||
if [[ "${1:-}" == "launch" ]]; then
|
||||
shift
|
||||
command omp launch --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
else
|
||||
command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"
|
||||
fi
|
||||
else
|
||||
command omp "$@"
|
||||
fi
|
||||
}
|
||||
if [[ -n "${ORCA_OMP_STATUS_EXTENSION:-}" ]]; then
|
||||
omp() { __orca_omp "$@"; }
|
||||
fi
|
||||
fi
|
||||
if [[ -n "${ORCA_HISTFILE:-}" ]]; then
|
||||
HISTFILE="$ORCA_HISTFILE"
|
||||
builtin unset ORCA_HISTFILE
|
||||
elif [[ "${HISTFILE:-}" == "$ZDOTDIR/.zsh_history" ]]; then
|
||||
# Why also when Orca injected nothing: /etc/zshrc derived this from Orca's
|
||||
# wrapper ZDOTDIR, so history would accumulate INSIDE the wrapper dir and the
|
||||
# user's real history would be invisible — the plain #11044 bug, with no
|
||||
# per-worktree scoping involved. Matching the exact clobbered value means a
|
||||
# HISTFILE the user set deliberately is never touched.
|
||||
HISTFILE="${ORCA_ORIG_ZDOTDIR:-$HOME}/.zsh_history"
|
||||
fi
|
||||
if __orca_has_feature ready; then
|
||||
# Why: capture the prior zle-line-init so the marker chains to it. On a
|
||||
# re-source we are already the bound widget, so keep the function captured
|
||||
# the first time instead of clobbering it to empty (which would silently
|
||||
# drop the user's widget on every prompt after the second source). Only
|
||||
# user-defined widgets are chainable as plain functions; builtin/completion
|
||||
# forms (rare for zle-line-init) are left unchained.
|
||||
if [[ "${widgets[zle-line-init]:-}" == "user:__orca_prompt_mark" ]]; then
|
||||
:
|
||||
elif (( ${+widgets[zle-line-init]} )) && [[ "${widgets[zle-line-init]}" == user:* ]]; then
|
||||
__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"
|
||||
else
|
||||
__orca_prev_line_init_fn=""
|
||||
fi
|
||||
__orca_prompt_mark() {
|
||||
printf "\033]777;orca-shell-ready\007"
|
||||
# Why: call the prior hook as a plain function, not an aliased widget, so
|
||||
# $WIDGET stays zle-line-init for add-zle-hook-widget dispatchers.
|
||||
if [[ -n "${__orca_prev_line_init_fn:-}" ]]; then
|
||||
"${__orca_prev_line_init_fn}" "$@"
|
||||
fi
|
||||
}
|
||||
zle -N zle-line-init __orca_prompt_mark
|
||||
fi
|
||||
__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
# Why: after Orca's last wrapper file has loaded, the interactive shell should
|
||||
# expose the same ZDOTDIR a normal zsh startup would expose.
|
||||
export ZDOTDIR="$_orca_resolved_config_dir"
|
||||
unset _orca_home _orca_resolved_config_dir
|
||||
unset _orca_shell_features
|
||||
unfunction __orca_shell_epilogue __orca_has_feature __orca_resolve_user_config_dir __orca_resolve_inherited_config_dir
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Orca relay zsh overlay wrapper
|
||||
__orca_resolve_user_config_dir() {
|
||||
typeset -g _orca_resolved_config_dir="${1:-}"
|
||||
while [[ "$_orca_resolved_config_dir" == */ ]]; do _orca_resolved_config_dir="${_orca_resolved_config_dir%/}"; done
|
||||
if [[ -z "$_orca_resolved_config_dir" || -f "$_orca_resolved_config_dir/.orca-shell-wrapper" || "$_orca_resolved_config_dir" == */shell-ready/zsh ]]; then
|
||||
_orca_resolved_config_dir="$HOME"
|
||||
fi
|
||||
}
|
||||
__orca_resolve_user_config_dir "${ORCA_USER_ZDOTDIR:-${ORCA_ORIG_ZDOTDIR:-$HOME}}"
|
||||
_orca_home="$_orca_resolved_config_dir"
|
||||
if [[ -o interactive && -f "$_orca_home/.zshrc" ]]; then
|
||||
_orca_wrapper_zdotdir="$ZDOTDIR"
|
||||
# Why: user startup files resolve plugin/config paths from their own ZDOTDIR;
|
||||
# Orca restores its wrapper dir afterward so zsh still loads wrapper files.
|
||||
export ZDOTDIR="$_orca_home"
|
||||
source "$_orca_home/.zshrc"
|
||||
export ZDOTDIR="$_orca_wrapper_zdotdir"
|
||||
unset _orca_wrapper_zdotdir
|
||||
fi
|
||||
|
||||
if [[ ! -o login ]] || { [[ -o ksharrays || -o shwordsplit || -o shglob ]] 2>/dev/null && [[ "$(emulate 2>/dev/null)" != zsh ]]; }; then
|
||||
(( ${+functions[__orca_shell_epilogue]} )) && __orca_shell_epilogue
|
||||
fi
|
||||
@@ -29,6 +29,13 @@ describe('createHookStatusSessionTabsInvalidator', () => {
|
||||
expect(changed(working())).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates when a restored row is confirmed by live activity', () => {
|
||||
const changed = createHookStatusSessionTabsInvalidator()
|
||||
changed(working({ restoredUnconfirmed: true }))
|
||||
|
||||
expect(changed(working())).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['state', { state: 'waiting' as const }],
|
||||
['prompt', { prompt: 'ship it' }],
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { AgentHookEventPayload } from '../../shared/agent-hook-listener'
|
||||
import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types'
|
||||
|
||||
type KnownStatus = { connectionId: string | null; payload: ParsedAgentStatusPayload }
|
||||
type KnownStatus = {
|
||||
connectionId: string | null
|
||||
payload: ParsedAgentStatusPayload
|
||||
restoredUnconfirmed: boolean
|
||||
}
|
||||
|
||||
/** Reports whether a hook status event changed anything the `session.tabs`
|
||||
* projection publishes, so a repeated same-state ping costs no snapshot rebuild.
|
||||
* Mirrors `retainAgentRowSnapshot`'s change set so both carriers invalidate alike. */
|
||||
* Mirrors `retainAgentRowSnapshot`'s change set plus hook restore provenance. */
|
||||
export function createHookStatusSessionTabsInvalidator(): {
|
||||
(event: AgentHookEventPayload): boolean
|
||||
forgetPane: (paneKey: string) => void
|
||||
@@ -18,19 +22,25 @@ export function createHookStatusSessionTabsInvalidator(): {
|
||||
if (event.providerSessionOnly === true) {
|
||||
return false
|
||||
}
|
||||
const previous = known.get(event.paneKey)?.payload
|
||||
const previous = known.get(event.paneKey)
|
||||
const next = event.payload
|
||||
known.set(event.paneKey, { connectionId: event.connectionId, payload: next })
|
||||
const restoredUnconfirmed = event.restoredUnconfirmed === true
|
||||
known.set(event.paneKey, {
|
||||
connectionId: event.connectionId,
|
||||
payload: next,
|
||||
restoredUnconfirmed
|
||||
})
|
||||
return (
|
||||
!previous ||
|
||||
previous.state !== next.state ||
|
||||
previous.prompt !== next.prompt ||
|
||||
(previous.agentType ?? null) !== (next.agentType ?? null) ||
|
||||
(previous.toolName ?? null) !== (next.toolName ?? null) ||
|
||||
(previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) ||
|
||||
(previous.interrupted ?? false) !== (next.interrupted ?? false) ||
|
||||
(previous.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
|
||||
(previous.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null)
|
||||
previous.payload.state !== next.state ||
|
||||
previous.payload.prompt !== next.prompt ||
|
||||
(previous.payload.agentType ?? null) !== (next.agentType ?? null) ||
|
||||
(previous.payload.toolName ?? null) !== (next.toolName ?? null) ||
|
||||
(previous.payload.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) ||
|
||||
(previous.payload.interrupted ?? false) !== (next.interrupted ?? false) ||
|
||||
(previous.payload.turnCompletedAt ?? null) !== (next.turnCompletedAt ?? null) ||
|
||||
(previous.payload.lastAssistantMessage ?? null) !== (next.lastAssistantMessage ?? null) ||
|
||||
previous.restoredUnconfirmed !== restoredUnconfirmed
|
||||
)
|
||||
}
|
||||
// Why: a cleared pane must re-arm, else the memo swallows the first event of the
|
||||
|
||||
@@ -526,6 +526,37 @@ describe('wrapPosixHookCommand', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('emits a fallback response before draining when the caller supplies one', () => {
|
||||
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {}, { fallbackStdout: '{"a":"b"}' })
|
||||
expect(cmd).toBe(
|
||||
`if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else printf '%s\\n' '{"a":"b"}'; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'writes the fallback response and still drains a large stdin payload',
|
||||
() => {
|
||||
const cmd = wrapPosixHookCommand('/does/not/exist.sh', {}, { fallbackStdout: '{"a":"b"}' })
|
||||
const result = spawnSync('/bin/sh', ['-c', cmd], {
|
||||
input: Buffer.alloc(1_000_000, 'x'),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('{"a":"b"}\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'runs the script instead of the fallback when the script is present',
|
||||
() => {
|
||||
const scriptPath = join(tmpDir, 'present-hook.sh')
|
||||
writeFileSync(scriptPath, "#!/bin/sh\nprintf 'from-script\\n'\n", { mode: 0o755 })
|
||||
const cmd = wrapPosixHookCommand(scriptPath, {}, { fallbackStdout: '{"a":"b"}' })
|
||||
const result = spawnSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' })
|
||||
expect(result.stdout).toBe('from-script\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'drains stdin when a directory occupies the managed script path',
|
||||
() => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { dirname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import { grantDirAcl, isPermissionError } from '../win32-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
import { resolveHooksJsonWritePath } from './hook-config-write-path'
|
||||
import { writeRollingFileBackup } from '../rolling-file-backup'
|
||||
import { wrapWindowsPowerShellEncodedCommand } from './windows-powershell-hook-launcher'
|
||||
@@ -106,20 +105,7 @@ export function getSharedManagedScriptPath(scriptFileName: string): string {
|
||||
return join(homedir(), '.orca', 'agent-hooks', scriptFileName)
|
||||
}
|
||||
|
||||
function quotePosixShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
|
||||
export function wrapPosixHookCommand(scriptPath: string, env: Record<string, string> = {}): string {
|
||||
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
|
||||
const quoted = quotePosixShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
||||
.join(' ')
|
||||
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
|
||||
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi`
|
||||
}
|
||||
export { wrapPosixHookCommand } from './posix-hook-command'
|
||||
|
||||
export function quotePowerShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract'
|
||||
|
||||
function quotePosixShellString(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
// Why: guard for a readable executable so a stale entry at a missing script becomes a silent no-op, not an exit-127 failure on every tool call.
|
||||
export function wrapPosixHookCommand(
|
||||
scriptPath: string,
|
||||
env: Record<string, string> = {},
|
||||
// Why: silence is a hard deny on gate events (Antigravity PreToolUse, #2426); those callers need the guard to still answer.
|
||||
options: { fallbackStdout?: string } = {}
|
||||
): string {
|
||||
// Why: single-quote escape so $, `, ", \ in scriptPath stay literal — avoids shell injection from an arbitrary path.
|
||||
const quoted = quotePosixShellString(scriptPath)
|
||||
const envPrefix = Object.entries(env)
|
||||
.map(([key, value]) => `${key}='${value.replaceAll("'", "'\\''")}'`)
|
||||
.join(' ')
|
||||
const invocation = envPrefix ? `${envPrefix} /bin/sh ${quoted}` : `/bin/sh ${quoted}`
|
||||
const fallback =
|
||||
options.fallbackStdout === undefined
|
||||
? POSIX_HOOK_STDIN_DRAIN_COMMAND
|
||||
: `printf '%s\\n' ${quotePosixShellString(options.fallbackStdout)}; ${POSIX_HOOK_STDIN_DRAIN_COMMAND}`
|
||||
return `if [ -f ${quoted} ] && [ -r ${quoted} ] && [ -x ${quoted} ]; then ${invocation}; else ${fallback}; fi`
|
||||
}
|
||||
@@ -343,14 +343,20 @@ describe('remote hook service installers', () => {
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
}
|
||||
expect(antigravityConfig['orca-status'].PreToolUse).toBeUndefined()
|
||||
for (const eventName of ['PostToolUse']) {
|
||||
for (const eventName of ['PreToolUse', 'PostToolUse']) {
|
||||
const definition = antigravityConfig['orca-status'][eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(definition?.matcher).toBe('*')
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(command).toContain(`ORCA_ANTIGRAVITY_EVENT='${eventName}'`)
|
||||
}
|
||||
// Why: #2426 was an SSH report — a remote host missing the script must still answer the gate, not deny every tool.
|
||||
expect(antigravityConfig['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
|
||||
`printf '%s\\n' '{"decision":"ask"}'`
|
||||
)
|
||||
expect(antigravityConfig['orca-status'].PostToolUse[0].hooks?.[0]?.command).not.toContain(
|
||||
'{"decision"'
|
||||
)
|
||||
|
||||
const ampPlugin = amp.fs.files.get('/home/dev/.config/amp/plugins/orca-agent-status.ts')
|
||||
expect(ampPlugin).toContain('/hook/amp')
|
||||
@@ -527,7 +533,7 @@ describe('remote hook service installers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('removes stale remote Antigravity PreToolUse hooks while installing SSH hooks', async () => {
|
||||
it('replaces stale remote Antigravity PreToolUse hooks while installing SSH hooks', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
fs.files.set(
|
||||
'/home/dev/.gemini/config/hooks.json',
|
||||
@@ -568,7 +574,12 @@ describe('remote hook service installers', () => {
|
||||
const config = JSON.parse(fs.files.get('/home/dev/.gemini/config/hooks.json')!) as {
|
||||
'orca-status': Record<string, { hooks?: { command: string }[] }[]>
|
||||
}
|
||||
expect(config['orca-status'].PreToolUse).toBeUndefined()
|
||||
const preToolCommands = config['orca-status'].PreToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(preToolCommands).toHaveLength(1)
|
||||
expect(preToolCommands[0]).toContain('/home/dev/.orca/agent-hooks/antigravity-hook.sh')
|
||||
expect(preToolCommands).not.toContain('/tmp/old/agent-hooks/antigravity-hook.sh')
|
||||
const postToolCommands = config['orca-status'].PostToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { buildBody, PANE } from './server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Claude child permission lifecycle', () => {
|
||||
async function createServer(): Promise<{
|
||||
server: AgentHookServer
|
||||
postClaudeHook: (payload: Record<string, unknown>) => Promise<Response>
|
||||
}> {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
return {
|
||||
server,
|
||||
postClaudeHook: (payload) =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('clears a child permission when that child stops', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'a-blocked',
|
||||
agent_type: 'general-purpose',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
await postClaudeHook({ hook_event_name: 'SubagentStop', agent_id: 'a-other' })
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'waiting',
|
||||
toolName: 'Bash',
|
||||
subagents: [expect.objectContaining({ id: 'a-blocked', state: 'working' })]
|
||||
})
|
||||
await postClaudeHook({ hook_event_name: 'SubagentStop', agent_id: 'a-blocked' })
|
||||
|
||||
const status = server.getStatusSnapshot()[0]
|
||||
expect(status).toMatchObject({ paneKey: PANE, state: 'working', agentType: 'claude' })
|
||||
expect(status?.toolName).toBeUndefined()
|
||||
expect(status?.toolInput).toBeUndefined()
|
||||
expect(status?.interactivePrompt).toBeUndefined()
|
||||
expect(status?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears a teammate permission when that teammate idles', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'areviewer-6d3cb5b5',
|
||||
agent_type: 'reviewer',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'writer' })
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'waiting',
|
||||
toolName: 'Bash',
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'working' })]
|
||||
})
|
||||
await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' })
|
||||
|
||||
const status = server.getStatusSnapshot()[0]
|
||||
expect(status).toMatchObject({
|
||||
paneKey: PANE,
|
||||
state: 'working',
|
||||
agentType: 'claude',
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'idle' })]
|
||||
})
|
||||
expect(status?.toolName).toBeUndefined()
|
||||
expect(status?.toolInput).toBeUndefined()
|
||||
expect(status?.interactivePrompt).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a child permission after unrelated lead progress and teammate idle', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'areviewer-6d3cb5b5',
|
||||
agent_type: 'reviewer',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
await postClaudeHook({ hook_event_name: 'PreToolUse', tool_name: 'Read' })
|
||||
await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'writer' })
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'waiting',
|
||||
toolName: 'Bash',
|
||||
subagents: [expect.objectContaining({ id: 'areviewer-6d3cb5b5', state: 'working' })]
|
||||
})
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears an omitted teammate permission when a full roster excludes its row', async () => {
|
||||
const { server, postClaudeHook } = await createServer()
|
||||
try {
|
||||
await postClaudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'guarded task' })
|
||||
for (let index = 0; index < AGENT_STATUS_MAX_SUBAGENTS; index += 1) {
|
||||
await postClaudeHook({ hook_event_name: 'SubagentStart', agent_id: `acapped${index}` })
|
||||
}
|
||||
await postClaudeHook({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'areviewer-6d3cb5b5',
|
||||
agent_type: 'reviewer',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' })
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS)
|
||||
|
||||
await postClaudeHook({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer' })
|
||||
|
||||
const status = server.getStatusSnapshot()[0]
|
||||
expect(status).toMatchObject({ paneKey: PANE, state: 'working', agentType: 'claude' })
|
||||
expect(status?.toolName).toBeUndefined()
|
||||
expect(status?.toolInput).toBeUndefined()
|
||||
expect(status?.subagents).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -267,6 +267,365 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// STA-4114: a detach/reattach cycle retires the pane, and nothing lifted the fence.
|
||||
// Reviving only on a new turn cannot help a pane re-attached mid-turn (its remaining
|
||||
// events are agent_end, not a new-turn event) or one re-attached idle.
|
||||
for (const kind of ['pi', 'omp', 'prime-agent'] as const) {
|
||||
it(`re-attaching a retired ${kind} pane restores status without needing a new turn`, async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/${kind}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: `retired-${kind}-token` }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
|
||||
server.retirePaneAuthority(PANE)
|
||||
|
||||
// The turn was already running, so only its completion is left to report —
|
||||
// and while retired it is suppressed. This is the reported permanent failure.
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(true)
|
||||
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'done' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('re-attaching a retired pane while idle re-opens it for a much later first turn', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'idle-reattach-token' }))
|
||||
})
|
||||
|
||||
// Nothing in flight: the pane is retired and re-attached while the agent sits idle.
|
||||
server.retirePaneAuthority(PANE)
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(true)
|
||||
|
||||
// Why: prove the fence is already down before any turn event arrives. Asserting
|
||||
// only on before_agent_start would also pass if a turn boundary lifted the fence,
|
||||
// so it cannot distinguish re-attach revival from turn-triggered revival (#14626).
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'done' })
|
||||
])
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'much later turn' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'working', prompt: 'much later turn' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-attach does not lift a closed-tab tombstone', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'closed-tab-token' }))
|
||||
})
|
||||
|
||||
// Pane fence AND tab fence are both standing; re-attach may lift neither.
|
||||
server.retirePaneAuthority(PANE)
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(false)
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab close' })
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
// The pane fence must still be standing too, not silently lifted underneath.
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// STA-4114: retirement fences every alias of a pane and deletes the alias itself.
|
||||
// Restoring only the owner key leaves the physical key the live process still posts
|
||||
// fenced forever — the detached pane, which is the canonical re-attach case.
|
||||
it('re-attaching a detached pane accepts hooks on the pane key its process launched under', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const detachedPane = makePaneKey('tab-2', LEAF_2)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'detached-token' }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
|
||||
// Detach into another tab. The live process keeps posting PANE (server.ts:1614),
|
||||
// so the alias is the only thing routing it to its new owner.
|
||||
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
|
||||
server.retirePaneAuthority(detachedPane)
|
||||
expect(server.restorePaneAuthority(detachedPane)).toBe(true)
|
||||
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
// One row under the OWNER key. Lifting the fence without rebuilding the alias
|
||||
// mints a second row on the stale key instead.
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: detachedPane, state: 'done' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-attaching restores a legacy numeric pane key alias', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const legacyPane = 'tab-1:0'
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-token' })
|
||||
)
|
||||
})
|
||||
|
||||
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-legacy')
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'legacy turn' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'working' })
|
||||
])
|
||||
|
||||
server.retirePaneAuthority(PANE)
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(true)
|
||||
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'done' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not rebuild a detached pane alias into a closed tab', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const detachedPane = makePaneKey('tab-2', LEAF_2)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'detached-closed-token' }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
|
||||
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
|
||||
server.retirePaneAuthority(detachedPane)
|
||||
// The tab the pane was detached into is closed: the stronger claim wins, and the
|
||||
// alias must not be resurrected to route a live process into a closed tab.
|
||||
server.dropStatusEntriesByTabPrefix('tab-2')
|
||||
expect(server.restorePaneAuthority(detachedPane)).toBe(false)
|
||||
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: the guard above short-circuits on the closed owner, so it never reaches the
|
||||
// alias rebuild. Restoring the ORIGINAL key does reach it — and rebuilding the alias
|
||||
// there would route a live process into the closed tab and silence it again.
|
||||
it('re-opens the original pane instead of rebuilding an alias into a closed tab', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const detachedPane = makePaneKey('tab-2', LEAF_2)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'reopen-origin-token' }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
|
||||
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
|
||||
server.retirePaneAuthority(detachedPane)
|
||||
server.dropStatusEntriesByTabPrefix('tab-2')
|
||||
|
||||
// The pane the process actually lives in is tab-1, which is still open.
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(true)
|
||||
|
||||
await postHook({ hook_event_name: 'agent_end' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE, state: 'done' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: closedAgentStatusTabIds is LRU-bounded, so the tab fence is not permanent.
|
||||
// If restoring a sibling key lifted the closed tab's PANE fence too, eviction of the
|
||||
// tab id would leave nothing at all holding that pane shut.
|
||||
it('leaves a closed tab pane fenced once its tab id is evicted', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const detachedPane = makePaneKey('tab-2', LEAF_2)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (
|
||||
payload: Record<string, unknown>,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(buildBody(payload, { launchToken: 'evict-token', ...overrides }))
|
||||
})
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'turn in flight' })
|
||||
server.transferPaneAuthority(PANE, detachedPane, 'pty-detached')
|
||||
server.retirePaneAuthority(detachedPane)
|
||||
server.dropStatusEntriesByTabPrefix('tab-2')
|
||||
expect(server.restorePaneAuthority(PANE)).toBe(true)
|
||||
|
||||
for (let i = 0; i <= CLOSED_AGENT_STATUS_TAB_IDS_MAX; i += 1) {
|
||||
server.dropStatusEntriesByTabPrefix(`tab-evict-${i}`)
|
||||
}
|
||||
|
||||
await postHook(
|
||||
{ hook_event_name: 'before_agent_start', prompt: 'after eviction' },
|
||||
{ paneKey: detachedPane, tabId: 'tab-2' }
|
||||
)
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: a detach re-points a legacy numeric alias at an owner in another tab, so the
|
||||
// fence can hold keys from two tabs at once. A legacy key never parses as a stable
|
||||
// one, so a stable-only tab check would wave it through when its own tab is closed.
|
||||
it('keeps a legacy alias fenced when its own tab closed but the owner tab did not', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const legacyPane = 'tab-1:0'
|
||||
const detachedPane = makePaneKey('tab-2', LEAF_2)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody(payload, { paneKey: legacyPane, launchToken: 'legacy-cross-tab-token' })
|
||||
)
|
||||
})
|
||||
|
||||
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-cross')
|
||||
server.transferPaneAuthority(PANE, detachedPane, 'pty-cross')
|
||||
server.retirePaneAuthority(detachedPane)
|
||||
server.dropStatusEntriesByTabPrefix('tab-1')
|
||||
|
||||
// The owner tab is open, so the restore proceeds — but the legacy key's own tab
|
||||
// is closed, and its alias must not be rebuilt into the still-open owner.
|
||||
expect(server.restorePaneAuthority(detachedPane)).toBe(true)
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after tab-1 close' })
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not clobber a newer alias when replaying a retired fence', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const legacyPane = 'tab-1:0'
|
||||
const reboundPane = makePaneKey('tab-1', LEAF_3)
|
||||
const env = server.buildPtyEnv()
|
||||
const postHook = (payload: Record<string, unknown>): Promise<Response> =>
|
||||
fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/pi`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildBody(payload, { paneKey: legacyPane, launchToken: 'rebind-token' })
|
||||
)
|
||||
})
|
||||
|
||||
server.registerPaneKeyAlias(legacyPane, PANE, 'pty-old')
|
||||
server.retirePaneAuthority(PANE)
|
||||
// The pane rebound to a different owner before the restore landed.
|
||||
server.registerPaneKeyAlias(legacyPane, reboundPane, 'pty-new')
|
||||
server.restorePaneAuthority(PANE)
|
||||
|
||||
await postHook({ hook_event_name: 'before_agent_start', prompt: 'after rebind' })
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({ paneKey: reboundPane, state: 'working' })
|
||||
])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a resumed-session SessionStart after launch authority retires in a reusable pane', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
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 { AgentHookServer, _internals } from './server'
|
||||
import { buildBody, postHookEvent, PANE, RUNNING_SHELL } from './server.test-fixtures'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Persisted Claude lead boundaries', () => {
|
||||
let userDataPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-lead-boundary-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps a restored-only child unconfirmed after a done lead and live sibling drain', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' })
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'continue after restart' })
|
||||
)
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'alive-sibling' })
|
||||
)
|
||||
await postHookEvent(server, buildBody({ hook_event_name: 'Stop' }))
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'alive-sibling' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'arestored-child' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores a persisted lead boundary before its last child stops', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'Stop', last_assistant_message: 'Lead finished.' })
|
||||
)
|
||||
expect(firstServer.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'finish after child',
|
||||
lastAssistantMessage: 'Lead finished.',
|
||||
subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })]
|
||||
})
|
||||
const turnCompletedAt = firstServer.getStatusSnapshot()[0]?.turnCompletedAt
|
||||
expect(turnCompletedAt).toEqual(expect.any(Number))
|
||||
expect(firstServer._getStateForTests().lastStatusByPaneKey.get(PANE)?.hookEventName).toBe(
|
||||
'Stop'
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'claude',
|
||||
prompt: 'finish after child',
|
||||
lastAssistantMessage: 'Lead finished.',
|
||||
turnCompletedAt
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a persisted child boundary across an OSC repaint and restart', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after child' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' })
|
||||
)
|
||||
await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const secondServer = new AgentHookServer()
|
||||
await secondServer.start({ env: 'production', userDataPath })
|
||||
secondServer.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
connectionId: null,
|
||||
payload: { state: 'working', prompt: '', agentType: 'claude' }
|
||||
})
|
||||
secondServer.flushStatusPersistSync()
|
||||
secondServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
agentType: 'claude',
|
||||
turnCompletedAt: expect.any(Number)
|
||||
})
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a persisted lead boundary across sibling drain', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish after siblings' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achilda' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achildb' })
|
||||
)
|
||||
await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
expect(
|
||||
(
|
||||
firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as
|
||||
| { claudeLeadBoundaryChildOnly?: true }
|
||||
| undefined
|
||||
)?.claudeLeadBoundaryChildOnly
|
||||
).toBe(true)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' })
|
||||
)
|
||||
expect(
|
||||
(
|
||||
firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as
|
||||
| { claudeLeadBoundaryChildOnly?: true }
|
||||
| undefined
|
||||
)?.claudeLeadBoundaryChildOnly
|
||||
).toBe(true)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achildb' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', agentType: 'claude' })
|
||||
expect(server.getStatusSnapshot()[0]?.restoredUnconfirmed).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears a persisted lead boundary when sticky permission hides new lead work', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'resume after boundary' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achild-a' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achild-b' })
|
||||
)
|
||||
await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'achild-a',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' })
|
||||
)
|
||||
expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' })
|
||||
expect(
|
||||
(
|
||||
firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as
|
||||
| { claudeLeadBoundaryChildOnly?: true }
|
||||
| undefined
|
||||
)?.claudeLeadBoundaryChildOnly
|
||||
).toBeUndefined()
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-a' })
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achild-b' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a persisted lead boundary unconfirmed after an unrelated child stop', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'ignore unrelated stop' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' })
|
||||
)
|
||||
await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'aunrelated' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true,
|
||||
subagents: [expect.objectContaining({ id: 'arestored-child', state: 'working' })]
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates a persisted lead boundary behind a sticky child permission', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'resume after stop' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achilda' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'achildb' })
|
||||
)
|
||||
await postHookEvent(firstServer, buildBody({ hook_event_name: 'Stop' }))
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'achilda',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'false' }
|
||||
})
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'PreToolUse', tool_name: 'Read' })
|
||||
)
|
||||
expect(firstServer.getStatusSnapshot()[0]).toMatchObject({ state: 'waiting', toolName: 'Bash' })
|
||||
expect(
|
||||
(
|
||||
firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as
|
||||
| { claudeLeadBoundaryChildOnly?: true }
|
||||
| undefined
|
||||
)?.claudeLeadBoundaryChildOnly
|
||||
).toBeUndefined()
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achilda' })
|
||||
)
|
||||
expect(
|
||||
(
|
||||
firstServer._getStateForTests().lastStatusByPaneKey.get(PANE) as
|
||||
| { claudeLeadBoundaryChildOnly?: true }
|
||||
| undefined
|
||||
)?.claudeLeadBoundaryChildOnly
|
||||
).toBeUndefined()
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'achildb' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a restored lead boundary unconfirmed when background work was running', async () => {
|
||||
const firstServer = new AgentHookServer()
|
||||
await firstServer.start({ env: 'production', userDataPath })
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'UserPromptSubmit', prompt: 'finish background work' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({ hook_event_name: 'SubagentStart', agent_id: 'arestored-child' })
|
||||
)
|
||||
await postHookEvent(
|
||||
firstServer,
|
||||
buildBody({
|
||||
hook_event_name: 'Stop',
|
||||
background_tasks: [
|
||||
{ id: 'arestored-child', type: 'subagent', status: 'running' },
|
||||
RUNNING_SHELL
|
||||
]
|
||||
})
|
||||
)
|
||||
firstServer.flushStatusPersistSync()
|
||||
firstServer.stop()
|
||||
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
try {
|
||||
await postHookEvent(
|
||||
server,
|
||||
buildBody({ hook_event_name: 'SubagentStop', agent_id: 'arestored-child' })
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
restoredUnconfirmed: true
|
||||
})
|
||||
expect(server.getStatusChangeSnapshot()[0]?.observedInCurrentRuntime).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { AgentStatusObservation } from '../../shared/agent-status-observation'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: getCohortAtEmitMock }))
|
||||
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE = makePaneKey('tab-1', LEAF)
|
||||
const CONNECTION = 'ssh-provenance'
|
||||
|
||||
type Observed = { paneKey: string; observation?: AgentStatusObservation }
|
||||
|
||||
function collectObservations(server: AgentHookServer): Observed[] {
|
||||
const seen: Observed[] = []
|
||||
server.setListener((payload) => {
|
||||
seen.push({ paneKey: payload.paneKey, observation: payload.observation })
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
function lastObservation(seen: Observed[]): AgentStatusObservation {
|
||||
const observation = seen.at(-1)?.observation
|
||||
if (!observation) {
|
||||
throw new Error('expected the last emitted status to carry an observation')
|
||||
}
|
||||
return observation
|
||||
}
|
||||
|
||||
describe('agent status observation provenance', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetCachesForTests()
|
||||
getCohortAtEmitMock.mockReturnValue({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function newServer(): AgentHookServer {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
return server
|
||||
}
|
||||
|
||||
it('stamps relayed hook events as hook-origin transitions', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'PostToolUse',
|
||||
payload: { state: 'working', prompt: 'relayed', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
|
||||
expect(lastObservation(seen)).toMatchObject({
|
||||
origin: 'hook',
|
||||
kind: 'transition',
|
||||
authorityId: expect.stringMatching(/^main-agent-hooks:/),
|
||||
observedAt: expect.any(Number)
|
||||
})
|
||||
expect(lastObservation(seen).boundary).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stamps main-parsed OSC 9999 rows as osc-origin snapshots', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestTerminalStatus({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
payload: { state: 'working', prompt: 'from bytes', agentType: 'codex' }
|
||||
})
|
||||
|
||||
expect(lastObservation(seen)).toMatchObject({ origin: 'osc', kind: 'snapshot' })
|
||||
})
|
||||
|
||||
it('stamps a relay replay as a snapshot, not a fresh transition', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
isReplay: true,
|
||||
payload: { state: 'working', prompt: 'replayed', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
|
||||
expect(lastObservation(seen).kind).toBe('snapshot')
|
||||
})
|
||||
|
||||
it('marks a resume-identity refresh as identity-only rather than a turn transition', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'pi',
|
||||
hookEventName: 'before_agent_start',
|
||||
providerSessionOnly: true,
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'pi-session-1',
|
||||
transcriptPath: '/tmp/pi-session-1.json'
|
||||
},
|
||||
payload: { state: 'done', prompt: '', agentType: 'pi' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
|
||||
expect(lastObservation(seen).kind).toBe('identity-only')
|
||||
})
|
||||
|
||||
// Why: this is the whole point of stamping boundary from the listener's own classifier.
|
||||
// `before_agent_start`, `BeforeAgent` and `agent.start` are NOT the two raw literals the
|
||||
// retired-pane gate matches, so a second hand-written list here would report them as
|
||||
// non-boundaries — exactly the defect PR #14626 fixed one instance of.
|
||||
it.each([
|
||||
{ source: 'claude', hookEventName: 'UserPromptSubmit', agentType: 'claude' },
|
||||
{ source: 'pi', hookEventName: 'before_agent_start', agentType: 'pi' },
|
||||
{ source: 'gemini', hookEventName: 'BeforeAgent', agentType: 'gemini' },
|
||||
{ source: 'amp', hookEventName: 'agent.start', agentType: 'amp' },
|
||||
{ source: 'cursor', hookEventName: 'beforeSubmitPrompt', agentType: 'cursor' }
|
||||
])(
|
||||
'stamps boundary for $source $hookEventName even though it is not a gate literal',
|
||||
({ source, hookEventName, agentType }) => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source,
|
||||
hookEventName,
|
||||
payload: { state: 'working', prompt: 'a new turn', agentType }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
|
||||
expect(lastObservation(seen).boundary).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not stamp boundary for a mid-turn event of a boundary-capable provider', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'gemini',
|
||||
hookEventName: 'PostToolUse',
|
||||
payload: { state: 'working', prompt: 'mid turn', agentType: 'gemini' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
|
||||
expect(lastObservation(seen).boundary).toBeUndefined()
|
||||
})
|
||||
|
||||
it('advances revision per accepted observation and bumps incarnation when a retired pane restarts', () => {
|
||||
const server = newServer()
|
||||
const seen = collectObservations(server)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'first turn', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
const first = lastObservation(seen)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'Stop',
|
||||
payload: { state: 'done', prompt: 'first turn', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
const second = lastObservation(seen)
|
||||
|
||||
expect(second.revision).toBeGreaterThan(first.revision)
|
||||
expect(second.incarnation).toBe(first.incarnation)
|
||||
|
||||
server.retirePaneAuthority(PANE)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'reused pane', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
const restarted = lastObservation(seen)
|
||||
|
||||
expect(restarted.incarnation).toBeGreaterThan(second.incarnation)
|
||||
expect(restarted.revision).toBeGreaterThan(second.revision)
|
||||
})
|
||||
|
||||
it('never persists the observation to last-status.json', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-observation-'))
|
||||
try {
|
||||
const server = newServer()
|
||||
await server.start({ env: 'production', userDataPath })
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'claude',
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
payload: { state: 'working', prompt: 'persist me', agentType: 'claude' }
|
||||
},
|
||||
CONNECTION
|
||||
)
|
||||
server.flushStatusPersistSync()
|
||||
|
||||
const path = join(userDataPath, 'agent-hooks', 'last-status.json')
|
||||
expect(existsSync(path)).toBe(true)
|
||||
const file = readFileSync(path, 'utf8')
|
||||
// Why: the sequencer that issued it dies with the process, so a stored copy could only
|
||||
// rehydrate as an ordering claim from a dead authority.
|
||||
expect(file).not.toContain('observation')
|
||||
expect(JSON.parse(file).entries[PANE].observation).toBeUndefined()
|
||||
} finally {
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// Reproduces #11217's mechanism without an IDS: an authenticated hook POST whose body is cut
|
||||
// short of its own Content-Length. The listener fails open on every request error, so the only
|
||||
// way this stays diagnosable is if the truncation is classified before it is swallowed.
|
||||
import { connect } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { HookTransportInterferenceReport } from '../../shared/agent-hook-transport-interference'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
async function postTruncatedHook(
|
||||
port: number,
|
||||
token: string,
|
||||
options: { pathname?: string; sentBytes?: string; announcedLength?: number } = {}
|
||||
): Promise<void> {
|
||||
const {
|
||||
pathname = '/hook/claude',
|
||||
sentBytes = 'paneKey=tab',
|
||||
announcedLength = 100_000
|
||||
} = options
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = connect({ port, host: '127.0.0.1' }, () => {
|
||||
socket.write(
|
||||
`POST ${pathname} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: ${announcedLength}\r\n\r\n${sentBytes}`
|
||||
)
|
||||
// Why: an RST mid-body is what an inspecting IDS does; a FIN would be an ordinary client hangup.
|
||||
setTimeout(() => {
|
||||
socket.resetAndDestroy()
|
||||
resolve()
|
||||
}, 20)
|
||||
})
|
||||
socket.on('error', () => {
|
||||
resolve()
|
||||
})
|
||||
socket.setTimeout(2_000, () => {
|
||||
socket.destroy()
|
||||
reject(new Error('truncated post never connected'))
|
||||
})
|
||||
})
|
||||
// Why: the server settles the request on 'close', which lands a tick after the client's reset.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
/** Opens a POST that announces a body and then never sends it, so Orca's own slowloris cap ends it. */
|
||||
async function postStalledHook(port: number, token: string): Promise<void> {
|
||||
const socket = connect({ port, host: '127.0.0.1' })
|
||||
await new Promise<void>((resolve) => socket.on('connect', () => resolve()))
|
||||
socket.write(
|
||||
`POST /hook/claude HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/x-www-form-urlencoded\r\nX-Orca-Agent-Hook-Token: ${token}\r\nContent-Length: 100000\r\n\r\n`
|
||||
)
|
||||
await new Promise<void>((resolve) => {
|
||||
socket.on('close', () => resolve())
|
||||
socket.on('error', () => resolve())
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
|
||||
async function postCompleteHook(port: number, token: string): Promise<void> {
|
||||
const body = 'paneKey=tab%3Aleaf&payload=%7B%7D'
|
||||
const response = await fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
describe('AgentHookServer transport interference', () => {
|
||||
const servers: AgentHookServer[] = []
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers) {
|
||||
server.stop()
|
||||
}
|
||||
servers.length = 0
|
||||
warn.mockClear()
|
||||
})
|
||||
|
||||
async function startServer(): Promise<{
|
||||
server: AgentHookServer
|
||||
port: number
|
||||
token: string
|
||||
reports: HookTransportInterferenceReport[]
|
||||
}> {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
const reports: HookTransportInterferenceReport[] = []
|
||||
server.setTransportInterferenceListener((report) => {
|
||||
reports.push(report)
|
||||
})
|
||||
await server.start()
|
||||
const env = server.buildPtyEnv()
|
||||
return {
|
||||
server,
|
||||
port: Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
token: env.ORCA_AGENT_HOOK_TOKEN,
|
||||
reports
|
||||
}
|
||||
}
|
||||
|
||||
it('reports once after repeated truncated POSTs and names the route', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
await postTruncatedHook(port, token)
|
||||
await postTruncatedHook(port, token)
|
||||
expect(reports).toEqual([])
|
||||
|
||||
await postTruncatedHook(port, token, { pathname: '/hook/codex' })
|
||||
expect(reports).toEqual([
|
||||
{ count: 3, source: 'codex', bytesRead: expect.any(Number), contentLength: 100_000 }
|
||||
])
|
||||
expect(warn.mock.calls.flat().join(' ')).toContain('security software')
|
||||
|
||||
// Why: warn-once — a blocked fleet must not turn every hook event into a log line.
|
||||
await postTruncatedHook(port, token)
|
||||
expect(reports).toHaveLength(1)
|
||||
}, 20_000)
|
||||
|
||||
it('never reports for POSTs that deliver their whole body', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await postCompleteHook(port, token)
|
||||
}
|
||||
|
||||
expect(reports).toEqual([])
|
||||
}, 20_000)
|
||||
|
||||
it('excludes requests the slowloris cap destroyed, so the count stays honest', async () => {
|
||||
const { port, token, reports } = await startServer()
|
||||
|
||||
await postTruncatedHook(port, token)
|
||||
await postTruncatedHook(port, token)
|
||||
// Why: Orca destroys this one itself at HOOK_REQUEST_SLOWLORIS_MS; counting it would make
|
||||
// every stalled agent look like an IDS block.
|
||||
await postStalledHook(port, token)
|
||||
expect(reports).toEqual([])
|
||||
|
||||
await postTruncatedHook(port, token)
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0].count).toBe(3)
|
||||
}, 30_000)
|
||||
|
||||
it('never reports for unauthenticated probes', async () => {
|
||||
const { port, reports } = await startServer()
|
||||
|
||||
// Why: a port scanner is not interference; only a request that cleared the token check can be.
|
||||
await postTruncatedHook(port, 'wrong-token')
|
||||
await postTruncatedHook(port, 'wrong-token')
|
||||
await postTruncatedHook(port, 'wrong-token')
|
||||
|
||||
expect(reports).toEqual([])
|
||||
}, 20_000)
|
||||
})
|
||||
+324
-23
@@ -18,6 +18,7 @@ import {
|
||||
hasCodexTranscriptSubagents,
|
||||
hasPendingAgentResultText,
|
||||
HOOK_REQUEST_SLOWLORIS_MS,
|
||||
isNewTurnEvent,
|
||||
markClaudeLeadTurnInterrupted,
|
||||
markCodexLeadTurnInterrupted,
|
||||
MAX_PANE_KEY_LEN,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
resolveCachedClaudeCompactOwnership,
|
||||
resolveHookSource,
|
||||
preparePendingGrokResultDiscovery,
|
||||
seedClaudeLeadTurnFromPersistedStatus,
|
||||
seedClaudeSubagentRosterFromSnapshots,
|
||||
seedCodexStateFromSnapshot,
|
||||
warnOnHookEnvOrVersionMismatch,
|
||||
@@ -40,6 +42,13 @@ import {
|
||||
type HookListenerState
|
||||
} from '../../shared/agent-hook-listener'
|
||||
import {
|
||||
createHookTransportInterferenceTracker,
|
||||
describeHookTransportInterference,
|
||||
isHookRequestTruncatedError,
|
||||
type HookTransportInterferenceReport
|
||||
} from '../../shared/agent-hook-transport-interference'
|
||||
import {
|
||||
claudeTeammateIdMatchesName,
|
||||
claudeRosterHasRestoredSnapshotSubagent,
|
||||
claudeRosterHasWorkingSubagent,
|
||||
claudeRosterToSnapshots
|
||||
@@ -63,6 +72,12 @@ import {
|
||||
type ParsedAgentStatusPayload,
|
||||
normalizeAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import {
|
||||
AgentStatusObservationSequencer,
|
||||
createAgentStatusAuthorityId,
|
||||
type AgentStatusObservation,
|
||||
type AgentStatusObservationOrigin
|
||||
} from '../../shared/agent-status-observation'
|
||||
import {
|
||||
resolveAgentStatusIdentity,
|
||||
shouldSuppressInheritedTerminalStatus
|
||||
@@ -90,10 +105,14 @@ export type { AgentHookSource }
|
||||
type EnrichedAgentHookEventPayload = AgentHookEventPayload & {
|
||||
receivedAt: number
|
||||
stateStartedAt: number
|
||||
/** Provenance/ordering stamped by this server as the pane authority (STA-4293). Read by nothing yet. */
|
||||
observation?: AgentStatusObservation
|
||||
/** Stamped at hydrate for nonterminal states; never persisted (hydrate re-stamps) and cleared by any accepted live event replacing the entry. */
|
||||
restoredUnconfirmed?: true
|
||||
/** User-hidden resume identity retained solely for destructive liveness checks. */
|
||||
retainedForLiveness?: true
|
||||
/** Persisted proof that a lead boundary was held working only by child agents. */
|
||||
claudeLeadBoundaryChildOnly?: true
|
||||
}
|
||||
|
||||
type NormalizedLocalHook = {
|
||||
@@ -103,7 +122,13 @@ type NormalizedLocalHook = {
|
||||
|
||||
type PersistedAgentHookEventPayload = Omit<
|
||||
EnrichedAgentHookEventPayload,
|
||||
'claudeRunningNonAgentTask' | 'launchToken' | 'promptInteractionKey' | 'restoredUnconfirmed'
|
||||
| 'claudeRunningNonAgentTask'
|
||||
| 'launchToken'
|
||||
| 'promptInteractionKey'
|
||||
| 'restoredUnconfirmed'
|
||||
// Why: revision counters are in-memory and the authority id is regenerated per process, so
|
||||
// a stored observation could only rehydrate as a stale ordering claim from a dead authority.
|
||||
| 'observation'
|
||||
> & {
|
||||
launchTokenHash?: string
|
||||
}
|
||||
@@ -154,6 +179,12 @@ type PaneKeyAliasEntry = {
|
||||
updatedAt: number
|
||||
authorityVerified: boolean
|
||||
}
|
||||
type RetiredPaneAlias = { physicalPaneKey: string; entry: PaneKeyAliasEntry }
|
||||
/** What one retirement fenced, so a re-attach can lift exactly that set and no more. */
|
||||
type RetiredPaneFence = {
|
||||
paneKeys: readonly string[]
|
||||
aliases: readonly RetiredPaneAlias[]
|
||||
}
|
||||
|
||||
// Why: co-located with the endpoint file in userData/agent-hooks/ so hook-server cross-restart artifacts stay together.
|
||||
const LAST_STATUS_FILE_NAME = 'last-status.json'
|
||||
@@ -177,6 +208,7 @@ const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
export const CLOSED_AGENT_STATUS_PANE_KEYS_MAX = 1024
|
||||
export const PANE_KEY_ALIASES_MAX = 1024
|
||||
export const RETIRED_PANE_FENCES_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
@@ -329,7 +361,9 @@ function sanitizeHydratedEntry(
|
||||
compactTrigger,
|
||||
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
|
||||
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
|
||||
teammateName: typeof record.teammateName === 'string' ? record.teammateName : undefined,
|
||||
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
|
||||
claudeLeadBoundaryChildOnly: record.claudeLeadBoundaryChildOnly === true ? true : undefined,
|
||||
providerSession,
|
||||
providerSessionOnly: providerSessionOnly ? true : undefined,
|
||||
retainedForLiveness: retainedForLiveness ? true : undefined,
|
||||
@@ -409,6 +443,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta
|
||||
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
|
||||
...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}),
|
||||
...(entry.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
|
||||
...(entry.observation ? { observation: entry.observation } : {}),
|
||||
...entry.payload
|
||||
}
|
||||
}
|
||||
@@ -468,6 +503,44 @@ function paneCacheKeyMatchesTab(key: string, tabId: string): boolean {
|
||||
return paneCacheKeyTabId(key) === tabId
|
||||
}
|
||||
|
||||
function attachClaudeChildOnlyBoundary(
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
next: AgentHookEventPayload
|
||||
): AgentHookEventPayload & { claudeLeadBoundaryChildOnly?: true } {
|
||||
const establishesBoundary =
|
||||
next.payload.agentType === 'claude' &&
|
||||
(next.hookEventName === 'Stop' || next.hookEventName === 'StopFailure') &&
|
||||
!next.toolAgentId &&
|
||||
next.payload.state === 'working' &&
|
||||
next.payload.subagents?.some((subagent) => subagent.state === 'working') === true &&
|
||||
next.claudeRunningNonAgentTask === false
|
||||
const carriesBoundary =
|
||||
previous?.claudeLeadBoundaryChildOnly === true &&
|
||||
next.payload.agentType === 'claude' &&
|
||||
next.claudeRunningNonAgentTask === false &&
|
||||
(next.toolAgentId !== undefined ||
|
||||
next.hookEventName === 'SubagentStart' ||
|
||||
next.hookEventName === 'SubagentStop' ||
|
||||
next.hookEventName === 'TeammateIdle')
|
||||
return establishesBoundary || carriesBoundary
|
||||
? { ...next, claudeLeadBoundaryChildOnly: true }
|
||||
: next
|
||||
}
|
||||
|
||||
function invalidateClaudeChildOnlyBoundary(
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
next: AgentHookEventPayload
|
||||
): EnrichedAgentHookEventPayload | undefined {
|
||||
if (
|
||||
previous?.claudeLeadBoundaryChildOnly !== true ||
|
||||
attachClaudeChildOnlyBoundary(previous, next).claudeLeadBoundaryChildOnly === true
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
const { claudeLeadBoundaryChildOnly: _boundary, ...withoutBoundary } = previous
|
||||
return withoutBoundary
|
||||
}
|
||||
|
||||
function shouldKeepClaudePermissionVisible(
|
||||
previous: EnrichedAgentHookEventPayload | undefined,
|
||||
next: AgentHookEventPayload
|
||||
@@ -487,6 +560,9 @@ function shouldKeepClaudePermissionVisible(
|
||||
if (next.hasExplicitPrompt === true) {
|
||||
return false
|
||||
}
|
||||
if (isClaudePermissionOwningChildEnding(previous, next)) {
|
||||
return false
|
||||
}
|
||||
if (isClaudePermissionResumingApprovedTool(previous, next)) {
|
||||
return false
|
||||
}
|
||||
@@ -497,6 +573,24 @@ function shouldKeepClaudePermissionVisible(
|
||||
return true
|
||||
}
|
||||
|
||||
function isClaudePermissionOwningChildEnding(
|
||||
previous: EnrichedAgentHookEventPayload,
|
||||
next: AgentHookEventPayload
|
||||
): boolean {
|
||||
const ownerId = previous.toolAgentId?.trim()
|
||||
if (!ownerId) {
|
||||
return false
|
||||
}
|
||||
if (next.hookEventName === 'SubagentStop') {
|
||||
return ownerId === next.toolAgentId?.trim()
|
||||
}
|
||||
return (
|
||||
next.hookEventName === 'TeammateIdle' &&
|
||||
next.teammateName !== undefined &&
|
||||
claudeTeammateIdMatchesName(ownerId, next.teammateName)
|
||||
)
|
||||
}
|
||||
|
||||
function isClaudePermissionResumingApprovedTool(
|
||||
previous: EnrichedAgentHookEventPayload,
|
||||
next: AgentHookEventPayload
|
||||
@@ -618,6 +712,11 @@ export class AgentHookServer {
|
||||
private endpointFileWritten = false
|
||||
// Why: per-instance (not module-level) so tests can spin up multiple servers without state cross-contamination.
|
||||
private state: HookListenerState = createHookListenerState()
|
||||
private onTransportInterference: ((report: HookTransportInterferenceReport) => void) | null = null
|
||||
private transportInterference = createHookTransportInterferenceTracker((report) => {
|
||||
console.warn(describeHookTransportInterference(report))
|
||||
this.onTransportInterference?.(report)
|
||||
})
|
||||
// Why: hydrated rows give UI continuity but aren't evidence of live agent work in this runtime.
|
||||
private runtimeObservedStatusPaneKeys = new Set<string>()
|
||||
private hydratedAuthorityCommitments: readonly AgentHookAuthorityEvidence[] = Object.freeze([])
|
||||
@@ -626,6 +725,10 @@ export class AgentHookServer {
|
||||
private revokedHydratedAuthorityCommitments = new WeakSet<AgentHookAuthorityEvidence>()
|
||||
private currentAuthorityObservations = new Map<string, AgentHookAuthorityEvidence>()
|
||||
private legacyPaneKeyAliases = new Map<string, PaneKeyAliasEntry>()
|
||||
// Why: indexed by every key the retirement fenced, so a re-attach on any of them
|
||||
// (owner, physical, or a deleted alias) finds the same record. Bounded like the maps
|
||||
// it mirrors; an evicted record simply degrades to lifting the key it was handed.
|
||||
private retiredPaneFencesByKey = new Map<string, RetiredPaneFence>()
|
||||
private paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
|
||||
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
|
||||
private lastStatusFilePath: string | null = null
|
||||
@@ -641,6 +744,22 @@ export class AgentHookServer {
|
||||
private connectionTimestampWatermarkById = new Map<string, number>()
|
||||
// Why: skip disk writes when the JSON exactly matches the last write; guards against re-firing trailing timers when nothing changed.
|
||||
private lastWrittenJson: string | null = null
|
||||
// Why: main is the pane authority for local/WSL/SSH panes — hook HTTP, relay, and its own
|
||||
// OSC parse all converge on applyNormalizedStatus, so one sequencer covers every ingress here.
|
||||
private readonly observations = new AgentStatusObservationSequencer(
|
||||
createAgentStatusAuthorityId('main-agent-hooks')
|
||||
)
|
||||
|
||||
/**
|
||||
* Notified once per process when repeated hook POSTs are cut off mid-body (#11217).
|
||||
* Why: the listener fails open on every request error, so without this the only symptom is
|
||||
* agent status quietly going stale — for every runtime at once, since they share this transport.
|
||||
*/
|
||||
setTransportInterferenceListener(
|
||||
listener: ((report: HookTransportInterferenceReport) => void) | null
|
||||
): void {
|
||||
this.onTransportInterference = listener
|
||||
}
|
||||
|
||||
setListener(listener: ((payload: EnrichedAgentHookEventPayload) => void) | null): void {
|
||||
this.onAgentStatus = listener
|
||||
@@ -1031,6 +1150,33 @@ export class AgentHookServer {
|
||||
return 'suppress'
|
||||
}
|
||||
|
||||
// Why: a fence can span tabs (a pane detached into another tab), and legacy numeric
|
||||
// keys never parse as stable ones — resolve both forms so neither slips the tab check.
|
||||
private isClosedAgentStatusTabForPaneKey(paneKey: string): boolean {
|
||||
const tabId =
|
||||
parsePaneKey(paneKey)?.tabId ?? parseLegacyNumericPaneKey(paneKey)?.tabId ?? undefined
|
||||
return tabId !== undefined && this.closedAgentStatusTabIds.has(tabId)
|
||||
}
|
||||
|
||||
private recordRetiredPaneFence(
|
||||
paneKeys: ReadonlySet<string>,
|
||||
aliases: readonly RetiredPaneAlias[]
|
||||
): void {
|
||||
const fence: RetiredPaneFence = { paneKeys: [...paneKeys], aliases }
|
||||
for (const key of paneKeys) {
|
||||
// Delete-then-set keeps the newest fence most-recent so eviction sheds only the oldest.
|
||||
this.retiredPaneFencesByKey.delete(key)
|
||||
this.retiredPaneFencesByKey.set(key, fence)
|
||||
}
|
||||
while (this.retiredPaneFencesByKey.size > RETIRED_PANE_FENCES_MAX) {
|
||||
const oldest = this.retiredPaneFencesByKey.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.retiredPaneFencesByKey.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private markPaneClosedForAgentStatus(paneKey: string): void {
|
||||
this.closedAgentStatusPaneKeys.delete(paneKey)
|
||||
this.closedAgentStatusPaneKeys.add(paneKey)
|
||||
@@ -1142,15 +1288,41 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stamp who observed this event, in what order, on main's clock. Nothing reads it yet
|
||||
* (STA-4293) — it is stamped here because every main-side ingress funnels through
|
||||
* applyNormalizedStatus, so no origin can silently arrive untagged. */
|
||||
private stampObservation(
|
||||
payload: AgentHookEventPayload,
|
||||
origin: AgentStatusObservationOrigin,
|
||||
observedAt: number
|
||||
): AgentStatusObservation {
|
||||
return this.observations.observe(payload.paneKey, {
|
||||
origin,
|
||||
observedAt,
|
||||
// Why: reuse the listener's own per-provider classifier; a second list of raw event-name
|
||||
// literals here would strand the providers whose boundary event is named anything else.
|
||||
boundary:
|
||||
payload.source !== undefined && isNewTurnEvent(payload.source, payload.hookEventName),
|
||||
kind: payload.providerSessionOnly
|
||||
? 'identity-only'
|
||||
: // Why: a replay restates a turn that already happened, and OSC 9999 repaints the
|
||||
// current state rather than announcing a change — neither is a fresh transition.
|
||||
payload.isReplay === true || origin === 'osc'
|
||||
? 'snapshot'
|
||||
: 'transition'
|
||||
})
|
||||
}
|
||||
|
||||
private applyNormalizedStatus(
|
||||
payload: AgentHookEventPayload,
|
||||
onAccepted?: () => void
|
||||
onAccepted?: () => void,
|
||||
origin: AgentStatusObservationOrigin = 'hook'
|
||||
): EnrichedAgentHookEventPayload {
|
||||
if (payload.hookEventName === 'UserPromptSubmit') {
|
||||
// Why: the prompt boundary is authoritative even when text is unchanged; its next OSC working row must not inherit the prior cron/background turn stamp.
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(payload.paneKey)
|
||||
}
|
||||
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
||||
let previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
const connectionClearWatermark = payload.connectionId
|
||||
@@ -1169,7 +1341,10 @@ export class AgentHookServer {
|
||||
if (payload.providerSessionOnly) {
|
||||
// Why: identity-only rows survive replay but must not emit prompt telemetry or a fabricated status.
|
||||
onAccepted?.()
|
||||
const enriched = this.attachStatusTiming(payload, now)
|
||||
const enriched = {
|
||||
...this.attachStatusTiming(payload, now),
|
||||
observation: this.stampObservation(payload, origin, now)
|
||||
}
|
||||
this.clearAssistantMessageRetry(enriched.paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(enriched.paneKey)
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
@@ -1215,6 +1390,17 @@ export class AgentHookServer {
|
||||
: stateReconciledPayload.payload
|
||||
}
|
||||
: stateReconciledPayload
|
||||
const boundaryReconciledPrevious = invalidateClaudeChildOnlyBoundary(
|
||||
previous,
|
||||
rootContextPreservingPayload
|
||||
)
|
||||
if (boundaryReconciledPrevious !== previous) {
|
||||
previous = boundaryReconciledPrevious
|
||||
if (previous) {
|
||||
this.state.lastStatusByPaneKey.set(previous.paneKey, previous)
|
||||
this.scheduleStatusPersist()
|
||||
}
|
||||
}
|
||||
const identity = resolveAgentStatusIdentity({
|
||||
existing: previous
|
||||
? {
|
||||
@@ -1247,6 +1433,7 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
const effectivePayload = attachClaudePermissionToolUseId(previous, identityResolvedPayload)
|
||||
const boundaryAwarePayload = attachClaudeChildOnlyBoundary(previous, effectivePayload)
|
||||
if (previous && shouldKeepClaudePermissionVisible(previous, effectivePayload)) {
|
||||
return previous
|
||||
}
|
||||
@@ -1287,8 +1474,11 @@ export class AgentHookServer {
|
||||
if (!identity.inheritedFromActivePane) {
|
||||
this.maybeTrackAgentPromptSent(effectivePayload, previous)
|
||||
}
|
||||
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
|
||||
const enriched = this.attachStatusTiming(cachedPayload, now)
|
||||
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, boundaryAwarePayload)
|
||||
const enriched = {
|
||||
...this.attachStatusTiming(cachedPayload, now),
|
||||
observation: this.stampObservation(cachedPayload, origin, now)
|
||||
}
|
||||
if (
|
||||
typeof enriched.payload.turnCompletedAt === 'number' &&
|
||||
Number.isFinite(enriched.payload.turnCompletedAt)
|
||||
@@ -1656,15 +1846,18 @@ export class AgentHookServer {
|
||||
retirePaneAuthority(paneKey: string): void {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
const paneKeys = new Set([paneKey, ownerPaneKey])
|
||||
const retiredAliases: RetiredPaneAlias[] = []
|
||||
let aliasChanged = false
|
||||
for (const [physicalPaneKey, entry] of this.legacyPaneKeyAliases) {
|
||||
if (physicalPaneKey === paneKey || entry.stablePaneKey === ownerPaneKey) {
|
||||
this.legacyPaneKeyAliases.delete(physicalPaneKey)
|
||||
retiredAliases.push({ physicalPaneKey, entry })
|
||||
paneKeys.add(physicalPaneKey)
|
||||
paneKeys.add(entry.stablePaneKey)
|
||||
aliasChanged = true
|
||||
}
|
||||
}
|
||||
this.recordRetiredPaneFence(paneKeys, retiredAliases)
|
||||
const authorityChanged = this.revokeHydratedAuthorityForPaneKeys(paneKeys)
|
||||
const hadStatus = [...paneKeys].some((key) => this.state.lastStatusByPaneKey.has(key))
|
||||
for (const key of paneKeys) {
|
||||
@@ -1676,6 +1869,7 @@ export class AgentHookServer {
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
this.currentAuthorityObservations.delete(key)
|
||||
this.promptSentDedupeByPaneKey.delete(key)
|
||||
this.observations.forget(key)
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
@@ -1686,6 +1880,64 @@ export class AgentHookServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: retirement fences a pane and every alias of it, then deletes those aliases.
|
||||
// Lifting only the key we are handed strands the rest — a detached pane's process
|
||||
// keeps posting the key it launched under, so it would stay suppressed forever with
|
||||
// the fence apparently lifted. Replay the recorded fence instead: same key set, same
|
||||
// aliases. Keys and aliases belonging to a closed tab are skipped, so the stronger
|
||||
// claim survives and a live process is never routed back into a closed tab.
|
||||
private restoreRetiredPaneFence(fence: RetiredPaneFence): void {
|
||||
let aliasChanged = false
|
||||
for (const { physicalPaneKey, entry } of fence.aliases) {
|
||||
if (
|
||||
this.isClosedAgentStatusTabForPaneKey(physicalPaneKey) ||
|
||||
this.isClosedAgentStatusTabForPaneKey(entry.stablePaneKey) ||
|
||||
// Why: the pane was rebound in the meantime; the newer alias is the truth.
|
||||
this.legacyPaneKeyAliases.has(physicalPaneKey)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
this.legacyPaneKeyAliases.set(physicalPaneKey, entry)
|
||||
aliasChanged = true
|
||||
}
|
||||
for (const key of fence.paneKeys) {
|
||||
if (this.retiredPaneFencesByKey.get(key) === fence) {
|
||||
this.retiredPaneFencesByKey.delete(key)
|
||||
}
|
||||
}
|
||||
if (aliasChanged) {
|
||||
this.boundPaneKeyAliases()
|
||||
this.notifyPaneKeyAliasPersistenceListener()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: retirement is a claim that a pane is gone. Re-attaching a live PTY to that
|
||||
// exact pane disproves the claim at the moment it stops being true, so the fence
|
||||
// lifts here instead of waiting for the agent to speak again — an agent re-attached
|
||||
// mid-turn or left idle would otherwise stay suppressed for the rest of its life
|
||||
// (STA-4114). A closed *tab* is a separate, stronger claim and is left standing.
|
||||
restorePaneAuthority(paneKey: string): boolean {
|
||||
const ownerPaneKey = this.resolvePaneKeyAlias(paneKey)
|
||||
if (this.isClosedAgentStatusTabForPaneKey(ownerPaneKey)) {
|
||||
return false
|
||||
}
|
||||
const fence =
|
||||
this.retiredPaneFencesByKey.get(paneKey) ?? this.retiredPaneFencesByKey.get(ownerPaneKey)
|
||||
let restored = false
|
||||
for (const key of new Set([paneKey, ownerPaneKey, ...(fence?.paneKeys ?? [])])) {
|
||||
if (this.isClosedAgentStatusTabForPaneKey(key)) {
|
||||
continue
|
||||
}
|
||||
if (this.closedAgentStatusPaneKeys.delete(key)) {
|
||||
restored = true
|
||||
}
|
||||
}
|
||||
if (fence) {
|
||||
this.restoreRetiredPaneFence(fence)
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
clearPaneKeyAliasesForPty(
|
||||
ptyId: string,
|
||||
options?: { shouldClearStablePaneKey?: (paneKey: string) => boolean }
|
||||
@@ -1858,6 +2110,14 @@ export class AgentHookServer {
|
||||
const previous = this.state.lastStatusByPaneKey.get(paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
if (
|
||||
previous?.claudeLeadBoundaryChildOnly === true &&
|
||||
previous.payload.agentType === 'claude' &&
|
||||
event.payload.agentType === 'claude'
|
||||
) {
|
||||
// Why: OSC has no child identity or lead boundary, so it cannot replace a persisted child-only proof before the lifecycle hook arrives.
|
||||
return
|
||||
}
|
||||
const preserveActiveTurnStamp =
|
||||
previous?.payload.turnCompletedAt !== undefined &&
|
||||
previous.payload.turnCompletedAt === this.activeHookTurnCompletedAtByPaneKey.get(paneKey)
|
||||
@@ -1889,14 +2149,18 @@ export class AgentHookServer {
|
||||
? previous.providerSession
|
||||
: undefined
|
||||
// Why: OSC status is a runtime observation, not a prompt boundary; keep prompt-sent telemetry tied to native hooks.
|
||||
this.applyNormalizedStatus({
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
|
||||
payload: event.payload
|
||||
})
|
||||
this.applyNormalizedStatus(
|
||||
{
|
||||
paneKey,
|
||||
tabId,
|
||||
worktreeId,
|
||||
connectionId,
|
||||
...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}),
|
||||
payload: event.payload
|
||||
},
|
||||
undefined,
|
||||
'osc'
|
||||
)
|
||||
}
|
||||
|
||||
/** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */
|
||||
@@ -1916,6 +2180,7 @@ export class AgentHookServer {
|
||||
compactTrigger?: unknown
|
||||
toolUseId?: string
|
||||
toolAgentId?: string
|
||||
teammateName?: string
|
||||
toolAgentType?: string
|
||||
providerSession?: unknown
|
||||
providerSessionOnly?: unknown
|
||||
@@ -1990,6 +2255,12 @@ export class AgentHookServer {
|
||||
if (statusDisposition === 'suppress') {
|
||||
return
|
||||
}
|
||||
if (statusDisposition === 'restart') {
|
||||
// Why: same rebind as the HTTP path — a retired pane taking a new turn is a new session.
|
||||
// Why paneKey, not envelope.paneKey: alias resolution already mapped it to the
|
||||
// stable pane, so the rebind cannot land on a legacy key.
|
||||
this.observations.rebind(paneKey)
|
||||
}
|
||||
const worktreeId =
|
||||
envelope.worktreeId !== undefined && envelope.worktreeId.trim().length > 0
|
||||
? envelope.worktreeId.trim()
|
||||
@@ -2007,6 +2278,10 @@ export class AgentHookServer {
|
||||
typeof envelope.toolAgentId === 'string' && envelope.toolAgentId.trim().length > 0
|
||||
? envelope.toolAgentId.trim()
|
||||
: undefined
|
||||
const teammateName =
|
||||
typeof envelope.teammateName === 'string' && envelope.teammateName.trim().length > 0
|
||||
? envelope.teammateName.trim()
|
||||
: undefined
|
||||
const toolAgentType =
|
||||
typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0
|
||||
? envelope.toolAgentType.trim()
|
||||
@@ -2093,6 +2368,7 @@ export class AgentHookServer {
|
||||
compactTrigger,
|
||||
toolUseId,
|
||||
toolAgentId,
|
||||
teammateName,
|
||||
toolAgentType,
|
||||
providerSession,
|
||||
providerSessionOnly: envelope.providerSessionOnly === true ? true : undefined,
|
||||
@@ -2160,13 +2436,16 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
// Why: bound request time so a stalled client can't hold a socket open (slowloris).
|
||||
// Why: track our own destroy so the slowloris cap can't be misread as outside interference.
|
||||
let destroyedBySlowlorisCap = false
|
||||
req.setTimeout(HOOK_REQUEST_SLOWLORIS_MS, () => {
|
||||
destroyedBySlowlorisCap = true
|
||||
req.destroy()
|
||||
})
|
||||
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
try {
|
||||
const body = await readRequestBody(req)
|
||||
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
||||
if (pathname === CLAUDE_STATUSLINE_PATHNAME) {
|
||||
const statusLineEvent = parseClaudeStatusLineBody(body)
|
||||
if (statusLineEvent) {
|
||||
@@ -2197,6 +2476,11 @@ export class AgentHookServer {
|
||||
statusDisposition === 'restart'
|
||||
? { ...normalized.event, launchToken: undefined }
|
||||
: normalized.event
|
||||
if (statusDisposition === 'restart') {
|
||||
// Why: a retired pane accepting a new turn is a different agent session behind the
|
||||
// same key — later observations must not be ordered against the retired one.
|
||||
this.observations.rebind(event.paneKey)
|
||||
}
|
||||
this.recordCurrentAuthorityObservation(event)
|
||||
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
@@ -2205,7 +2489,13 @@ export class AgentHookServer {
|
||||
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why (#11217): an authenticated POST whose body dies short of its own Content-Length was cut
|
||||
// by something on the loopback path, not by a bad payload. Fail open as before, but count it —
|
||||
// this is the one failure mode that silently stops status for every runtime at once.
|
||||
if (isHookRequestTruncatedError(error) && !destroyedBySlowlorisCap) {
|
||||
this.transportInterference.record({ source: resolveHookSource(pathname) ?? null, error })
|
||||
}
|
||||
// Why: fail open — return success on malformed payloads so a broken hook never blocks the agent.
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
@@ -2272,6 +2562,7 @@ export class AgentHookServer {
|
||||
this.promptSentDedupeByPaneKey.clear()
|
||||
this.closedAgentStatusTabIds.clear()
|
||||
this.closedAgentStatusPaneKeys.clear()
|
||||
this.retiredPaneFencesByKey.clear()
|
||||
this.connectionTimestampWatermarkById.clear()
|
||||
this.legacyPaneKeyAliases.clear()
|
||||
clearAllListenerCaches(this.state)
|
||||
@@ -2718,12 +3009,17 @@ export class AgentHookServer {
|
||||
// Why: restore live child hierarchy immediately; provider-specific reconciliation reaps stale seeds.
|
||||
if (entry.payload.agentType === 'codex') {
|
||||
seedCodexStateFromSnapshot(this.state, resolvedPaneKey, entry.payload)
|
||||
} else if (entry.payload.agentType === 'claude' && entry.payload.subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
entry.payload.subagents
|
||||
)
|
||||
} else if (entry.payload.agentType === 'claude') {
|
||||
seedClaudeLeadTurnFromPersistedStatus(this.state, resolvedPaneKey, entry, {
|
||||
childOnlyBoundary: entry.claudeLeadBoundaryChildOnly === true
|
||||
})
|
||||
if (entry.payload.subagents) {
|
||||
seedClaudeSubagentRosterFromSnapshots(
|
||||
this.state,
|
||||
resolvedPaneKey,
|
||||
entry.payload.subagents
|
||||
)
|
||||
}
|
||||
}
|
||||
hydrated += 1
|
||||
} else {
|
||||
@@ -2819,19 +3115,24 @@ export class AgentHookServer {
|
||||
if (!isValidPaneKey(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const enrichedPayload = payload as EnrichedAgentHookEventPayload
|
||||
const childOnlyBoundary = enrichedPayload.claudeLeadBoundaryChildOnly === true
|
||||
const {
|
||||
claudeRunningNonAgentTask: _claudeRunningNonAgentTask,
|
||||
promptInteractionKey: _promptInteractionKey,
|
||||
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
|
||||
restoredUnconfirmed: _restoredUnconfirmed,
|
||||
// Why: same — the sequencer that issued it dies with the process (see PersistedAgentHookEventPayload).
|
||||
observation: _observation,
|
||||
launchToken,
|
||||
...persistedPayload
|
||||
} = payload as EnrichedAgentHookEventPayload
|
||||
} = enrichedPayload
|
||||
const launchTokenHash = launchToken?.trim()
|
||||
? createHash('sha256').update(launchToken.trim()).digest('hex')
|
||||
: this.hydratedLaunchTokenHashByPaneKey.get(paneKey)
|
||||
entries[paneKey] = {
|
||||
...persistedPayload,
|
||||
...(childOnlyBoundary ? { claudeLeadBoundaryChildOnly: true } : {}),
|
||||
...(launchTokenHash ? { launchTokenHash } : {})
|
||||
}
|
||||
const commitment = this.toAuthorityEvidence(payload, launchTokenHash)
|
||||
|
||||
@@ -12,8 +12,12 @@ export const ANTIGRAVITY_EVENTS = [
|
||||
windowsWrapperFileName: 'antigravity-post-invocation.cmd'
|
||||
},
|
||||
{ eventName: 'Stop', schema: 'direct', windowsWrapperFileName: 'antigravity-stop.cmd' },
|
||||
// Why: Antigravity requires PreToolUse hooks to make permission decisions.
|
||||
// Orca's hook is observational, so installing there can block user tools.
|
||||
// PreToolUse is the only pre-tool signal Antigravity emits; without it panes show a bare "Working" spinner (#12898).
|
||||
{
|
||||
eventName: 'PreToolUse',
|
||||
schema: 'tool',
|
||||
windowsWrapperFileName: 'antigravity-pre-tool-use.cmd'
|
||||
},
|
||||
{
|
||||
eventName: 'PostToolUse',
|
||||
schema: 'tool',
|
||||
@@ -21,6 +25,10 @@ export const ANTIGRAVITY_EVENTS = [
|
||||
}
|
||||
] as const
|
||||
|
||||
// Why: Antigravity requires a decision on PreToolUse and reads silence as deny (#2426). Of the documented values
|
||||
// only "ask" defers to the user's permission config — "allow" would auto-approve every tool call Orca observes.
|
||||
export const ANTIGRAVITY_PRE_TOOL_USE_DECISION = '{"decision":"ask"}'
|
||||
|
||||
export type AntigravityEvent = (typeof ANTIGRAVITY_EVENTS)[number]
|
||||
|
||||
export const ANTIGRAVITY_MANAGED_SCRIPT_FILE_NAMES = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildWindowsHookStdinDrainEpilogue,
|
||||
WINDOWS_HOOK_STDIN_DRAIN_COMMAND
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
import { ANTIGRAVITY_PRE_TOOL_USE_DECISION } from './hook-events'
|
||||
|
||||
export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
@@ -12,6 +13,8 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
'setlocal',
|
||||
'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (',
|
||||
' echo {"decision":""}',
|
||||
') else if /I "%ORCA_ANTIGRAVITY_EVENT%"=="PreToolUse" (',
|
||||
` echo ${ANTIGRAVITY_PRE_TOOL_USE_DECISION}`,
|
||||
') else (',
|
||||
' echo {}',
|
||||
')',
|
||||
@@ -30,10 +33,12 @@ export function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
' Stop)',
|
||||
' printf \'{"decision":""}\\n\'',
|
||||
' ;;',
|
||||
' PreToolUse)',
|
||||
` printf '${ANTIGRAVITY_PRE_TOOL_USE_DECISION}\\n'`,
|
||||
' ;;',
|
||||
' *)',
|
||||
// Why: Antigravity accepts an empty JSON object for passive status hooks;
|
||||
// returning allow/ask/deny from PreToolUse would change the user's tool
|
||||
// permission policy.
|
||||
// only the PreToolUse gate rejects it, and that branch answers above.
|
||||
' printf "{}\\n"',
|
||||
' ;;',
|
||||
'esac',
|
||||
@@ -79,6 +84,8 @@ export function getWindowsWrapperScript(eventName: string): string {
|
||||
')',
|
||||
'if /I "%ORCA_ANTIGRAVITY_EVENT%"=="Stop" (',
|
||||
' echo {"decision":""}',
|
||||
') else if /I "%ORCA_ANTIGRAVITY_EVENT%"=="PreToolUse" (',
|
||||
` echo ${ANTIGRAVITY_PRE_TOOL_USE_DECISION}`,
|
||||
') else (',
|
||||
' echo {}',
|
||||
')',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -25,6 +26,11 @@ const ANTIGRAVITY_PRE_INVOCATION_COMMAND =
|
||||
process.platform === 'win32' ? 'antigravity-pre-invocation.cmd' : 'antigravity-hook.sh'
|
||||
const ANTIGRAVITY_POST_TOOL_USE_COMMAND =
|
||||
process.platform === 'win32' ? 'antigravity-post-tool-use.cmd' : 'antigravity-hook.sh'
|
||||
const ANTIGRAVITY_PRE_TOOL_USE_COMMAND =
|
||||
process.platform === 'win32' ? 'antigravity-pre-tool-use.cmd' : 'antigravity-hook.sh'
|
||||
// Why: the gate decision Orca is allowed to emit — "allow" would auto-approve every observed tool call.
|
||||
const PRE_TOOL_USE_DECISION = '{"decision":"ask"}'
|
||||
const POLICY_OVERRIDING_DECISIONS = ['allow', 'deny', 'force_ask', 'deny_unless_prior_grant']
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
@@ -67,9 +73,12 @@ describe('AntigravityHookService', () => {
|
||||
>
|
||||
}
|
||||
expect(Object.keys(config['orca-status']).sort()).toEqual(
|
||||
['PostInvocation', 'PostToolUse', 'PreInvocation', 'Stop'].sort()
|
||||
['PostInvocation', 'PostToolUse', 'PreInvocation', 'PreToolUse', 'Stop'].sort()
|
||||
)
|
||||
expect(config['orca-status'].PreToolUse[0].matcher).toBe('*')
|
||||
expect(config['orca-status'].PreToolUse[0].hooks?.[0]?.command).toContain(
|
||||
ANTIGRAVITY_PRE_TOOL_USE_COMMAND
|
||||
)
|
||||
expect(config['orca-status'].PreToolUse).toBeUndefined()
|
||||
expect(config['orca-status'].PostToolUse[0].matcher).toBe('*')
|
||||
expect(config['orca-status'].PreInvocation[0].command).toContain(
|
||||
ANTIGRAVITY_PRE_INVOCATION_COMMAND
|
||||
@@ -105,9 +114,85 @@ describe('AntigravityHookService', () => {
|
||||
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
|
||||
}
|
||||
expect(script).toContain('{"decision":""}')
|
||||
expect(script).toContain(PRE_TOOL_USE_DECISION)
|
||||
for (const decision of POLICY_OVERRIDING_DECISIONS) {
|
||||
expect(script).not.toContain(`{"decision":"${decision}"}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('installs Windows event wrappers without nested cmd quoting and removes stale PreToolUse hooks', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'answers the PreToolUse gate with ask so the hook never decides tool permissions',
|
||||
() => {
|
||||
new AntigravityHookService().install()
|
||||
|
||||
const result = spawnSync(
|
||||
'/bin/sh',
|
||||
[join(homeDir, '.orca', 'agent-hooks', 'antigravity-hook.sh')],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_ANTIGRAVITY_EVENT: 'PreToolUse',
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '',
|
||||
ORCA_AGENT_HOOK_PORT: '',
|
||||
ORCA_AGENT_HOOK_TOKEN: '',
|
||||
ORCA_PANE_KEY: ''
|
||||
},
|
||||
input: '{"toolCall":{"name":"run_command","args":{"CommandLine":"ls"}}}',
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
// Why: Antigravity reads silence/`{}` on PreToolUse as a deny (#2426), so the exact payload is the contract.
|
||||
expect(result.stdout).toBe(`${PRE_TOOL_USE_DECISION}\n`)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'keeps answering the PreToolUse gate when the managed script is missing',
|
||||
() => {
|
||||
new AntigravityHookService().install()
|
||||
rmSync(join(homeDir, '.orca', 'agent-hooks'), { recursive: true, force: true })
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(homeDir, '.gemini', 'config', 'hooks.json'), 'utf8')
|
||||
) as { 'orca-status': Record<string, { hooks?: { command: string }[] }[]> }
|
||||
const command = config['orca-status'].PreToolUse[0].hooks?.[0]?.command
|
||||
|
||||
const result = spawnSync('/bin/sh', ['-c', command!], {
|
||||
input: '{"toolCall":{"name":"run_command"}}',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
// Why: hooks.json lives in ~/.gemini and outlives ~/.orca, so a swept script must not deny every tool call.
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe(`${PRE_TOOL_USE_DECISION}\n`)
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'leaves non-gate Antigravity events silent when the managed script is missing',
|
||||
() => {
|
||||
new AntigravityHookService().install()
|
||||
rmSync(join(homeDir, '.orca', 'agent-hooks'), { recursive: true, force: true })
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(join(homeDir, '.gemini', 'config', 'hooks.json'), 'utf8')
|
||||
) as {
|
||||
'orca-status': Record<string, { command?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
const postToolUse = config['orca-status'].PostToolUse[0].hooks?.[0]?.command
|
||||
const preInvocation = config['orca-status'].PreInvocation[0].command
|
||||
|
||||
for (const command of [postToolUse, preInvocation]) {
|
||||
const result = spawnSync('/bin/sh', ['-c', command!], { input: '{}', encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toBe('')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('installs Windows event wrappers without nested cmd quoting and replaces stale PreToolUse hooks', () => {
|
||||
withPlatform('win32', () => {
|
||||
const configPath = join(homeDir, '.gemini', 'config', 'hooks.json')
|
||||
const staleScriptPath = join(
|
||||
@@ -155,18 +240,20 @@ describe('AntigravityHookService', () => {
|
||||
{ matcher?: string; command?: string; hooks?: { command: string }[] }[]
|
||||
>
|
||||
}
|
||||
expect(config['orca-status'].PreToolUse).toBeUndefined()
|
||||
expect(config['orca-status'].PreToolUse).toHaveLength(1)
|
||||
|
||||
const expectedWrappers = {
|
||||
PreInvocation: 'antigravity-pre-invocation.cmd',
|
||||
PostInvocation: 'antigravity-post-invocation.cmd',
|
||||
Stop: 'antigravity-stop.cmd',
|
||||
PreToolUse: 'antigravity-pre-tool-use.cmd',
|
||||
PostToolUse: 'antigravity-post-tool-use.cmd'
|
||||
}
|
||||
for (const [eventName, wrapperFileName] of Object.entries(expectedWrappers)) {
|
||||
const definition = config['orca-status'][eventName][0]
|
||||
const command =
|
||||
eventName === 'PostToolUse' ? definition.hooks?.[0]?.command : definition.command
|
||||
const command = ['PreToolUse', 'PostToolUse'].includes(eventName)
|
||||
? definition.hooks?.[0]?.command
|
||||
: definition.command
|
||||
expect(createManagedCommandMatcher(wrapperFileName)(command)).toBe(true)
|
||||
expect(command).not.toContain('cmd /d /s /c')
|
||||
expect(command).not.toContain('ORCA_ANTIGRAVITY_EVENT')
|
||||
@@ -174,6 +261,13 @@ describe('AntigravityHookService', () => {
|
||||
const wrapper = readFileSync(join(homeDir, '.orca', 'agent-hooks', wrapperFileName), 'utf8')
|
||||
expect(wrapper).toContain(`set "ORCA_ANTIGRAVITY_EVENT=${eventName}"`)
|
||||
expect(wrapper).toContain('call "%ORCA_ANTIGRAVITY_CORE%"')
|
||||
// Why: the wrapper is the stdin owner when the core script is gone, so it must answer the gate itself.
|
||||
if (eventName === 'PreToolUse') {
|
||||
expect(wrapper).toContain(`echo ${PRE_TOOL_USE_DECISION}`)
|
||||
}
|
||||
for (const decision of POLICY_OVERRIDING_DECISIONS) {
|
||||
expect(wrapper).not.toContain(`{"decision":"${decision}"}`)
|
||||
}
|
||||
}
|
||||
|
||||
const script = readFileSync(
|
||||
@@ -254,7 +348,15 @@ describe('AntigravityHookService', () => {
|
||||
'orca-status': Record<string, { command?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
expect(config['orca-status'].OldEvent).toBeUndefined()
|
||||
expect(config['orca-status'].PreToolUse).toBeUndefined()
|
||||
// Why: the pre-a480e6b7 PreToolUse entry pointed at a script with no gate branch; it must be replaced, not kept.
|
||||
const preToolCommands = config['orca-status'].PreToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
expect(preToolCommands).toHaveLength(1)
|
||||
expect(preToolCommands[0]).toContain(
|
||||
join(homeDir, '.orca', 'agent-hooks', ANTIGRAVITY_PRE_TOOL_USE_COMMAND)
|
||||
)
|
||||
expect(preToolCommands[0]).not.toContain('/tmp/old/agent-hooks/antigravity-hook.sh')
|
||||
const commands = config['orca-status'].PostToolUse.flatMap((definition) =>
|
||||
(definition.hooks ?? []).map((hook) => hook.command)
|
||||
)
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
|
||||
import { ANTIGRAVITY_EVENTS, type AntigravityEvent } from './hook-events'
|
||||
import {
|
||||
ANTIGRAVITY_EVENTS,
|
||||
ANTIGRAVITY_PRE_TOOL_USE_DECISION,
|
||||
type AntigravityEvent
|
||||
} from './hook-events'
|
||||
import { getManagedScript, getWindowsWrapperScript } from './hook-script'
|
||||
import {
|
||||
buildInstalledConfig,
|
||||
@@ -46,11 +50,20 @@ function getWindowsWrapperScriptPath(event: AntigravityEvent): string {
|
||||
return getSharedManagedScriptPath(event.windowsWrapperFileName)
|
||||
}
|
||||
|
||||
function getPosixManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
return wrapPosixHookCommand(
|
||||
scriptPath,
|
||||
{ ORCA_ANTIGRAVITY_EVENT: event.eventName },
|
||||
// Why: a missing managed script must not brick tools; the guard answers PreToolUse itself instead of staying silent.
|
||||
event.eventName === 'PreToolUse' ? { fallbackStdout: ANTIGRAVITY_PRE_TOOL_USE_DECISION } : {}
|
||||
)
|
||||
}
|
||||
|
||||
function getManagedCommand(scriptPath: string, event: AntigravityEvent): string {
|
||||
if (process.platform === 'win32') {
|
||||
return wrapWindowsCmdHookCommand(getWindowsWrapperScriptPath(event))
|
||||
}
|
||||
return wrapPosixHookCommand(scriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName })
|
||||
return getPosixManagedCommand(scriptPath, event)
|
||||
}
|
||||
|
||||
export class AntigravityHookService {
|
||||
@@ -174,8 +187,7 @@ export class AntigravityHookService {
|
||||
|
||||
buildInstalledConfig(
|
||||
config,
|
||||
(event) =>
|
||||
wrapPosixHookCommand(remoteScriptPath, { ORCA_ANTIGRAVITY_EVENT: event.eventName }),
|
||||
(event) => getPosixManagedCommand(remoteScriptPath, event),
|
||||
createAntigravityManagedCommandMatcher()
|
||||
)
|
||||
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { Cookie } from 'electron'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
removeTransplantableCookies,
|
||||
type CookieClearIdentity,
|
||||
type CookieClearSession
|
||||
} from './browser-cookie-import-clear'
|
||||
|
||||
/**
|
||||
* A jar that actually holds cookies.
|
||||
*
|
||||
* Why this file exists: every end-to-end fixture in this module starts with `cookies.get` returning
|
||||
* `[]`. Against an empty jar, "clear then write all" and "clear then write some" produce the same
|
||||
* final state, so the STA-4300 P0 — clearing a populated jar and writing back a strict subset —
|
||||
* was invisible to four independent gates. These tests start populated on purpose.
|
||||
*/
|
||||
function jar(initial: Cookie[]) {
|
||||
let cookies = [...initial]
|
||||
const clearDataMock = vi.fn(async () => {
|
||||
// Why: the real bulk clear removes everything it is not told to exclude.
|
||||
cookies = []
|
||||
})
|
||||
const removeMock = vi.fn(async (url: string, name: string) => {
|
||||
const host = new URL(url).hostname
|
||||
cookies = cookies.filter(
|
||||
(c) => !(c.name === name && (c.domain ?? '').replace(/^\./, '') === host.replace(/^\./, ''))
|
||||
)
|
||||
})
|
||||
const session: CookieClearSession = {
|
||||
cookies: {
|
||||
get: async () => [...cookies],
|
||||
remove: removeMock
|
||||
},
|
||||
clearData: clearDataMock,
|
||||
snapshotClearIdentities: async (items) =>
|
||||
items.map(({ cookie, url }) => ({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path
|
||||
})) as CookieClearIdentity[],
|
||||
restoreClearIdentities: async () => undefined
|
||||
} as unknown as CookieClearSession
|
||||
return {
|
||||
session,
|
||||
clearDataMock,
|
||||
removeMock,
|
||||
names: () => cookies.map((c) => c.name).sort(),
|
||||
get: (name: string) => cookies.find((c) => c.name === name)
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = (domain: string, name: string, value = `${name}-live`): Cookie =>
|
||||
({ domain, name, value, path: '/', secure: true }) as Cookie
|
||||
|
||||
describe('removeTransplantableCookies — preserved families on a POPULATED jar', () => {
|
||||
it('clears everything when nothing is preserved (unchanged behaviour)', async () => {
|
||||
const target = jar([
|
||||
cookie('.mixed.example', 'live-session'),
|
||||
cookie('.other.example', 'stale')
|
||||
])
|
||||
|
||||
await removeTransplantableCookies(target.session)
|
||||
|
||||
expect(target.names()).toEqual([])
|
||||
// Why: the ordinary import must keep using the single bulk call.
|
||||
expect(target.clearDataMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('leaves a preserved family untouched while clearing everything else', async () => {
|
||||
const target = jar([
|
||||
cookie('.mixed.example', 'apex-session'),
|
||||
cookie('sub.mixed.example', 'sub-session'),
|
||||
cookie('.other.example', 'stale')
|
||||
])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['mixed.example']))
|
||||
|
||||
// The whole family survives — apex AND subdomain — and it survives byte-identically.
|
||||
expect(target.names()).toEqual(['apex-session', 'sub-session'])
|
||||
expect(target.get('apex-session')?.value).toBe('apex-session-live')
|
||||
expect(target.get('sub-session')?.value).toBe('sub-session-live')
|
||||
})
|
||||
|
||||
it('never calls bulk clearData when a family is preserved', async () => {
|
||||
// Why (§4.3a): clearData removes everything outside excludeOrigins and its own contract admits
|
||||
// a rejection may already have emptied part of the jar. A preserved family is deliberately
|
||||
// absent from the snapshot, so a partial delete then a rejection would destroy it with nothing
|
||||
// able to restore it. The only safe answer is not to call it.
|
||||
const target = jar([
|
||||
cookie('.mixed.example', 'live-session'),
|
||||
cookie('.other.example', 'stale')
|
||||
])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['mixed.example']))
|
||||
|
||||
expect(target.clearDataMock).not.toHaveBeenCalled()
|
||||
expect(target.removeMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never submits a preserved coordinate to a removal', async () => {
|
||||
const target = jar([
|
||||
cookie('.mixed.example', 'apex-session'),
|
||||
cookie('sub.mixed.example', 'sub-session'),
|
||||
cookie('.other.example', 'stale')
|
||||
])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['mixed.example']))
|
||||
|
||||
const removedNames = target.removeMock.mock.calls.map((call) => call[1])
|
||||
expect(removedNames).toEqual(['stale'])
|
||||
})
|
||||
|
||||
it('never submits a preserved coordinate to the restore set either', async () => {
|
||||
// Why: the CDP snapshot is taken FROM the removal plan, so filtering the plan filters the
|
||||
// restore set with it. That is what keeps a preserved cookie out of every mutation — including
|
||||
// the unconditional Network.setCookie that rollback performs.
|
||||
const snapshotted: string[] = []
|
||||
const target = jar([
|
||||
cookie('.mixed.example', 'live-session'),
|
||||
cookie('.other.example', 'stale')
|
||||
])
|
||||
const session = {
|
||||
...target.session,
|
||||
snapshotClearIdentities: async (items: { cookie: Cookie; url: string }[]) => {
|
||||
snapshotted.push(...items.map((i) => i.cookie.name))
|
||||
return items.map(({ cookie: c, url }) => ({
|
||||
url,
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path
|
||||
})) as CookieClearIdentity[]
|
||||
}
|
||||
} as unknown as CookieClearSession
|
||||
|
||||
await removeTransplantableCookies(session, new Set(['mixed.example']))
|
||||
|
||||
expect(snapshotted).toEqual(['stale'])
|
||||
})
|
||||
|
||||
it('preserves a family named by an IPv4 literal', async () => {
|
||||
// Why: psl reads 127.0.0.1 as the dotted DNS name '0.1'. If registrableFamily returned that,
|
||||
// the live 127.0.0.1 session would not match the preserve set and would be erased.
|
||||
const target = jar([cookie('127.0.0.1', 'loopback-session'), cookie('.other.example', 'stale')])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['127.0.0.1']))
|
||||
|
||||
expect(target.names()).toEqual(['loopback-session'])
|
||||
})
|
||||
|
||||
it('preserves a single-label host family', async () => {
|
||||
const target = jar([cookie('localhost', 'dev-session'), cookie('.other.example', 'stale')])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['localhost']))
|
||||
|
||||
expect(target.names()).toEqual(['dev-session'])
|
||||
})
|
||||
|
||||
it('does not preserve a different family that merely shares a suffix', async () => {
|
||||
const target = jar([cookie('.kept.example', 'kept'), cookie('.other.example', 'stale')])
|
||||
|
||||
await removeTransplantableCookies(target.session, new Set(['kept.example']))
|
||||
|
||||
expect(target.names()).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('keeps skip-path removals at concurrency eight', async () => {
|
||||
let releaseRemovals: (() => void) | undefined
|
||||
const removalsReleased = new Promise<void>((resolve) => {
|
||||
releaseRemovals = resolve
|
||||
})
|
||||
let active = 0
|
||||
let maxActive = 0
|
||||
const remove = vi.fn(async (_url: string, _name: string) => {
|
||||
active++
|
||||
maxActive = Math.max(maxActive, active)
|
||||
await removalsReleased
|
||||
active--
|
||||
})
|
||||
const cookies = [
|
||||
cookie('.preserved.example', 'live-session'),
|
||||
...Array.from({ length: 12 }, (_, index) => cookie('.other.example', `stale-${index}`))
|
||||
]
|
||||
const clearData = vi.fn()
|
||||
const session = {
|
||||
cookies: { get: async () => cookies, remove },
|
||||
clearData,
|
||||
snapshotClearIdentities: async (items: { cookie: Cookie; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry })),
|
||||
restoreClearIdentities: async () => undefined
|
||||
} as unknown as CookieClearSession
|
||||
|
||||
const clearing = removeTransplantableCookies(session, new Set(['preserved.example']))
|
||||
await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8))
|
||||
expect(maxActive).toBe(8)
|
||||
expect(clearData).not.toHaveBeenCalled()
|
||||
releaseRemovals?.()
|
||||
await clearing
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(12)
|
||||
expect(remove.mock.calls.map(([, name]) => name)).not.toContain('live-session')
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,13 @@ const electron = vi.hoisted(() => {
|
||||
destroy = vi.fn()
|
||||
resolveLoad!: () => void
|
||||
webContents = {
|
||||
debugger: { sendCommand: vi.fn(async (_method: string) => ({ cookies: [] })) },
|
||||
debugger: {
|
||||
sendCommand: vi.fn(
|
||||
async (_method: string, _params?: Record<string, unknown>): Promise<unknown> => ({
|
||||
cookies: []
|
||||
})
|
||||
)
|
||||
},
|
||||
isDestroyed: vi.fn(() => false)
|
||||
}
|
||||
|
||||
@@ -77,4 +83,19 @@ describe('cookie clear debugger lifecycle', () => {
|
||||
await expect(store.restoreClearIdentities([])).rejects.toThrow(/store was disposed/)
|
||||
expect(electron.windows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('attempts the whole frozen restore snapshot after one cookie is rejected', async () => {
|
||||
const store = openCookieClearStore(targetSession())
|
||||
const restore = store.restoreClearIdentities([
|
||||
{ url: 'https://first.example/', name: 'first', value: 'one', sameSite: 'unspecified' },
|
||||
{ url: 'https://second.example/', name: 'second', value: 'two', sameSite: 'unspecified' }
|
||||
])
|
||||
const sendCommand = electron.windows[0].webContents.debugger.sendCommand
|
||||
sendCommand.mockResolvedValueOnce({ success: false }).mockResolvedValueOnce({ success: true })
|
||||
|
||||
electron.windows[0].resolveLoad()
|
||||
|
||||
await expect(restore).rejects.toThrow(/restore/i)
|
||||
expect(sendCommand.mock.calls.map(([, params]) => params?.name)).toEqual(['first', 'second'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
cdpRestoreParamsFromIdentity,
|
||||
cdpSetCookieParamsFromIdentity,
|
||||
cookieClearIdentitiesFromCdp
|
||||
} from './browser-cookie-clear-store'
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('cookie clear CDP identities', () => {
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
])
|
||||
expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual(
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[0])).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'chips-auth',
|
||||
sameSite: 'None',
|
||||
@@ -55,6 +55,79 @@ describe('cookie clear CDP identities', () => {
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it('fails closed when a partitioned CDP identity omits its ancestor bit', () => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
}
|
||||
]
|
||||
)
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it('fails closed instead of making an opaque CDP partition restorable as unpartitioned', () => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
partitionKeyOpaque: true
|
||||
}
|
||||
]
|
||||
)
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it.each([null, 1, 'true'])('fails closed on a malformed CDP opaque flag (%s)', (opaque) => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
partitionKeyOpaque: opaque as unknown as boolean
|
||||
}
|
||||
]
|
||||
)
|
||||
).toThrow(/Could not snapshot cookie identity/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ hasCrossSiteAncestor: true },
|
||||
{ topLevelSite: 'not-a-site', hasCrossSiteAncestor: true },
|
||||
{ topLevelSite: 'ftp://top.example', hasCrossSiteAncestor: true },
|
||||
{ topLevelSite: 'https://top.example/path', hasCrossSiteAncestor: true }
|
||||
])('fails closed when a CDP partition key has an invalid site (%o)', (partitionKey) => {
|
||||
expect(() =>
|
||||
cookieClearIdentitiesFromCdp(
|
||||
[{ cookie: chipsCookie, url: 'https://app.acme-chips.test/' }],
|
||||
[
|
||||
{
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
domain: 'app.acme-chips.test',
|
||||
path: '/',
|
||||
partitionKey
|
||||
}
|
||||
]
|
||||
)
|
||||
).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' },
|
||||
@@ -72,8 +145,8 @@ describe('cookie clear CDP identities', () => {
|
||||
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')
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
|
||||
})
|
||||
|
||||
it('indexes CDP cookies once instead of rescanning the jar for every cookie', () => {
|
||||
@@ -99,7 +172,7 @@ describe('cookie clear CDP identities', () => {
|
||||
|
||||
it('does not turn an unspecified SameSite policy into explicit Lax', () => {
|
||||
expect(
|
||||
cdpRestoreParamsFromIdentity({
|
||||
cdpSetCookieParamsFromIdentity({
|
||||
url: 'https://example.com/',
|
||||
name: 'unspecified',
|
||||
value: 'value',
|
||||
|
||||
@@ -4,8 +4,11 @@ import { normalizeCookieDomain } from './browser-cookie-import-policy'
|
||||
import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieClearStore
|
||||
CookieClearStore,
|
||||
CookieImportWriteStore
|
||||
} from './browser-cookie-import-clear'
|
||||
import { restoreEveryCookieIdentity } from './browser-cookie-identity-restore'
|
||||
import { normalizeCookiePartitionSite } from './browser-cookie-source-partition'
|
||||
|
||||
type CdpCookiePartitionKey = {
|
||||
topLevelSite?: string
|
||||
@@ -22,7 +25,8 @@ type CdpCookie = {
|
||||
session?: boolean
|
||||
expires?: number
|
||||
sameSite?: string
|
||||
partitionKey?: CdpCookiePartitionKey
|
||||
partitionKey?: CdpCookiePartitionKey | null
|
||||
partitionKeyOpaque?: boolean
|
||||
}
|
||||
|
||||
type CookieClearDebugger = {
|
||||
@@ -51,25 +55,28 @@ function cdpSameSite(sameSite: Cookie['sameSite']): 'Strict' | 'Lax' | 'None' |
|
||||
}
|
||||
|
||||
function electronSameSite(sameSite: string | undefined): Cookie['sameSite'] {
|
||||
if (sameSite === 'Strict') {
|
||||
return 'strict'
|
||||
}
|
||||
if (sameSite === 'None') {
|
||||
return 'no_restriction'
|
||||
if (sameSite === 'Strict' || sameSite === 'None') {
|
||||
return sameSite === 'Strict' ? 'strict' : 'no_restriction'
|
||||
}
|
||||
return sameSite === 'Lax' ? 'lax' : 'unspecified'
|
||||
}
|
||||
|
||||
function partitionKeyFromCdp(
|
||||
partitionKey: CdpCookiePartitionKey | undefined
|
||||
): CookieClearPartitionKey | undefined {
|
||||
const topLevelSite = partitionKey?.topLevelSite
|
||||
if (!topLevelSite) {
|
||||
function partitionKeyFromCdp(cookie: CdpCookie): CookieClearPartitionKey | undefined {
|
||||
const opaque = cookie.partitionKeyOpaque
|
||||
if (opaque === true || (opaque !== undefined && typeof opaque !== 'boolean')) {
|
||||
throw new Error('Could not snapshot cookie identity for an atomic clear')
|
||||
}
|
||||
const partitionKey = cookie.partitionKey
|
||||
if (partitionKey === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const topLevelSite = normalizeCookiePartitionSite(partitionKey?.topLevelSite ?? '')
|
||||
if (!topLevelSite || typeof partitionKey?.hasCrossSiteAncestor !== 'boolean') {
|
||||
throw new Error('Could not snapshot cookie identity for an atomic clear')
|
||||
}
|
||||
return {
|
||||
topLevelSite,
|
||||
hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor === true
|
||||
hasCrossSiteAncestor: partitionKey.hasCrossSiteAncestor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ function indexCdpCookies(cookies: readonly CdpCookie[]): Map<string, CdpCookie[]
|
||||
}
|
||||
|
||||
function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearIdentity {
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie.partitionKey)
|
||||
const partitionKey = partitionKeyFromCdp(cdpCookie)
|
||||
return {
|
||||
url,
|
||||
name: cdpCookie.name,
|
||||
@@ -120,25 +127,24 @@ function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearId
|
||||
}
|
||||
}
|
||||
|
||||
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>')
|
||||
function openHiddenCookieWindow(targetSession: Session): BrowserWindow {
|
||||
return new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
session: targetSession,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
const contents = existing ?? window?.webContents
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
})
|
||||
}
|
||||
|
||||
async function leaseHiddenCookieDebugger(targetSession: Session): Promise<CookieClearSession> {
|
||||
const window = openHiddenCookieWindow(targetSession)
|
||||
try {
|
||||
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
|
||||
const contents = window.webContents
|
||||
if (contents.isDestroyed()) {
|
||||
throw new Error('Could not attach to the cookie session for an atomic clear')
|
||||
}
|
||||
const lease = acquireElectronDebugger(contents)
|
||||
@@ -146,15 +152,30 @@ async function attachCookieClearSession(targetSession: Session): Promise<CookieC
|
||||
debugger: contents.debugger,
|
||||
dispose: () => {
|
||||
lease.release()
|
||||
window?.destroy()
|
||||
window.destroy()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
window?.destroy()
|
||||
window.destroy()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
|
||||
const existing = findPartitionWebContents(targetSession)
|
||||
if (!existing) {
|
||||
return leaseHiddenCookieDebugger(targetSession)
|
||||
}
|
||||
try {
|
||||
const lease = acquireElectronDebugger(existing)
|
||||
return { debugger: existing.debugger, dispose: () => lease.release() }
|
||||
} catch {
|
||||
// Why (STA-4300): every cookie write now needs this channel, and attaching to a live tab fails
|
||||
// outright when DevTools already owns its debugger. A hidden window of our own always can.
|
||||
return leaseHiddenCookieDebugger(targetSession)
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieClearIdentitiesFromCdp(
|
||||
cookies: readonly { cookie: Cookie; url: string }[],
|
||||
cdpCookies: readonly CdpCookie[]
|
||||
@@ -179,7 +200,7 @@ export function cookieClearIdentitiesFromCdp(
|
||||
match.name,
|
||||
match.domain,
|
||||
match.path,
|
||||
partitionKeyFromCdp(match.partitionKey) ?? null
|
||||
partitionKeyFromCdp(match) ?? null
|
||||
])
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
@@ -191,7 +212,7 @@ export function cookieClearIdentitiesFromCdp(
|
||||
return identities
|
||||
}
|
||||
|
||||
export function cdpRestoreParamsFromIdentity(
|
||||
export function cdpSetCookieParamsFromIdentity(
|
||||
identity: CookieClearIdentity
|
||||
): Record<string, unknown> {
|
||||
const sameSite = cdpSameSite(identity.sameSite)
|
||||
@@ -232,24 +253,25 @@ async function snapshotClearIdentitiesFromCdp(
|
||||
return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result))
|
||||
}
|
||||
|
||||
async function restoreClearIdentitiesWithCdp(
|
||||
async function writeIdentityWithCdp(
|
||||
cookieDebugger: CookieClearDebugger,
|
||||
identities: readonly CookieClearIdentity[]
|
||||
identity: CookieClearIdentity,
|
||||
failureLabel: string
|
||||
): 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}`)
|
||||
}
|
||||
const result = await cookieDebugger.sendCommand(
|
||||
'Network.setCookie',
|
||||
cdpSetCookieParamsFromIdentity(identity)
|
||||
)
|
||||
// Why: Network.setCookie reports rejection in the reply rather than throwing, so an unchecked
|
||||
// call reads as a successful write of a cookie that was never stored.
|
||||
if (!cdpSetCookieSucceeded(result)) {
|
||||
throw new Error(`Could not ${failureLabel} cookie ${identity.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function openCookieClearStore(
|
||||
targetSession: Session
|
||||
): CookieClearStore & { dispose: () => void } {
|
||||
): CookieClearStore & CookieImportWriteStore & { dispose: () => void } {
|
||||
let attached: CookieClearSession | null = null
|
||||
let pendingAttach: Promise<CookieClearSession> | null = null
|
||||
let disposed = false
|
||||
@@ -285,8 +307,14 @@ export function openCookieClearStore(
|
||||
remove: (url, name) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (cookies) =>
|
||||
snapshotClearIdentitiesFromCdp((await attach()).debugger, cookies),
|
||||
restoreClearIdentities: async (identities) =>
|
||||
restoreClearIdentitiesWithCdp((await attach()).debugger, identities),
|
||||
restoreClearIdentities: async (identities) => {
|
||||
const cookieDebugger = (await attach()).debugger
|
||||
await restoreEveryCookieIdentity(identities, (identity) =>
|
||||
writeIdentityWithCdp(cookieDebugger, identity, 'restore')
|
||||
)
|
||||
},
|
||||
writeCookieIdentity: async (identity) =>
|
||||
writeIdentityWithCdp((await attach()).debugger, identity, 'import'),
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
pendingAttach = null
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CookieClearIdentity } from './browser-cookie-import-clear'
|
||||
|
||||
export async function restoreEveryCookieIdentity(
|
||||
identities: readonly CookieClearIdentity[],
|
||||
restore: (identity: CookieClearIdentity) => Promise<void>
|
||||
): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
for (const identity of identities) {
|
||||
try {
|
||||
await restore(identity)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, `Could not restore ${failures.length} cookie(s)`)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
cookieRemovalUrl,
|
||||
isNonTransplantableCookieDomain,
|
||||
NON_TRANSPLANTABLE_CLEAR_EXCLUDED_ORIGINS,
|
||||
normalizeCookieDomain
|
||||
normalizeCookieDomain,
|
||||
registrableFamily
|
||||
} from './browser-cookie-import-policy'
|
||||
|
||||
const COOKIE_CLEAR_CONCURRENCY = 8
|
||||
@@ -35,6 +36,13 @@ export type CookieClearStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
|
||||
}
|
||||
|
||||
// Why (STA-4300): the import writes go through this store, and 'set' stays out of it for the same
|
||||
// reason it stays out of the clear path — cookies.set() drops partitionKey silently, so a CHIPS
|
||||
// cookie imported through it is downgraded on the success path with nothing to report it.
|
||||
export type CookieImportWriteStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
writeCookieIdentity(identity: 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'>
|
||||
@@ -43,7 +51,7 @@ export type CookieClearSession = {
|
||||
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
|
||||
}
|
||||
|
||||
const clearLocks = new WeakMap<object, Promise<void>>()
|
||||
const mutationLocks = new WeakMap<object, Promise<void>>()
|
||||
|
||||
function cookieClearKey(url: string, name: string): string {
|
||||
return JSON.stringify([url, name])
|
||||
@@ -66,17 +74,30 @@ export function identitiesFromClearCookies(
|
||||
}))
|
||||
}
|
||||
|
||||
export async function withCookieClearLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
|
||||
const previous = clearLocks.get(owner) ?? Promise.resolve()
|
||||
/**
|
||||
* Serialises every live-jar mutation for one owner.
|
||||
*
|
||||
* Why (STA-4601): an import's clear, its writes, and its rollback are one transaction. Holding the
|
||||
* lock for the clear alone lets a second import interleave between them, so a stale rollback can
|
||||
* remove cookies the newer import already reported as written. Callers that need the lock across a
|
||||
* try/finally take it directly; callers with a single callback use the wrapper below.
|
||||
*/
|
||||
export async function acquireCookieMutationLock(owner: object): Promise<() => void> {
|
||||
const previous = mutationLocks.get(owner) ?? Promise.resolve()
|
||||
let release!: () => void
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
clearLocks.set(
|
||||
mutationLocks.set(
|
||||
owner,
|
||||
previous.then(() => current)
|
||||
)
|
||||
await previous
|
||||
return release
|
||||
}
|
||||
|
||||
export async function withCookieMutationLock<T>(owner: object, run: () => Promise<T>): Promise<T> {
|
||||
const release = await acquireCookieMutationLock(owner)
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
@@ -84,12 +105,25 @@ export async function withCookieClearLock<T>(owner: object, run: () => Promise<T
|
||||
}
|
||||
}
|
||||
|
||||
function removableCookieEntries(cookies: readonly Cookie[]): { cookie: Cookie; url: string }[] {
|
||||
function removableCookieEntries(
|
||||
cookies: readonly Cookie[],
|
||||
preserveFamilies: ReadonlySet<string>
|
||||
): { cookie: Cookie; url: string }[] {
|
||||
const removable: { cookie: Cookie; url: string }[] = []
|
||||
for (const cookie of cookies) {
|
||||
if (isNonTransplantableCookieDomain(cookie.domain ?? '')) {
|
||||
continue
|
||||
}
|
||||
// Why (STA-4300 I2): a family whose partition could not be read faithfully is neither written
|
||||
// nor removed. Filtering HERE keeps it out of the removal plan and — because the CDP snapshot
|
||||
// is taken from this same list — out of the restore set too, so it is never submitted to any
|
||||
// mutation at all.
|
||||
if (preserveFamilies.size > 0) {
|
||||
const family = registrableFamily(cookie.domain ?? '')
|
||||
if (family !== null && preserveFamilies.has(family)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
|
||||
const url = domain ? cookieRemovalUrl(cookie, domain) : null
|
||||
if (!url) {
|
||||
@@ -144,17 +178,29 @@ async function restoreClearedCookies(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the transplantable cookies from a jar.
|
||||
*
|
||||
* Why (STA-4601): this takes the mutation lock on the object it is PASSED, which serialises direct
|
||||
* callers that hand it a real Session — pinned by "serializes concurrent clears on the same
|
||||
* session" in the atomicity suite. It does NOT serialise the importer, because both import paths
|
||||
* build a fresh adapter object per call, so the key is new every time and this lock is a no-op for
|
||||
* them. That is deliberate and safe: the importer holds the real per-partition lock, keyed on the
|
||||
* Electron Session, across its whole clear-and-write transaction — a scope this function cannot
|
||||
* see. Do not remove the importer's outer lock on the assumption that this one covers it.
|
||||
*/
|
||||
export async function removeTransplantableCookies(
|
||||
targetSession: CookieClearSession
|
||||
targetSession: CookieClearSession,
|
||||
preserveFamilies: ReadonlySet<string> = new Set()
|
||||
): Promise<void> {
|
||||
return withCookieClearLock(targetSession, async () => {
|
||||
return withCookieMutationLock(targetSession, async () => {
|
||||
const store = targetSession.cookies
|
||||
const initialCookies = await store.get({})
|
||||
if (initialCookies.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const initialRemovable = removableCookieEntries(initialCookies)
|
||||
const initialRemovable = removableCookieEntries(initialCookies, preserveFamilies)
|
||||
if (initialRemovable.length === 0) {
|
||||
return
|
||||
}
|
||||
@@ -167,16 +213,26 @@ export async function removeTransplantableCookies(
|
||||
// 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.
|
||||
// Why (STA-4300 §4.3a): bulk clearData removes everything outside excludeOrigins, and its own
|
||||
// contract admits a rejection may already have emptied part of the jar. Handing it a
|
||||
// dynamically derived preserve list cannot be made safe: a partial delete followed by a
|
||||
// rejection would destroy a preserved family with no identity to restore it from, because the
|
||||
// preserved coordinates are deliberately absent from the snapshot. So when anything is
|
||||
// preserved we do not use the bulk path at all — the frozen per-coordinate plan, which already
|
||||
// excludes those families, becomes the primary path. Nothing is preserved on the ordinary
|
||||
// import, so that path keeps today's single clearData call unchanged.
|
||||
if (preserveFamilies.size === 0) {
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
sessionFromPartitionMock,
|
||||
snapshotClearIdentitiesMock,
|
||||
writeCookieIdentityMock,
|
||||
copyFileWithWindowsRetryMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
snapshotClearIdentitiesMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn(),
|
||||
copyFileWithWindowsRetryMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
// Why (STA-4300): both import paths now write through writeCookieIdentity, not cookies.set. A stub
|
||||
// without that method throws a TypeError that writeImportedCookies CATCHES as a write rejection, so
|
||||
// the import would silently take the failure path and the ordering assertions would go vacuous.
|
||||
// Every test below therefore also asserts writeCookieIdentity was actually CALLED.
|
||||
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: snapshotClearIdentitiesMock,
|
||||
restoreClearIdentities: async () => undefined,
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
setPendingCookieImport: setPendingCookieImportMock,
|
||||
clearPendingCookieImport: clearPendingCookieImportMock
|
||||
}
|
||||
}))
|
||||
vi.mock('../codex-accounts/fs-utils', () => ({
|
||||
copyFileWithWindowsRetry: copyFileWithWindowsRetryMock
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
import { acquireCookieMutationLock, withCookieMutationLock } from './browser-cookie-import-clear'
|
||||
|
||||
/**
|
||||
* STA-4601: two imports on one partition must not interleave on the live jar.
|
||||
*
|
||||
* Nothing serialises imports per partition — neither the renderer IPC handler nor the runtime RPC
|
||||
* method — so before this change the lock covered the clear alone and was released before the
|
||||
* writes and the rollback. That let import A's rollback remove cookies import B had already
|
||||
* written and reported as imported.
|
||||
*
|
||||
* These tests drive the lock primitive directly with deterministic interleavings rather than
|
||||
* racing two real imports, so a failure names the ordering rule that broke instead of flaking.
|
||||
*/
|
||||
describe('cookie mutation lock', () => {
|
||||
it('serialises two transactions on the same owner', async () => {
|
||||
const owner = {}
|
||||
const order: string[] = []
|
||||
|
||||
const first = withCookieMutationLock(owner, async () => {
|
||||
order.push('A:clear')
|
||||
await Promise.resolve()
|
||||
order.push('A:write')
|
||||
})
|
||||
// Why: started before A resolves, so an unserialised implementation interleaves here.
|
||||
const second = withCookieMutationLock(owner, async () => {
|
||||
order.push('B:clear')
|
||||
await Promise.resolve()
|
||||
order.push('B:write')
|
||||
})
|
||||
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(order).toEqual(['A:clear', 'A:write', 'B:clear', 'B:write'])
|
||||
})
|
||||
|
||||
it('does not serialise across different owners', async () => {
|
||||
// Why: the lock is per partition. Two different profiles must still import concurrently.
|
||||
const order: string[] = []
|
||||
const a = withCookieMutationLock({}, async () => {
|
||||
order.push('a:start')
|
||||
await Promise.resolve()
|
||||
order.push('a:end')
|
||||
})
|
||||
const b = withCookieMutationLock({}, async () => {
|
||||
order.push('b:start')
|
||||
await Promise.resolve()
|
||||
order.push('b:end')
|
||||
})
|
||||
|
||||
await Promise.all([a, b])
|
||||
|
||||
expect(order).toContain('a:end')
|
||||
expect(order).toContain('b:end')
|
||||
// Interleaved rather than strictly sequential.
|
||||
expect(order).not.toEqual(['a:start', 'a:end', 'b:start', 'b:end'])
|
||||
})
|
||||
|
||||
it('releases the lock when the transaction throws, so the next import is not wedged', async () => {
|
||||
const owner = {}
|
||||
|
||||
await expect(
|
||||
withCookieMutationLock(owner, async () => {
|
||||
throw new Error('clear rejected')
|
||||
})
|
||||
).rejects.toThrow('clear rejected')
|
||||
|
||||
// Why: a failed import must not leave the partition permanently locked.
|
||||
await expect(withCookieMutationLock(owner, async () => 'second ran')).resolves.toBe(
|
||||
'second ran'
|
||||
)
|
||||
})
|
||||
|
||||
it('holds across an explicit acquire/release so a rollback stays inside the transaction', async () => {
|
||||
// Why: path A takes the lock directly rather than through a callback, because its rollback runs
|
||||
// in a finally block. A stale rollback outside the lock is the STA-4601 defect.
|
||||
const owner = {}
|
||||
const order: string[] = []
|
||||
|
||||
const release = await acquireCookieMutationLock(owner)
|
||||
const queued = withCookieMutationLock(owner, async () => {
|
||||
order.push('B:clear')
|
||||
})
|
||||
|
||||
order.push('A:replace')
|
||||
await Promise.resolve()
|
||||
order.push('A:rollback')
|
||||
release()
|
||||
|
||||
await queued
|
||||
|
||||
expect(order).toEqual(['A:replace', 'A:rollback', 'B:clear'])
|
||||
})
|
||||
|
||||
it('leaves the lock usable if a holder releases twice', async () => {
|
||||
const owner = {}
|
||||
const release = await acquireCookieMutationLock(owner)
|
||||
release()
|
||||
release()
|
||||
|
||||
await expect(withCookieMutationLock(owner, async () => 'ok')).resolves.toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The primitive tests above prove the lock works. They would ALL still pass if the importer stopped
|
||||
* calling it, because they never run an import — so on their own they are not a detector for the
|
||||
* bug this change fixes. These tests drive two real concurrent imports into one partition and
|
||||
* assert the second cannot begin until the first has finished writing.
|
||||
*/
|
||||
describe('two concurrent imports into one partition', () => {
|
||||
let tmpDir: string
|
||||
let events: string[]
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let releaseFirstWrite: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-concurrency-'))
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
events = []
|
||||
copyFileWithWindowsRetryMock
|
||||
.mockReset()
|
||||
.mockImplementation((source: string, destination: string) => {
|
||||
copyFileSync(source, destination)
|
||||
})
|
||||
// Why: the clear asserts its snapshot covers the removal plan, so a stub returning [] fails
|
||||
// the import before it ever reaches a write — and the ordering assertion would pass vacuously.
|
||||
snapshotClearIdentitiesMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry }))
|
||||
)
|
||||
|
||||
cookiesGetMock = vi.fn().mockResolvedValue([
|
||||
{ domain: '.a.example', name: 'old-a', value: 'v', path: '/', secure: true },
|
||||
{ domain: '.b.example', name: 'old-b', value: 'v', path: '/', secure: true }
|
||||
])
|
||||
cookiesRemoveMock = vi.fn(async (_url: string, name: string) => {
|
||||
events.push(`remove:${name}`)
|
||||
})
|
||||
const firstWrite = new Promise<void>((resolve) => {
|
||||
releaseFirstWrite = resolve
|
||||
})
|
||||
writeCookieIdentityMock.mockReset().mockImplementation(async (identity: { name: string }) => {
|
||||
events.push(`set:${identity.name}`)
|
||||
// Why: hold the FIRST import inside its write so a second import has a real opportunity to
|
||||
// interleave. Without the lock it takes that opportunity; with it, it waits.
|
||||
if (identity.name === 'new-a') {
|
||||
await firstWrite
|
||||
}
|
||||
})
|
||||
// Why (STA-4300): the importer must never reach cookies.set — it drops partitionKey. Kept as a
|
||||
// guard so a regression back onto it shows up here instead of silently passing.
|
||||
cookiesSetMock = vi.fn()
|
||||
|
||||
// Why: Electron returns the SAME Session instance for a given partition string, which is what
|
||||
// makes a per-session lock a per-partition lock. Returning a fresh object per call here would
|
||||
// make this test pass against an unlocked implementation.
|
||||
const stableSession = {
|
||||
cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock }
|
||||
}
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue(stableSession)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeSource(name: string, domain: string): string {
|
||||
const filePath = join(tmpDir, `${name}.json`)
|
||||
writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify([{ domain, name, value: 'imported', path: '/', secure: true }])
|
||||
)
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('does not let the second import touch the jar until the first has finished writing', async () => {
|
||||
const fileA = writeSource('new-a', '.a.example')
|
||||
const fileB = writeSource('new-b', '.b.example')
|
||||
|
||||
const first = importCookiesFromFile(fileA, 'persist:concurrency-test')
|
||||
// Let the first import reach its write and block there.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = importCookiesFromFile(fileB, 'persist:concurrency-test')
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
// At this instant the first import is still inside its write. Nothing from the second may have
|
||||
// touched the jar yet — this is the assertion that fails if the call sites drop the lock.
|
||||
expect(events).not.toContain('remove:old-b')
|
||||
expect(events).not.toContain('set:new-b')
|
||||
|
||||
releaseFirstWrite()
|
||||
const [resultA, resultB] = await Promise.all([first, second])
|
||||
|
||||
// Why: if either import failed, the ordering assertions below would pass vacuously.
|
||||
expect(resultA.ok).toBe(true)
|
||||
expect(resultB.ok).toBe(true)
|
||||
// Why: a store stub missing writeCookieIdentity throws a TypeError that writeImportedCookies
|
||||
// swallows as a rejection, so BOTH imports would report zero writes while the ordering
|
||||
// assertions still held. Pin that the write path was actually entered, once per import.
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledTimes(2)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
|
||||
// And the full ordering is serial, not interleaved.
|
||||
expect(events).toContain('set:new-a')
|
||||
expect(events).toContain('remove:old-b')
|
||||
expect(events.indexOf('set:new-a')).toBeLessThan(events.indexOf('remove:old-b'))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Path B (native Chromium) has its own lock acquisition, and the file-import test above cannot
|
||||
* reach it. Without this, deleting path B's withCookieMutationLock leaves every test green — which
|
||||
* a review round confirmed by mutation before this was added.
|
||||
*/
|
||||
describe('two concurrent NATIVE imports into one partition', () => {
|
||||
let tmpDir: string
|
||||
let events: string[]
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let releaseFirstWrite: () => void
|
||||
let platformSpy: { mockRestore: () => void }
|
||||
let stagingCopyCount: number
|
||||
|
||||
function chromeBrowser(cookiesPath: string) {
|
||||
return {
|
||||
family: 'chrome' as const,
|
||||
label: 'Google Chrome',
|
||||
cookiesPath,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-native-concurrency-'))
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
events = []
|
||||
stagingCopyCount = 0
|
||||
copyFileWithWindowsRetryMock
|
||||
.mockReset()
|
||||
.mockImplementation((source: string, destination: string) => {
|
||||
if (destination.includes('cookie-import-staging')) {
|
||||
stagingCopyCount += 1
|
||||
events.push(`copy:${stagingCopyCount === 1 ? 'first' : 'second'}`)
|
||||
}
|
||||
copyFileSync(source, destination)
|
||||
})
|
||||
// Why: an EMPTY jar makes removeTransplantableCookies return before it clears, so the clear
|
||||
// would never run and a clearData assertion would pass vacuously. Seed it.
|
||||
snapshotClearIdentitiesMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry }))
|
||||
)
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
|
||||
const firstWrite = new Promise<void>((resolve) => {
|
||||
releaseFirstWrite = resolve
|
||||
})
|
||||
writeCookieIdentityMock.mockReset().mockImplementation(async (identity: { name: string }) => {
|
||||
events.push(`set:${identity.name}`)
|
||||
if (identity.name === 'first') {
|
||||
await firstWrite
|
||||
}
|
||||
})
|
||||
// Why (STA-4300): same guard as the file path — the native writes go through CDP identities.
|
||||
cookiesSetMock = vi.fn()
|
||||
const stableSession = {
|
||||
cookies: {
|
||||
get: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ domain: '.stale.example', name: 'stale', value: 'v', path: '/', secure: true }
|
||||
]),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: cookiesSetMock,
|
||||
flushStore: vi.fn(async () => {
|
||||
events.push('flushStore')
|
||||
})
|
||||
},
|
||||
clearData: vi.fn(async () => {
|
||||
events.push('clearData')
|
||||
}),
|
||||
// Why (STA-4300): the importer asks the Session where its storage lives instead of rebuilding
|
||||
// the path from the partition string, so the stub has to answer.
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'native-conc'),
|
||||
setUserAgent: vi.fn()
|
||||
}
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue(stableSession)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not let a second native import clear while the first is still writing', async () => {
|
||||
const sourceA = join(tmpDir, 'ChromeA', 'Default', 'Network', 'Cookies')
|
||||
const sourceB = join(tmpDir, 'ChromeB', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceA, [
|
||||
{ domain: '.a.example', name: 'first', value: 'v' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(sourceB, [
|
||||
{ domain: '.b.example', name: 'second', value: 'v' }
|
||||
]).close()
|
||||
// Why: a live target DB so the cold-init probe does not run and change the event ordering.
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'native-conc', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
|
||||
// Start both calls before either reaches its write. This makes the detector cover the full
|
||||
// lock boundary: B's staging copy must be blocked too, not merely B's live clear/write.
|
||||
const first = importCookiesFromBrowser(chromeBrowser(sourceA), 'persist:native-conc')
|
||||
const second = importCookiesFromBrowser(chromeBrowser(sourceB), 'persist:native-conc')
|
||||
// Wait until A is parked in its write so the assertions below have a deterministic hold point.
|
||||
for (let i = 0; i < 100 && !events.includes('set:first'); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
expect(events).toContain('set:first')
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
// The first import is parked inside its write. The second must not stage a stale image while
|
||||
// that transaction is active — this assertion fails if the staging copy sits outside the lock.
|
||||
expect(events).not.toContain('copy:second')
|
||||
// Nor may it flush, clear, or write the live jar while that transaction is active.
|
||||
expect(events.filter((e) => e === 'flushStore')).toHaveLength(1)
|
||||
expect(events.filter((e) => e === 'clearData')).toHaveLength(1)
|
||||
expect(events).not.toContain('set:second')
|
||||
|
||||
releaseFirstWrite()
|
||||
const [resultA, resultB] = await Promise.all([first, second])
|
||||
|
||||
// Why: a failed import would make the ordering assertions pass vacuously.
|
||||
expect(resultA.ok).toBe(true)
|
||||
expect(resultB.ok).toBe(true)
|
||||
// Why: same swallowed-TypeError trap as the file path — assert the write path was ATTEMPTED,
|
||||
// once per import, not just that the end state looks right.
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledTimes(2)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(events.indexOf('set:first')).toBeLessThan(events.lastIndexOf('clearData'))
|
||||
expect(events.indexOf('copy:second')).toBeGreaterThan(events.indexOf('set:first'))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The cold-init probe writes and then DELETES https://localhost/__init on the live jar, and an
|
||||
* import can legitimately write that exact coordinate (normalizeCookieImportDomain accepts
|
||||
* `localhost` and deriveUrl produces that URL). Unlocked, the probe's remove() would erase a cookie
|
||||
* a concurrent import had just reported as imported.
|
||||
*
|
||||
* Nothing else in the suite reaches this call site — the other native test pre-creates the target
|
||||
* DB so the probe never runs — so without this, dropping the probe's lock leaves every test green.
|
||||
*/
|
||||
describe('cold-init probe on a partition that has never stored a cookie', () => {
|
||||
let tmpDir: string
|
||||
let events: string[]
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let platformSpy: { mockRestore: () => void }
|
||||
let stableSession: object
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-probe-'))
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
events = []
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
snapshotClearIdentitiesMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie: entry, url }) => ({ url, ...entry }))
|
||||
)
|
||||
writeCookieIdentityMock.mockReset().mockResolvedValue(undefined)
|
||||
|
||||
const partitionDir = join(tmpDir, 'userData', 'Partitions', 'probe-conc')
|
||||
cookiesSetMock = vi.fn(async (cookie: { name: string }) => {
|
||||
events.push(`set:${cookie.name}`)
|
||||
})
|
||||
stableSession = {
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn(async (_url: string, name: string) => {
|
||||
events.push(`remove:${name}`)
|
||||
}),
|
||||
set: cookiesSetMock,
|
||||
// Why: the real probe's flushStore is what creates the file. It must NOT create it on the
|
||||
// earlier pre-staging flush, or the DB already exists when the probe is chosen and the
|
||||
// probe never runs — which is exactly how this detector went vacuous the first time.
|
||||
flushStore: vi.fn(async () => {
|
||||
if (events.includes('set:__init')) {
|
||||
createChromiumCookieTestDatabase(join(partitionDir, 'Network', 'Cookies'), []).close()
|
||||
}
|
||||
})
|
||||
},
|
||||
clearData: vi.fn(async () => {
|
||||
events.push('clearData')
|
||||
}),
|
||||
getStoragePath: () => partitionDir,
|
||||
setUserAgent: vi.fn()
|
||||
}
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue(stableSession)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cannot write or delete __init while another transaction holds the partition', async () => {
|
||||
const source = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(source, [
|
||||
{ domain: '.probe.example', name: 'probe', value: 'v' }
|
||||
]).close()
|
||||
|
||||
// Why: stand in for an import already mid-transaction on this partition.
|
||||
const release = await acquireCookieMutationLock(stableSession)
|
||||
|
||||
const running = importCookiesFromBrowser(
|
||||
{
|
||||
family: 'chrome' as const,
|
||||
label: 'Google Chrome',
|
||||
cookiesPath: source,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
},
|
||||
'persist:probe-conc'
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
// This is the assertion that fails if the probe stops taking the lock.
|
||||
expect(events).not.toContain('set:__init')
|
||||
expect(events).not.toContain('remove:__init')
|
||||
|
||||
release()
|
||||
await running
|
||||
|
||||
// Why: if the import had bailed before the probe, the assertions above would pass vacuously.
|
||||
// Pin that the probe actually ran once released.
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: '__init', url: 'https://localhost' })
|
||||
)
|
||||
expect(events.indexOf('set:__init')).toBeGreaterThanOrEqual(0)
|
||||
expect(events.indexOf('remove:__init')).toBeGreaterThan(events.indexOf('set:__init'))
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
|
||||
// a missing method cannot silently reroute every write down the rejected-cookie path.
|
||||
writeCookieIdentity: (identity: Record<string, unknown>) =>
|
||||
targetSession.cookies.set!(identity),
|
||||
snapshotClearIdentities: async (
|
||||
items: {
|
||||
cookie: {
|
||||
@@ -217,7 +221,8 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
remove: cookiesRemoveMock,
|
||||
set: cookiesSetMock
|
||||
},
|
||||
clearData: clearDataMock
|
||||
clearData: clearDataMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
copyFileSyncMock,
|
||||
execFileSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock,
|
||||
writeCookieIdentityMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
setPendingCookieImport: setPendingCookieImportMock,
|
||||
clearPendingCookieImport: clearPendingCookieImportMock
|
||||
}
|
||||
}))
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
...actual,
|
||||
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
|
||||
copyFileSyncMock(...args)
|
||||
return actual.copyFileSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
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,
|
||||
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
|
||||
// method would throw a TypeError the per-cookie catch swallows, quietly routing every write
|
||||
// down the rejected-cookie path while the suite still looked green.
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { DetectedBrowser } from './browser-cookie-import'
|
||||
|
||||
function chromeBrowser(cookiesPath: string): DetectedBrowser {
|
||||
return {
|
||||
family: 'chrome',
|
||||
label: 'Google Chrome',
|
||||
cookiesPath,
|
||||
keychainService: 'Chrome Safe Storage',
|
||||
keychainAccount: 'Chrome',
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}
|
||||
}
|
||||
|
||||
function firefoxBrowser(cookiesPath: string): DetectedBrowser {
|
||||
return {
|
||||
family: 'firefox',
|
||||
label: 'Firefox',
|
||||
cookiesPath,
|
||||
profiles: [{ name: 'default-release', directory: 'default-release' }],
|
||||
selectedProfile: 'default-release'
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
|
||||
// the __init probe — which writes no user data — is allowed through; anything else is the
|
||||
// downgrade returning, and it must fail the test rather than quietly succeed.
|
||||
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
|
||||
if (details.name !== '__init') {
|
||||
throw new Error(`cookies.set was called for user cookie ${details.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
describe('validated import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-file-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
setPendingCookieImportMock.mockClear()
|
||||
clearPendingCookieImportMock.mockClear()
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeCookieFile(cookies: unknown[]): string {
|
||||
const filePath = join(tmpDir, 'cookies.json')
|
||||
writeFileSync(filePath, JSON.stringify(cookies))
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Why (STA-4300): a JSON export that carries a full CHIPS partition must reach the jar with it.
|
||||
// cookies.set() drops partitionKey silently, so this only works through the CDP identity store.
|
||||
it('carries a complete partitionKey from a JSON export through to the write', async () => {
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0]).toMatchObject({
|
||||
name: 'chips-auth',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why (STA-4300): exporters that emit only topLevelSite carry no ancestor bit. Writing the cookie
|
||||
// unpartitioned would report a clean success the site cannot use, so it is skipped and counted.
|
||||
it('skips and reports a cookie whose partitionKey cannot be read faithfully', async () => {
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'ok' }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// The lossy write is not merely unused — only the readable cookie was ever attempted.
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.importedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.skippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.domains).toEqual(['plain.example'])
|
||||
})
|
||||
|
||||
it('does not replace existing cookies for a domain whose only source cookie is skipped', async () => {
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'existing-session',
|
||||
value: 'still-valid',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
session: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]),
|
||||
remove,
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a populated family and creates no staged replay for an opaque JSON partition', async () => {
|
||||
const targetJar = [
|
||||
{
|
||||
name: 'live-session',
|
||||
value: 'must-survive',
|
||||
domain: '.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]
|
||||
const remove = vi.fn(async (_url: string, name: string) => {
|
||||
const index = targetJar.findIndex((cookie) => cookie.name === name)
|
||||
if (index !== -1) {
|
||||
targetJar.splice(index, 1)
|
||||
}
|
||||
})
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: { get: vi.fn(async () => targetJar), remove, set: unreachableCookieSet }
|
||||
})
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.preserved.example',
|
||||
name: 'opaque-session',
|
||||
value: 'do-not-downgrade',
|
||||
secure: true,
|
||||
partitionKeyOpaque: true
|
||||
},
|
||||
{ domain: 'sub.preserved.example', name: 'sibling', value: 'do-not-write', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
importedCookies: 0,
|
||||
skippedCookies: 2,
|
||||
partitionSkippedCookies: 2
|
||||
})
|
||||
expect(targetJar).toEqual([expect.objectContaining({ name: 'live-session' })])
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['empty string', '']
|
||||
])('preserves a populated family for a present %s partitionKey', async (_label, partitionKey) => {
|
||||
const targetJar = [
|
||||
{
|
||||
name: 'apex-session',
|
||||
value: 'apex-live',
|
||||
domain: '.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'lax'
|
||||
},
|
||||
{
|
||||
name: 'sub-session',
|
||||
value: 'sub-live',
|
||||
domain: 'sub.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'lax'
|
||||
},
|
||||
{
|
||||
name: 'stale-plain',
|
||||
value: 'replace-me',
|
||||
domain: '.plain.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]
|
||||
const remove = vi.fn(async (_url: string, name: string) => {
|
||||
const index = targetJar.findIndex((cookie) => cookie.name === name)
|
||||
if (index !== -1) {
|
||||
targetJar.splice(index, 1)
|
||||
}
|
||||
})
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn(async () => targetJar),
|
||||
remove,
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.preserved.example',
|
||||
name: 'malformed-partition',
|
||||
value: 'do-not-write',
|
||||
secure: true,
|
||||
partitionKey
|
||||
},
|
||||
{
|
||||
domain: 'deep.sub.preserved.example',
|
||||
name: 'readable-sibling',
|
||||
value: 'do-not-write-either',
|
||||
secure: true
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'write-me', secure: true }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(targetJar).toEqual([
|
||||
expect.objectContaining({ name: 'apex-session', value: 'apex-live' }),
|
||||
expect.objectContaining({ name: 'sub-session', value: 'sub-live' })
|
||||
])
|
||||
expect(remove.mock.calls.map(([, name]) => name)).toEqual(['stale-plain'])
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'plain' }))
|
||||
expect(cookieWriteMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'readable-sibling' })
|
||||
)
|
||||
expect(result.summary).toMatchObject({
|
||||
totalCookies: 3,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 2,
|
||||
partitionSkippedCookies: 2,
|
||||
domains: ['plain.example']
|
||||
})
|
||||
expect(result.summary.totalCookies).toBe(
|
||||
result.summary.importedCookies + result.summary.skippedCookies
|
||||
)
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native Chromium import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let platformSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-native-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData'))
|
||||
copyFileSyncMock.mockClear()
|
||||
setPendingCookieImportMock.mockClear()
|
||||
clearPendingCookieImportMock.mockClear()
|
||||
execFileSyncMock.mockReset().mockImplementation(() => {
|
||||
throw new Error('OS credential commands are unavailable in this test')
|
||||
})
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
set: unreachableCookieSet,
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData: vi.fn().mockResolvedValue(undefined),
|
||||
setUserAgent: vi.fn(),
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why (STA-4300): the native path's in-memory load used cookies.set(), which drops partitionKey
|
||||
// silently. This pins the identity that actually reaches the jar; the end-to-end proof that
|
||||
// Chromium stores it is browser-cookie-import-partition-success.electron.test.ts.
|
||||
it('carries a partitioned source row through to the write with both partition halves', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
isSecure: 1,
|
||||
sameSite: 1,
|
||||
topFrameSiteKey: 'https://top.example',
|
||||
hasCrossSiteAncestor: 1
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'plain-ok', isSecure: 1 }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
|
||||
const written = cookieWriteMock.mock.calls.map((call) => call[0])
|
||||
expect(written.find((cookie) => cookie.name === 'chips-auth')).toMatchObject({
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
expect(written.find((cookie) => cookie.name === 'plain')).not.toHaveProperty('partitionKey')
|
||||
expect(result.ok && result.summary?.partitionSkippedCookies).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never stages a partitioned row whose ancestor bit is unreadable', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
mkdirSync(dirname(sourceCookiesPath), { recursive: true })
|
||||
const legacyDb = new DatabaseSync(sourceCookiesPath)
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE cookies (
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL DEFAULT X'',
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
samesite INTEGER NOT NULL,
|
||||
source_scheme INTEGER NOT NULL DEFAULT 0,
|
||||
source_port INTEGER NOT NULL DEFAULT -1,
|
||||
last_update_utc INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`)
|
||||
legacyDb.exec(`
|
||||
INSERT INTO cookies VALUES
|
||||
(133000000000000, '.app.acme-chips.test', 'https://top.example', 'chips-auth', 'keep-me', X'', '/', 0, 1, 0, 1, 0, -1, 0),
|
||||
(133000000000001, '.plain.example', '', 'plain', 'plain-ok', X'', '/', 0, 1, 0, 0, 0, -1, 0)
|
||||
`)
|
||||
legacyDb.close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
cookieWriteMock.mockRejectedValueOnce(new Error('plain cookie needs restart'))
|
||||
|
||||
const blocked = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test',
|
||||
{
|
||||
canReportPartitionSkippedCookies: false
|
||||
}
|
||||
)
|
||||
expect(blocked.ok).toBe(false)
|
||||
expect(blocked.ok || blocked.reason).toContain('cannot report')
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
const blockedSession = sessionFromPartitionMock.mock.results[0].value
|
||||
expect(blockedSession.clearData).not.toHaveBeenCalled()
|
||||
expect(blockedSession.cookies.remove).not.toHaveBeenCalled()
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// Only the readable cookie was ever attempted — the unreadable one reached no writer at all.
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
|
||||
expect(result.ok && result.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary?.importedCookies).toBe(1)
|
||||
expect(result.ok && result.summary?.skippedCookies).toBe(1)
|
||||
// Why (STA-4300 §4.3b): this assertion is STRONGER than the one it replaces. bf6dc6fcba staged
|
||||
// an image and merely omitted the unreadable row from it; that image is a whole-database
|
||||
// replacement on the next start, so it would still have erased the preserved family it was
|
||||
// supposed to leave alone. A skip-bearing import now registers NO image at all, which closes
|
||||
// the cold-start channel by construction rather than by a staged predicate that has to be right.
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a populated family when the partition site is malformed', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.preserved.example',
|
||||
name: 'invalid-partition',
|
||||
value: 'source-value',
|
||||
topFrameSiteKey: 'https://top.example/not-a-site',
|
||||
hasCrossSiteAncestor: 1
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'plain-ok' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
const targetJar = [
|
||||
{
|
||||
name: 'live-session',
|
||||
value: 'must-survive',
|
||||
domain: '.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]
|
||||
const clearData = vi.fn(async () => targetJar.splice(0))
|
||||
const remove = vi.fn(async (_url: string, name: string) => {
|
||||
const index = targetJar.findIndex((cookie) => cookie.name === name)
|
||||
if (index !== -1) {
|
||||
targetJar.splice(index, 1)
|
||||
}
|
||||
})
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn(async () => targetJar),
|
||||
set: unreachableCookieSet,
|
||||
remove,
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData,
|
||||
setUserAgent: vi.fn(),
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(clearData).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalledWith(expect.any(String), 'live-session')
|
||||
expect(targetJar).toEqual([expect.objectContaining({ name: 'live-session' })])
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'plain' }))
|
||||
expect(result.ok && result.summary).toMatchObject({
|
||||
totalCookies: 2,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 1,
|
||||
partitionSkippedCookies: 1
|
||||
})
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: without this, "disable staging on a skip" could be implemented as "disable staging
|
||||
// always" and no test would notice — a real regression to the restart path wearing the disguise
|
||||
// of a safety fix.
|
||||
it('still stages a cold-restart image when nothing is preserved', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Cookies')
|
||||
const legacyDb = new DatabaseSync(sourceCookiesPath)
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE cookies (
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
last_access_utc INTEGER NOT NULL,
|
||||
has_expires INTEGER NOT NULL,
|
||||
is_persistent INTEGER NOT NULL,
|
||||
priority INTEGER NOT NULL,
|
||||
samesite INTEGER NOT NULL,
|
||||
source_scheme INTEGER NOT NULL,
|
||||
has_cross_site_ancestor INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO cookies VALUES
|
||||
(133000000000000, '.plain.example', '', 'plain', 'plain-ok', X'', '/', 0, 1, 0, 0, 0, -1, 0, 0, 2, 0)
|
||||
`)
|
||||
legacyDb.close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
cookieWriteMock.mockRejectedValueOnce(new Error('plain cookie needs restart'))
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.ok && result.summary?.partitionSkippedCookies).toBeUndefined()
|
||||
expect(setPendingCookieImportMock).toHaveBeenCalledOnce()
|
||||
const stagedPath = setPendingCookieImportMock.mock.calls[0][1] as string
|
||||
const stagedDb = new DatabaseSync(stagedPath, { readOnly: true })
|
||||
const stagedNames = stagedDb.prepare('SELECT name FROM cookies ORDER BY name').all()
|
||||
stagedDb.close()
|
||||
expect(stagedNames).toEqual([{ name: 'plain' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Firefox import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-firefox-'))
|
||||
writeCookieIdentityMock.mockReset().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('leaves the target jar untouched when the client cannot report a partition skip', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'cookies.sqlite')
|
||||
const sourceDb = new DatabaseSync(sourceCookiesPath)
|
||||
sourceDb.exec(`
|
||||
CREATE TABLE moz_cookies (
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
host TEXT,
|
||||
path TEXT,
|
||||
expiry INTEGER,
|
||||
isSecure INTEGER,
|
||||
isHttpOnly INTEGER,
|
||||
sameSite INTEGER,
|
||||
originAttributes TEXT,
|
||||
isPartitionedAttributeSet INTEGER
|
||||
);
|
||||
INSERT INTO moz_cookies VALUES
|
||||
('chips-auth', 'keep-me', '.app.example', '/', 0, 1, 1, 0,
|
||||
'^partitionKey=(https,top.example)', 1),
|
||||
('dfpi', 'dfpi-ok', '.dfpi.example', '/', 0, 1, 0, 0,
|
||||
'^partitionKey=(https,top.example,f)', 0)
|
||||
`)
|
||||
sourceDb.close()
|
||||
const get = vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'existing-session',
|
||||
value: 'still-valid',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
session: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReturnValue({ cookies: { get, remove } })
|
||||
|
||||
const result = await importCookiesFromBrowser(
|
||||
firefoxBrowser(sourceCookiesPath),
|
||||
'persist:test',
|
||||
{ canReportPartitionSkippedCookies: false }
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.ok || result.reason).toContain('cannot report')
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(writeCookieIdentityMock).not.toHaveBeenCalled()
|
||||
|
||||
get.mockResolvedValue([])
|
||||
const supportedResult = await importCookiesFromBrowser(
|
||||
firefoxBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(supportedResult.ok).toBe(true)
|
||||
expect(supportedResult.ok && supportedResult.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(supportedResult.ok && supportedResult.summary?.importedCookies).toBe(1)
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledOnce()
|
||||
expect(writeCookieIdentityMock.mock.calls[0][0].name).toBe('dfpi')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,464 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
// STA-4300: cookies.set() silently drops partitionKey, so an imported partitioned cookie is
|
||||
// written into the target jar as an unpartitioned cookie even though the import reports success.
|
||||
// Why: a CHIPS partition key is keyed by SITE, not origin — Chromium canonicalises
|
||||
// topLevelSite to the registrable domain, so "https://app.example.com" reads back as
|
||||
// "https://example.com". Source rows must use that canonical site.
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://example.com',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
const SOURCE_HOST = '.thirdparty.example'
|
||||
const COOKIE_NAME_NATIVE = 'sta4300-native-partitioned'
|
||||
const COOKIE_NAME_FILE = 'sta4300-file-partitioned'
|
||||
const COOKIE_VALUE_NATIVE = 'partitioned-native-value'
|
||||
const COOKIE_VALUE_FILE = 'partitioned-file-value'
|
||||
|
||||
type SetCall = {
|
||||
name: string
|
||||
hasPartitionKey: boolean
|
||||
partitionKey?: unknown
|
||||
}
|
||||
|
||||
type ImportResultProjection = {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
summary?: { importedCookies: number }
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
step: string
|
||||
error?: string
|
||||
importResult?: ImportResultProjection
|
||||
importedCookie?: { name: string; value: string; partitionKey?: Record<string, unknown> }
|
||||
controlCookiePartitionKey?: Record<string, unknown>
|
||||
setCallForCookie?: SetCall | null
|
||||
setCalls?: SetCall[]
|
||||
sourcePartitionVerified?: boolean
|
||||
electronVersion?: string
|
||||
elapsedMs?: number
|
||||
}
|
||||
|
||||
function chromiumExpiry(unixSeconds: number): bigint {
|
||||
return BigInt(unixSeconds) * 1_000_000n + 11_644_473_600_000_000n
|
||||
}
|
||||
|
||||
function createPartitionedChromiumSourceDatabase(
|
||||
databasePath: string,
|
||||
name: string,
|
||||
value: string
|
||||
): DatabaseSync {
|
||||
mkdirSync(dirname(databasePath), { recursive: true })
|
||||
const database = new DatabaseSync(databasePath)
|
||||
database.exec(`
|
||||
CREATE TABLE cookies (
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL DEFAULT X'',
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
samesite INTEGER NOT NULL,
|
||||
source_scheme INTEGER NOT NULL DEFAULT 0,
|
||||
source_port INTEGER NOT NULL DEFAULT -1,
|
||||
last_update_utc INTEGER NOT NULL DEFAULT 0,
|
||||
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(host_key, top_frame_site_key, name, path, source_scheme, source_port)
|
||||
)
|
||||
`)
|
||||
database
|
||||
.prepare(
|
||||
`INSERT INTO cookies (
|
||||
creation_utc, host_key, top_frame_site_key, name, value, encrypted_value, path,
|
||||
expires_utc, is_secure, is_httponly, samesite, source_scheme, source_port,
|
||||
last_update_utc, has_cross_site_ancestor
|
||||
) VALUES (?, ?, ?, ?, ?, X'', '/', ?, 1, 0, 1, 2, 443, 0, 1)`
|
||||
)
|
||||
.run(
|
||||
133_000_000_000_000,
|
||||
SOURCE_HOST,
|
||||
EXPECTED_PARTITION_KEY.topLevelSite,
|
||||
name,
|
||||
value,
|
||||
chromiumExpiry(Math.floor(Date.now() / 1000) + 30 * 86400)
|
||||
)
|
||||
return database
|
||||
}
|
||||
|
||||
// Why: the repro is only meaningful if the fixture row really is partitioned.
|
||||
function assertSourceRowPartitioned(database: DatabaseSync, name: string): void {
|
||||
const row = database
|
||||
.prepare('SELECT top_frame_site_key, has_cross_site_ancestor FROM cookies WHERE name = ?')
|
||||
.get(name) as { top_frame_site_key: string; has_cross_site_ancestor: number } | undefined
|
||||
if (
|
||||
!row ||
|
||||
row.top_frame_site_key !== EXPECTED_PARTITION_KEY.topLevelSite ||
|
||||
Number(row.has_cross_site_ancestor) !== 1
|
||||
) {
|
||||
throw new Error(`fixture source row is not partitioned: ${JSON.stringify(row)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeSourceFileReady(databasePath: string, name: string): void {
|
||||
if (!existsSync(databasePath)) {
|
||||
throw new Error(`native source cookies DB was not created: ${databasePath}`)
|
||||
}
|
||||
const database = new DatabaseSync(databasePath, { readOnly: true })
|
||||
try {
|
||||
assertSourceRowPartitioned(database, name)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
}
|
||||
|
||||
function buildFixtureMain(
|
||||
mode: 'native' | 'file',
|
||||
importPath: string,
|
||||
resultPath: string,
|
||||
sourcePath: string
|
||||
): string {
|
||||
const importedCookieName = mode === 'native' ? COOKIE_NAME_NATIVE : COOKIE_NAME_FILE
|
||||
const sourcePartitionBlock =
|
||||
mode === 'native'
|
||||
? `
|
||||
const { existsSync } = require('node:fs')
|
||||
if (!existsSync(${JSON.stringify(sourcePath)})) {
|
||||
throw new Error('SOURCE COOKIES FILE MISSING: ' + ${JSON.stringify(sourcePath)})
|
||||
}
|
||||
const sourceRow = new DatabaseSync(${JSON.stringify(sourcePath)}, { readOnly: true })
|
||||
.prepare('SELECT top_frame_site_key, has_cross_site_ancestor FROM cookies WHERE name = ?')
|
||||
.get(${JSON.stringify(importedCookieName)})
|
||||
if (!sourceRow || sourceRow.top_frame_site_key !== expectedPartitionKey.topLevelSite ||
|
||||
Number(sourceRow.has_cross_site_ancestor) !== 1) {
|
||||
throw new Error('SOURCE ROW IS NOT PARTITIONED: ' + JSON.stringify(sourceRow))
|
||||
}
|
||||
`
|
||||
: `
|
||||
const sourceEntry = JSON.parse(readFileSync(${JSON.stringify(sourcePath)}, 'utf8'))[0]
|
||||
if (!sourceEntry || !sourceEntry.partitionKey ||
|
||||
sourceEntry.partitionKey.topLevelSite !== expectedPartitionKey.topLevelSite ||
|
||||
sourceEntry.partitionKey.hasCrossSiteAncestor !== true) {
|
||||
throw new Error('SOURCE ENTRY IS NOT PARTITIONED: ' + JSON.stringify(sourceEntry))
|
||||
}
|
||||
`
|
||||
const importBlock =
|
||||
mode === 'native'
|
||||
? `
|
||||
importResult = await importCookiesFromBrowser({
|
||||
family: 'chrome',
|
||||
label: 'Chromium fixture',
|
||||
cookiesPath: ${JSON.stringify(sourcePath)},
|
||||
profiles: [{ name: 'Default', directory: 'Default' }],
|
||||
selectedProfile: 'Default'
|
||||
}, partition)
|
||||
`
|
||||
: `
|
||||
importResult = await importCookiesFromFile(${JSON.stringify(sourcePath)}, partition)
|
||||
`
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const { importCookiesFromBrowser, importCookiesFromFile } = require(${JSON.stringify(importPath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
const expectedPartitionKey = ${JSON.stringify(EXPECTED_PARTITION_KEY)}
|
||||
const importedCookieName = ${JSON.stringify(importedCookieName)}
|
||||
const startedAt = Date.now()
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
const writeResult = (payload) => writeFileSync(resultPath, JSON.stringify({ step: 'final', ...payload }))
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 45000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:sta4300-' + ${JSON.stringify(mode)}
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>sta4300 fixture</title>')
|
||||
mark('window loaded')
|
||||
|
||||
// Why: pin the defect to cookies.set. The wrapper records every call's args.
|
||||
const setCalls = []
|
||||
const realSet = targetSession.cookies.set.bind(targetSession.cookies)
|
||||
targetSession.cookies.set = async (details) => {
|
||||
setCalls.push({
|
||||
name: details && details.name,
|
||||
hasPartitionKey: !!(details && 'partitionKey' in details && details.partitionKey !== undefined),
|
||||
partitionKey: details && details.partitionKey
|
||||
})
|
||||
return realSet(details)
|
||||
}
|
||||
mark('cookies.set spied')
|
||||
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
await debug.sendCommand('Network.enable')
|
||||
mark('network enabled')
|
||||
|
||||
${sourcePartitionBlock}
|
||||
mark('source partition verified')
|
||||
|
||||
let importResult
|
||||
${importBlock}
|
||||
mark('import complete')
|
||||
if (!importResult.ok) {
|
||||
throw new Error('IMPORT REPORTED FAILURE: ' + String(importResult.reason ?? JSON.stringify(importResult)))
|
||||
}
|
||||
if (!importResult.summary || importResult.summary.importedCookies < 1) {
|
||||
throw new Error('IMPORT DID NOT IMPORT ANY COOKIES: ' + JSON.stringify(importResult.summary))
|
||||
}
|
||||
mark('import ok with cookies')
|
||||
|
||||
// Why: same CDP readback must see a partitioned cookie in this session, or
|
||||
// "partitionKey absent" could mean the oracle cannot see partitions.
|
||||
await debug.sendCommand('Network.setCookie', {
|
||||
url: 'https://control.example/',
|
||||
name: 'sta4300-control',
|
||||
value: 'control-value',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: expectedPartitionKey
|
||||
})
|
||||
const allCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const controlCookie = allCookies.find((cookie) => cookie.name === 'sta4300-control')
|
||||
if (!controlCookie || !controlCookie.partitionKey) {
|
||||
throw new Error('POSITIVE CONTROL FAILED: ' + JSON.stringify(controlCookie))
|
||||
}
|
||||
mark('positive control verified')
|
||||
|
||||
const importedCookie = allCookies.find((cookie) => cookie.name === importedCookieName) ?? null
|
||||
if (!importedCookie) {
|
||||
throw new Error('IMPORTED COOKIE MISSING AFTER IMPORT')
|
||||
}
|
||||
// Why (STA-4300 6.6): on main this fixture REQUIRED cookies.set to have seen the imported
|
||||
// cookie - that was the defect fingerprint. After the reland the import writes through CDP
|
||||
// identities instead, so the spy correctly never sees it. The guard is inverted: seeing the
|
||||
// imported cookie on cookies.set now means the structural guard has regressed.
|
||||
const setCallForCookie = setCalls.find((call) => call.name === importedCookieName) ?? null
|
||||
if (setCallForCookie) {
|
||||
throw new Error('REGRESSION: imported user data went through cookies.set, which drops partitionKey')
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
writeResult({
|
||||
importResult: {
|
||||
ok: importResult.ok,
|
||||
reason: importResult.reason,
|
||||
summary: importResult.summary
|
||||
},
|
||||
importedCookie: {
|
||||
name: importedCookie.name,
|
||||
value: importedCookie.value,
|
||||
partitionKey: importedCookie.partitionKey
|
||||
},
|
||||
controlCookiePartitionKey: controlCookie.partitionKey,
|
||||
setCallForCookie: null,
|
||||
setCalls,
|
||||
sourcePartitionVerified: true,
|
||||
electronVersion: process.versions.electron,
|
||||
elapsedMs: Date.now() - startedAt
|
||||
})
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(mode: 'native' | 'file'): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), `orca-sta4300-${mode}-`))
|
||||
fixtureRoots.push(root)
|
||||
const importPath = join(root, 'browser-cookie-import.cjs')
|
||||
const registryStubPath = join(root, 'browser-session-registry.cjs')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
let sourcePath: string
|
||||
if (mode === 'native') {
|
||||
sourcePath = join(root, 'source', 'Network', 'Cookies')
|
||||
const database = createPartitionedChromiumSourceDatabase(
|
||||
sourcePath,
|
||||
COOKIE_NAME_NATIVE,
|
||||
COOKIE_VALUE_NATIVE
|
||||
)
|
||||
assertSourceRowPartitioned(database, COOKIE_NAME_NATIVE)
|
||||
database.close()
|
||||
assertNativeSourceFileReady(sourcePath, COOKIE_NAME_NATIVE)
|
||||
} else {
|
||||
sourcePath = join(root, 'cookies.json')
|
||||
const jsonSource = [
|
||||
{
|
||||
domain: SOURCE_HOST,
|
||||
name: COOKIE_NAME_FILE,
|
||||
value: COOKIE_VALUE_FILE,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: 'no_restriction',
|
||||
expirationDate: Math.floor(Date.now() / 1000) + 30 * 86400,
|
||||
// Why: readJsonCookiePartition reads a NESTED partitionKey object. The first version of
|
||||
// this fixture put topLevelSite/hasCrossSiteAncestor at the entry's top level, where the
|
||||
// importer never looks — so the file/paste case failed on main for a fixture-shape reason
|
||||
// rather than for the defect, and its own non-vacuity check validated what the fixture
|
||||
// wrote instead of what the parser reads.
|
||||
partitionKey: {
|
||||
topLevelSite: EXPECTED_PARTITION_KEY.topLevelSite,
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
}
|
||||
]
|
||||
writeFileSync(sourcePath, JSON.stringify(jsonSource, null, 2))
|
||||
const entry = JSON.parse(readFileSync(sourcePath, 'utf8'))[0]
|
||||
if (
|
||||
!entry?.partitionKey ||
|
||||
entry.partitionKey.topLevelSite !== EXPECTED_PARTITION_KEY.topLevelSite ||
|
||||
entry.partitionKey.hasCrossSiteAncestor !== true
|
||||
) {
|
||||
throw new Error(`fixture source JSON entry is not partitioned: ${JSON.stringify(entry)}`)
|
||||
}
|
||||
}
|
||||
writeFileSync(
|
||||
registryStubPath,
|
||||
'exports.browserSessionRegistry = { clearPendingCookieImport() {}, setPendingCookieImport() {} }\n'
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'stub-browser-session-registry',
|
||||
resolveId(source, importer) {
|
||||
return source === './browser-session-registry' &&
|
||||
importer?.endsWith('browser-cookie-import.ts')
|
||||
? registryStubPath
|
||||
: null
|
||||
}
|
||||
}
|
||||
],
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'browser-cookie-import.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(mode, importPath, resultPath, sourcePath))
|
||||
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
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 180_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
function expectImportNonVacuity(result: FixtureResult): void {
|
||||
expect(result.step).toBe('final')
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.importResult?.ok).toBe(true)
|
||||
expect(result.importResult?.summary?.importedCookies ?? 0).toBeGreaterThan(0)
|
||||
expect(result.sourcePartitionVerified).toBe(true)
|
||||
// Why (STA-4300 §4.4/§6.6): on `main` these asserted that the user-data write went through
|
||||
// cookies.set WITHOUT a partitionKey — that was the defect. After the reland the import write
|
||||
// path does not touch cookies.set for user data at all, so the assertion is INVERTED rather than
|
||||
// deleted, which is strictly stronger: it now pins the structural guard itself.
|
||||
expect(result.setCalls?.some((call) => call.name === result.importedCookie?.name)).toBe(false)
|
||||
expect(result.controlCookiePartitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
expect(result.importedCookie?.name).toBeTruthy()
|
||||
// Why: proves the fixture ran inside a launched Electron main process.
|
||||
expect(result.electronVersion).toBeTruthy()
|
||||
expect(result.elapsedMs ?? 0).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
function partitionFailureMessage(result: FixtureResult): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
step: result.step,
|
||||
importResult: result.importResult,
|
||||
sourcePartitionVerified: result.sourcePartitionVerified,
|
||||
setCallForCookie: result.setCallForCookie,
|
||||
setCalls: result.setCalls,
|
||||
controlCookiePartitionKey: result.controlCookiePartitionKey,
|
||||
electronVersion: result.electronVersion,
|
||||
elapsedMs: result.elapsedMs,
|
||||
importedCookie: result.importedCookie
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
describe('STA-4300 reland: import preserves partitionKey on the native Chromium write path', () => {
|
||||
it('writes an imported partitioned cookie back with its partition identity intact', async () => {
|
||||
const result = await runFixture('native')
|
||||
|
||||
expectImportNonVacuity(result)
|
||||
expect(result.importedCookie?.value).toBe(COOKIE_VALUE_NATIVE)
|
||||
|
||||
// The oracle: the imported cookie keeps its partition identity. RED on main, green here.
|
||||
expect(result.importedCookie?.partitionKey, partitionFailureMessage(result)).toEqual(
|
||||
EXPECTED_PARTITION_KEY
|
||||
)
|
||||
}, 240_000)
|
||||
})
|
||||
|
||||
describe('STA-4300 reland: import preserves partitionKey on the file/paste write path', () => {
|
||||
it('writes an imported partitioned cookie back with its partition identity intact', async () => {
|
||||
const result = await runFixture('file')
|
||||
|
||||
expectImportNonVacuity(result)
|
||||
expect(result.importedCookie?.value).toBe(COOKIE_VALUE_FILE)
|
||||
|
||||
// The oracle: the imported cookie keeps its partition identity. RED on main, green here.
|
||||
expect(result.importedCookie?.partitionKey, partitionFailureMessage(result)).toEqual(
|
||||
EXPECTED_PARTITION_KEY
|
||||
)
|
||||
}, 240_000)
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
partitionKey: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type ImportResult = {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
summary?: {
|
||||
totalCookies: number
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
domains: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
step: string
|
||||
error?: string
|
||||
beforeCookieCount: number
|
||||
importResult: ImportResult
|
||||
afterChips: CdpCookie[]
|
||||
afterPlain: CdpCookie[]
|
||||
}
|
||||
|
||||
type SourceChipsRow = {
|
||||
host_key: string
|
||||
name: string
|
||||
value: string
|
||||
top_frame_site_key: string
|
||||
has_cross_site_ancestor: number
|
||||
}
|
||||
|
||||
// Why: the correct behavior — the CDP object form Network.setCookie writes and
|
||||
// Network.getAllCookies returns, as proven by the rollback fixture on this Electron.
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
const SOURCE_DB_RELATIVE_PATH = 'source-cookies.db'
|
||||
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 30000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:partition-success-cookie-test'
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>cookie partition success fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
|
||||
// Why: only CDP can observe partitionKey; prove the jar is empty before the import
|
||||
// so every cookie read afterwards can only have come from the import itself.
|
||||
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
mark('jar read before import')
|
||||
|
||||
const importResult = await importCookiesFromBrowser(
|
||||
{
|
||||
family: 'chrome',
|
||||
label: 'Google Chrome',
|
||||
cookiesPath: ${JSON.stringify(sourceDbPath)},
|
||||
profiles: [],
|
||||
selectedProfile: ''
|
||||
},
|
||||
partition
|
||||
)
|
||||
mark('import finished')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const project = (name) => afterCookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
partitionKey: cookie.partitionKey ?? null
|
||||
}))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
step: currentStep,
|
||||
beforeCookieCount: beforeCookies.length,
|
||||
importResult,
|
||||
afterChips: project('chips-auth'),
|
||||
afterPlain: project('plain')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
function readSourceChipsRow(sourceDbPath: string): SourceChipsRow {
|
||||
const db = new DatabaseSync(sourceDbPath, { readOnly: true })
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT host_key, name, value, top_frame_site_key, has_cross_site_ancestor FROM cookies ORDER BY rowid'
|
||||
)
|
||||
.all() as SourceChipsRow[]
|
||||
const row = rows.find((candidate) => candidate.name === 'chips-auth')
|
||||
if (!row) {
|
||||
throw new Error('source DB has no chips-auth row')
|
||||
}
|
||||
return row
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<{ fixture: FixtureResult; sourceChips: SourceChipsRow }> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-partition-success-'))
|
||||
fixtureRoots.push(root)
|
||||
const bundlePath = join(root, 'cookie-import-success.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-import-success.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const sourceDbPath = join(root, SOURCE_DB_RELATIVE_PATH)
|
||||
const sourceDb = createChromiumCookieTestDatabase(sourceDbPath, [
|
||||
// Why: the CHIPS row is a genuine partitioned cookie: top_frame_site_key names the
|
||||
// top-level site and has_cross_site_ancestor=1 means it was set cross-site.
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
isSecure: 1,
|
||||
sameSite: 1,
|
||||
topFrameSiteKey: EXPECTED_PARTITION_KEY.topLevelSite,
|
||||
hasCrossSiteAncestor: 1
|
||||
},
|
||||
// Why: an ordinary row with identical attributes minus the partition columns proves
|
||||
// the import still works for the unpartitioned case.
|
||||
{
|
||||
domain: '.plain.example',
|
||||
name: 'plain',
|
||||
value: 'plain-ok',
|
||||
isSecure: 1,
|
||||
sameSite: 1
|
||||
}
|
||||
])
|
||||
sourceDb.close()
|
||||
const sourceChips = readSourceChipsRow(sourceDbPath)
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
`export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'cookie-import-success.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath))
|
||||
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
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 90_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceChips }
|
||||
}
|
||||
|
||||
describe('STA-4300: partitioned (CHIPS) cookies on the native Chromium import success path', () => {
|
||||
it('preserves the source partitionKey through importCookiesFromBrowser', async () => {
|
||||
const { fixture, sourceChips } = await runFixture()
|
||||
|
||||
// Non-vacuity breadcrumbs: the fixture reached the import, the source row really was
|
||||
// partitioned before import, and the import reported success with both cookies.
|
||||
expect(fixture.step).toBe('import finished')
|
||||
expect(fixture.beforeCookieCount).toBe(0)
|
||||
expect(sourceChips).toEqual({
|
||||
host_key: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
top_frame_site_key: 'https://top.example',
|
||||
has_cross_site_ancestor: 1
|
||||
})
|
||||
expect(fixture.importResult.ok).toBe(true)
|
||||
expect(fixture.importResult.summary?.importedCookies).toBe(2)
|
||||
|
||||
// Ordinary unpartitioned import still works end to end.
|
||||
expect(fixture.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
|
||||
|
||||
// The partitioned cookie must keep its partition — cookies.set() silently drops it, so
|
||||
// this is the assertion that fails on current main and goes green once writes go through CDP.
|
||||
expect(fixture.afterChips).toHaveLength(1)
|
||||
expect(fixture.afterChips[0].value).toBe('keep-me')
|
||||
expect(fixture.afterChips[0].partitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { planImportWrites, type SourceCookieToWrite } from './browser-cookie-import-write'
|
||||
|
||||
function cookie(domain: string, name: string, partition: SourceCookieToWrite['partition']) {
|
||||
const host = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
return {
|
||||
url: `https://${host}/`,
|
||||
domain,
|
||||
name,
|
||||
value: `${name}-value`,
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: 'no_restriction',
|
||||
expirationDate: undefined,
|
||||
partition
|
||||
} satisfies SourceCookieToWrite
|
||||
}
|
||||
|
||||
const READABLE = { status: 'unpartitioned' } as const
|
||||
const UNREADABLE = {
|
||||
status: 'unreadable',
|
||||
reason: 'schema has no has_cross_site_ancestor'
|
||||
} as const
|
||||
|
||||
const names = (cookies: readonly SourceCookieToWrite[]) => cookies.map((c) => c.name).sort()
|
||||
|
||||
describe('planImportWrites', () => {
|
||||
it('writes every cookie when no partition is unreadable', () => {
|
||||
const plan = planImportWrites([
|
||||
cookie('.a.example', 'a', READABLE),
|
||||
cookie('.b.example', 'b', READABLE)
|
||||
])
|
||||
|
||||
expect(names(plan.writes)).toEqual(['a', 'b'])
|
||||
expect(plan.skips).toHaveLength(0)
|
||||
expect(plan.skippedFamilies.size).toBe(0)
|
||||
})
|
||||
|
||||
it('skips the whole registrable family, not just the unreadable cookie', () => {
|
||||
const plan = planImportWrites([
|
||||
cookie('.mixed.example', 'readable-sibling', READABLE),
|
||||
cookie('sub.mixed.example', 'unreadable', UNREADABLE),
|
||||
cookie('.other.example', 'unrelated', READABLE)
|
||||
])
|
||||
|
||||
expect(names(plan.writes)).toEqual(['unrelated'])
|
||||
expect(names(plan.skips.map((s) => s.cookie))).toEqual(['readable-sibling', 'unreadable'])
|
||||
expect([...plan.skippedFamilies]).toEqual(['mixed.example'])
|
||||
})
|
||||
|
||||
// Why (STA-4300 §4.1): this is the ONLY property that distinguishes two passes from one. A
|
||||
// per-row guard emits the readable sibling before it can know the family will be skipped, so it
|
||||
// passes unreadable-first and fails readable-first. Both orders must produce the same plan.
|
||||
it.each([
|
||||
[
|
||||
'readable before unreadable',
|
||||
[
|
||||
cookie('.mixed.example', 'readable-sibling', READABLE),
|
||||
cookie('sub.mixed.example', 'unreadable', UNREADABLE)
|
||||
]
|
||||
],
|
||||
[
|
||||
'unreadable before readable',
|
||||
[
|
||||
cookie('sub.mixed.example', 'unreadable', UNREADABLE),
|
||||
cookie('.mixed.example', 'readable-sibling', READABLE)
|
||||
]
|
||||
]
|
||||
])('suppresses the family regardless of source row order (%s)', (_label, rows) => {
|
||||
const plan = planImportWrites(rows)
|
||||
|
||||
expect(plan.writes).toHaveLength(0)
|
||||
expect(names(plan.skips.map((s) => s.cookie))).toEqual(['readable-sibling', 'unreadable'])
|
||||
expect([...plan.skippedFamilies]).toEqual(['mixed.example'])
|
||||
})
|
||||
|
||||
it('closes the family at the registrable boundary, not the exact domain', () => {
|
||||
// Why (§2b): importedDomainScopes expands an imported domain into its descendant roots, so a
|
||||
// readable cookie on mixed.example pulls sub.mixed.example into the removal scope. Preserving
|
||||
// only the exact skipped domain would still erase the sibling's live session.
|
||||
const plan = planImportWrites([
|
||||
cookie('deep.sub.mixed.example', 'unreadable', UNREADABLE),
|
||||
cookie('.mixed.example', 'apex', READABLE),
|
||||
cookie('other.mixed.example', 'cousin', READABLE)
|
||||
])
|
||||
|
||||
expect(plan.writes).toHaveLength(0)
|
||||
expect([...plan.skippedFamilies]).toEqual(['mixed.example'])
|
||||
})
|
||||
|
||||
it('does not suppress a different family that merely shares a public suffix', () => {
|
||||
const plan = planImportWrites([
|
||||
cookie('.skipped.example', 'unreadable', UNREADABLE),
|
||||
cookie('.kept.example', 'kept', READABLE)
|
||||
])
|
||||
|
||||
expect(names(plan.writes)).toEqual(['kept'])
|
||||
expect([...plan.skippedFamilies]).toEqual(['skipped.example'])
|
||||
})
|
||||
|
||||
it('flags a skip whose family cannot be named so the caller can refuse before mutating', () => {
|
||||
// Why (§4.3c): a family we cannot name is one we cannot exclude from the removal plan, and
|
||||
// clearing a family we cannot protect is exactly the P0. Refusal is the caller's job; the
|
||||
// planner's job is to make the condition impossible to miss.
|
||||
const plan = planImportWrites([cookie('com', 'bare-suffix', UNREADABLE)])
|
||||
|
||||
expect(plan.hasUnrepresentableSkip).toBe(true)
|
||||
expect(plan.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('leaves hasUnrepresentableSkip false when every skipped family is nameable', () => {
|
||||
const plan = planImportWrites([cookie('.mixed.example', 'unreadable', UNREADABLE)])
|
||||
|
||||
expect(plan.hasUnrepresentableSkip).toBe(false)
|
||||
expect([...plan.skippedFamilies]).toEqual(['mixed.example'])
|
||||
})
|
||||
|
||||
it('counts every suppressed cookie as skipped so the summary still adds up', () => {
|
||||
const rows = [
|
||||
cookie('.mixed.example', 'readable-sibling', READABLE),
|
||||
cookie('sub.mixed.example', 'unreadable', UNREADABLE),
|
||||
cookie('.other.example', 'unrelated', READABLE)
|
||||
]
|
||||
|
||||
const plan = planImportWrites(rows)
|
||||
|
||||
// totalCookies === importedCookies + skippedCookies, as an identity rather than a field.
|
||||
expect(plan.writes.length + plan.skips.length).toBe(rows.length)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isIP } from 'node:net'
|
||||
import type { Cookie, Cookies } from 'electron'
|
||||
import { parse as parseDomain } from 'psl'
|
||||
// Why: type-only, so this does not create a runtime cycle with the clear module.
|
||||
@@ -40,6 +41,40 @@ export function normalizeCookieDomain(domain: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): one definition of "family" for every consumer of the partition skip set — the
|
||||
// planner, the per-coordinate removal filter, and the path A domain comparison. Deriving it inline
|
||||
// in several places is what let the removal scope and the write set disagree (STA-4090, STA-4170).
|
||||
//
|
||||
// The IP test MUST run on normalizeCookieDomain's output, never the raw string: Chromium accepts
|
||||
// many spellings of one address and psl mangles all of them (psl.parse('2130706433').domain is
|
||||
// null, psl.parse('127.0.0.1').domain is '0.1'). normalizeCookieDomain runs the value through
|
||||
// `new URL()`, which canonicalises 127.1 / 2130706433 / 0x7f.1 / 010.0.0.1 / a trailing dot to a
|
||||
// dotted quad first, so isIP() then recognises every one of them.
|
||||
//
|
||||
// Returns null when no family can be named (a bare public suffix). A helper that named `com` as a
|
||||
// family would preserve an entire TLD from removal, silently turning an import into a no-op.
|
||||
export function registrableFamily(domain: string): string | null {
|
||||
const host = normalizeCookieDomain(domain)
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
if (isIP(host)) {
|
||||
return host
|
||||
}
|
||||
// Why: isIP('[::1]') is 0 — the brackets have to come off before the check.
|
||||
if (host.startsWith('[') && host.endsWith(']') && isIP(host.slice(1, -1)) === 6) {
|
||||
return host
|
||||
}
|
||||
const parsed = parseDomain(host)
|
||||
if ('error' in parsed) {
|
||||
return host
|
||||
}
|
||||
if (parsed.domain === null) {
|
||||
return parsed.listed ? null : host
|
||||
}
|
||||
return parsed.domain
|
||||
}
|
||||
|
||||
export function normalizeCookieImportDomain(domain: string): string | null {
|
||||
const normalized = normalizeCookieDomain(domain)
|
||||
if (!normalized) {
|
||||
|
||||
@@ -38,10 +38,15 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
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),
|
||||
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
|
||||
// a missing method cannot silently reroute every write down the rejected-cookie path.
|
||||
writeCookieIdentity: (identity: Record<string, unknown>) =>
|
||||
targetSession.cookies.set!(identity),
|
||||
snapshotClearIdentities: snapshotClearIdentitiesMock,
|
||||
restoreClearIdentities: restoreClearIdentitiesMock,
|
||||
dispose: disposeClearStoreMock
|
||||
@@ -180,6 +185,7 @@ describe('validated cookie replacement', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([
|
||||
['https://example.com/', 'existing'],
|
||||
['https://example.com/', 'second'],
|
||||
['https://example.com/', 'first']
|
||||
])
|
||||
expect(restoreClearIdentitiesMock).toHaveBeenCalledOnce()
|
||||
@@ -208,8 +214,12 @@ describe('validated cookie replacement', () => {
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
// Why: the imported cookie is still removed — that half of the rollback is lossless.
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'first']])
|
||||
// Why: a rejected CDP command does not prove the cookie was not written before the transport
|
||||
// failed, so rollback must remove the failing coordinate as well as earlier successes.
|
||||
expect(cookiesRemoveMock.mock.calls).toEqual([
|
||||
['https://example.com/', 'second'],
|
||||
['https://example.com/', 'first']
|
||||
])
|
||||
expect(restoreClearIdentitiesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -248,7 +258,8 @@ describe('native Chromium integrity-cookie accounting', () => {
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: cookiesSetMock
|
||||
},
|
||||
clearData: clearDataMock
|
||||
clearData: clearDataMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ type ChromiumCookieTestRow = {
|
||||
name: string
|
||||
value: string
|
||||
encryptedValue?: Buffer
|
||||
// Why: default '' matches a real unpartitioned row; CHIPS fixtures set a top-level site.
|
||||
topFrameSiteKey?: string
|
||||
hasCrossSiteAncestor?: 0 | 1
|
||||
isSecure?: 0 | 1
|
||||
isHttpOnly?: 0 | 1
|
||||
sameSite?: 0 | 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function createChromiumCookieTestDatabase(
|
||||
@@ -57,16 +63,21 @@ export function createChromiumCookieTestDatabase(
|
||||
source_port,
|
||||
last_update_utc,
|
||||
has_cross_site_ancestor
|
||||
) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, '/', 0, ?, ?, ?, 0, -1, ?, ?)
|
||||
`)
|
||||
rows.forEach((row, index) => {
|
||||
insert.run(
|
||||
133_000_000_000_000 + index,
|
||||
row.domain ?? '.example.com',
|
||||
row.topFrameSiteKey ?? '',
|
||||
row.name,
|
||||
row.value,
|
||||
row.encryptedValue ?? Buffer.alloc(0),
|
||||
0
|
||||
row.isSecure ?? 0,
|
||||
row.isHttpOnly ?? 0,
|
||||
row.sameSite ?? 0,
|
||||
0,
|
||||
row.hasCrossSiteAncestor ?? 0
|
||||
)
|
||||
})
|
||||
return database
|
||||
|
||||
@@ -9,7 +9,8 @@ const {
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock
|
||||
clearPendingCookieImportMock,
|
||||
writeCookieIdentityMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
createDecipherivMock: vi.fn(),
|
||||
@@ -17,7 +18,8 @@ const {
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn()
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:crypto', async (importOriginal) => {
|
||||
@@ -51,6 +53,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
// Why (STA-4300): imports write through the CDP identity store, not cookies.set. A store double
|
||||
// missing this method throws a TypeError the per-cookie catch swallows, routing every write
|
||||
// down the rejected path while the suite still looks green.
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
@@ -89,21 +95,38 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser {
|
||||
describe('importCookiesFromBrowser — undecryptable cookies', () => {
|
||||
let tmpDir: string
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let clearDataMock: ReturnType<typeof vi.fn>
|
||||
let targetJar: Record<string, unknown>[]
|
||||
let platformSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-'))
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
targetJar = []
|
||||
cookiesRemoveMock = vi.fn(async (_url: string, name: string) => {
|
||||
const index = targetJar.findIndex((cookie) => cookie.name === name)
|
||||
if (index !== -1) {
|
||||
targetJar.splice(index, 1)
|
||||
}
|
||||
})
|
||||
clearDataMock = vi.fn(async () => {
|
||||
targetJar.splice(0)
|
||||
})
|
||||
writeCookieIdentityMock.mockReset().mockResolvedValue(undefined)
|
||||
appGetPathMock.mockReturnValue(join(tmpDir, 'userData'))
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(async () => targetJar),
|
||||
set: cookiesSetMock,
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
remove: cookiesRemoveMock,
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData: vi.fn().mockResolvedValue(undefined),
|
||||
setUserAgent: vi.fn()
|
||||
clearData: clearDataMock,
|
||||
setUserAgent: vi.fn(),
|
||||
// Why (STA-4300): the importer derives the partition dir from the session rather than
|
||||
// trusting a caller-supplied partition, so the double must expose it.
|
||||
getStoragePath: vi.fn().mockReturnValue(join(tmpDir, 'userData', 'Partitions', 'test'))
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
// Why: both secret-tool lookups failing is exactly the keyring-unavailable case.
|
||||
@@ -156,6 +179,67 @@ describe('importCookiesFromBrowser — undecryptable cookies', () => {
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a populated family when its unreadable partition row cannot decrypt', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const sourceDb = createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.preserved.example',
|
||||
name: 'undecryptable',
|
||||
value: '',
|
||||
encryptedValue: encryptLinuxChromiumCookie('wrong-key-garbage', 'peanuts', 'v11'),
|
||||
topFrameSiteKey: 'https://top.example'
|
||||
},
|
||||
{ domain: 'sub.preserved.example', name: 'readable-sibling', value: 'do-not-write' },
|
||||
{ domain: '.replace.test', name: 'plain', value: 'plain-value' }
|
||||
])
|
||||
sourceDb.exec("UPDATE cookies SET has_cross_site_ancestor = 2 WHERE name = 'undecryptable'")
|
||||
sourceDb.close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
targetJar.push({
|
||||
name: 'live-session',
|
||||
value: 'must-survive',
|
||||
domain: '.preserved.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: 'no_restriction'
|
||||
})
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(clearDataMock).not.toHaveBeenCalled()
|
||||
expect(cookiesRemoveMock).not.toHaveBeenCalledWith(expect.any(String), 'live-session')
|
||||
expect(targetJar).toEqual([expect.objectContaining({ name: 'live-session' })])
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledTimes(1)
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'plain', value: 'plain-value' })
|
||||
)
|
||||
expect(writeCookieIdentityMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'readable-sibling' })
|
||||
)
|
||||
expect(result.summary).toMatchObject({
|
||||
totalCookies: 3,
|
||||
importedCookies: 1,
|
||||
skippedCookies: 2,
|
||||
partitionSkippedCookies: 2,
|
||||
warning: {
|
||||
code: 'cookies-undecryptable',
|
||||
failedCookies: 1,
|
||||
reason: 'linux-keyring-unavailable'
|
||||
}
|
||||
})
|
||||
expect(result.summary.totalCookies).toBe(
|
||||
result.summary.importedCookies + result.summary.skippedCookies
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['v10', 'v99'])(
|
||||
'rejects a prefix-only %s CBC row before importing it',
|
||||
async (prefix) => {
|
||||
@@ -213,9 +297,13 @@ describe('importCookiesFromBrowser — undecryptable cookies', () => {
|
||||
// Mutation guard: replacing the keyring-unavailable branch with `return null` must break v10.
|
||||
expect(result.summary.importedCookies).toBe(1)
|
||||
expect(result.summary.warning).toBeUndefined()
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
// Why (STA-4300): the write seam moved from cookies.set to the CDP identity store. The
|
||||
// assertion is adapted, not weakened — and it is now strictly stronger, because cookies.set
|
||||
// must not carry imported user data at all (that is the structural guard #14383/STA-4300 add).
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid-0', value: 'v10-value' })
|
||||
)
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses an unknown warning when app-bound and corrupt failures are tied', async () => {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
partitionKey: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
step: string
|
||||
error?: string
|
||||
beforeCookieCount: number
|
||||
importResult: {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
summary?: {
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
partitionSkippedCookies?: number
|
||||
domains: string[]
|
||||
}
|
||||
}
|
||||
afterChips: CdpCookie[]
|
||||
afterHostChips: CdpCookie[]
|
||||
afterPlain: CdpCookie[]
|
||||
afterUnreadable: CdpCookie[]
|
||||
}
|
||||
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
// Why (STA-4300): the JSON/paste import writes through the same CDP identity store as the native
|
||||
// one. These four cookies are the failure-mode table's validated-path row: ordinary, host-prefixed,
|
||||
// partitioned, and a partition the exporter described incompletely.
|
||||
const SOURCE_COOKIES = [
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
},
|
||||
{
|
||||
domain: 'host.acme-chips.test',
|
||||
name: '__Host-chips-session',
|
||||
value: 'host-keep-me',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
},
|
||||
{
|
||||
domain: '.plain.example',
|
||||
name: 'plain',
|
||||
value: 'plain-ok',
|
||||
path: '/',
|
||||
secure: true
|
||||
},
|
||||
{
|
||||
// Only topLevelSite — the shape exporters emit without the cross-site-ancestor bit.
|
||||
domain: '.partial.example',
|
||||
name: 'partial-chips',
|
||||
value: 'must-not-land',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
}
|
||||
]
|
||||
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string, cookieFilePath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { importCookiesFromFile } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 30000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:validated-partition-cookie-test'
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>validated partition fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
|
||||
// Why: only CDP can observe partitionKey; an empty jar first means every cookie read
|
||||
// afterwards can only have come from the import.
|
||||
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
mark('jar read before import')
|
||||
|
||||
const importResult = await importCookiesFromFile(${JSON.stringify(cookieFilePath)}, partition)
|
||||
mark('import finished')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const project = (name) => afterCookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
partitionKey: cookie.partitionKey ?? null
|
||||
}))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
step: currentStep,
|
||||
beforeCookieCount: beforeCookies.length,
|
||||
importResult,
|
||||
afterChips: project('chips-auth'),
|
||||
afterHostChips: project('__Host-chips-session'),
|
||||
afterPlain: project('plain'),
|
||||
afterUnreadable: project('partial-chips')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-validated-partition-'))
|
||||
fixtureRoots.push(root)
|
||||
const bundlePath = join(root, 'cookie-import-validated.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-import-validated.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const cookieFilePath = join(root, 'cookies.json')
|
||||
writeFileSync(cookieFilePath, JSON.stringify(SOURCE_COOKIES))
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
`export { importCookiesFromFile } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'cookie-import-validated.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, cookieFilePath))
|
||||
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
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 90_000 })
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('STA-4300: partitioned cookies on the validated (file/paste) import success path', () => {
|
||||
it('stores CHIPS and __Host- CHIPS partitioned, and skips an unreadable partition', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
// Non-vacuity breadcrumbs: the fixture reached the import and the jar was empty before it.
|
||||
expect(result.step).toBe('import finished')
|
||||
expect(result.beforeCookieCount).toBe(0)
|
||||
expect(result.importResult.ok).toBe(true)
|
||||
|
||||
// Ordinary cookie: unchanged behavior, no partition invented for it.
|
||||
expect(result.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
|
||||
|
||||
// Partitioned cookie: Chromium stored the partition the export declared.
|
||||
expect(result.afterChips).toEqual([
|
||||
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
|
||||
])
|
||||
|
||||
// A host-prefixed cookie can also be partitioned; the __Host- rules must not drop the partition.
|
||||
expect(result.afterHostChips).toEqual([
|
||||
{
|
||||
name: '__Host-chips-session',
|
||||
value: 'host-keep-me',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
}
|
||||
])
|
||||
|
||||
// The incompletely-described partition was skipped, not downgraded to an unpartitioned cookie.
|
||||
expect(result.afterUnreadable).toEqual([])
|
||||
expect(result.importResult.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(result.importResult.summary?.importedCookies).toBe(3)
|
||||
expect(result.importResult.summary?.domains).not.toContain('partial.example')
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CookieClearIdentity } from './browser-cookie-import-clear'
|
||||
import type { SourcePartitionRead } from './browser-cookie-source-partition'
|
||||
import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write'
|
||||
|
||||
const PARTITION_KEY = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
|
||||
function sourceCookie(
|
||||
overrides: Partial<SourceCookieToWrite> & { name: string; partition: SourcePartitionRead }
|
||||
): SourceCookieToWrite {
|
||||
return {
|
||||
url: 'https://app.example/',
|
||||
value: 'v',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: 'no_restriction',
|
||||
expirationDate: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function recordingStore() {
|
||||
const writeCookieIdentity = vi.fn<(identity: CookieClearIdentity) => Promise<void>>(
|
||||
async () => undefined
|
||||
)
|
||||
return { writeCookieIdentity }
|
||||
}
|
||||
|
||||
const silentOptions = { stopOnFailure: false, log: () => undefined }
|
||||
|
||||
describe('writeImportedCookies', () => {
|
||||
it('writes a partitioned cookie through the identity store with its partition key intact', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'chips',
|
||||
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
// Why: assert the write was ATTEMPTED, not just that the counters look right — a store mock
|
||||
// missing this method would throw a TypeError the catch swallows, and every counter below
|
||||
// would still be reachable through the rejected path.
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
|
||||
name: 'chips',
|
||||
partitionKey: PARTITION_KEY
|
||||
})
|
||||
expect(phase.importedCount).toBe(1)
|
||||
expect(phase.partitionSkipped).toBe(0)
|
||||
expect(phase.writeRejected).toBe(0)
|
||||
})
|
||||
|
||||
it('writes an ordinary cookie with no partitionKey attribute at all', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
await writeImportedCookies(
|
||||
store,
|
||||
[sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).not.toHaveProperty('partitionKey')
|
||||
})
|
||||
|
||||
// Why (STA-4300): the whole point of the change — an unreadable partition must never reach the
|
||||
// store as an unpartitioned write.
|
||||
it('never writes a cookie whose partition could not be read, and counts it', async () => {
|
||||
const store = recordingStore()
|
||||
const logged: string[] = []
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'chips', partition: { status: 'unreadable', reason: 'no ancestor' } }),
|
||||
sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
{ stopOnFailure: false, log: (message) => logged.push(message) }
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0].name).toBe('plain')
|
||||
expect(phase.partitionSkipped).toBe(1)
|
||||
expect(phase.importedCount).toBe(1)
|
||||
expect(phase.failure).toBeNull()
|
||||
expect(logged.join('\n')).toContain('unreadable partition')
|
||||
})
|
||||
|
||||
// Why: cookie values are secrets; a skip line that echoed one would leak it into the diag log.
|
||||
it('logs only the domain for a skipped cookie, never its name or value', async () => {
|
||||
const logged: string[] = []
|
||||
|
||||
await writeImportedCookies(
|
||||
recordingStore(),
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'session-token',
|
||||
value: 'super-secret',
|
||||
partition: { status: 'unreadable', reason: 'no ancestor' }
|
||||
})
|
||||
],
|
||||
{ stopOnFailure: false, log: (message) => logged.push(message) }
|
||||
)
|
||||
|
||||
const line = logged.join('\n')
|
||||
expect(line).toContain('app.example')
|
||||
expect(line).not.toContain('super-secret')
|
||||
expect(line).not.toContain('session-token')
|
||||
})
|
||||
|
||||
it('writes __Host- cookies host-only at the root path so Chromium accepts them', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({
|
||||
name: '__Host-session',
|
||||
path: '/deep',
|
||||
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
|
||||
hostOnly: true,
|
||||
path: '/',
|
||||
// A host-prefixed cookie can still be partitioned; the prefix must not drop the partition.
|
||||
partitionKey: PARTITION_KEY
|
||||
})
|
||||
})
|
||||
|
||||
// Why: once existing cookies have been removed, the caller has to roll back, so the run must stop
|
||||
// at the first rejection instead of writing over a jar it is about to restore.
|
||||
it('stops at the first rejection when the caller must roll back', async () => {
|
||||
const store = recordingStore()
|
||||
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
|
||||
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
{ stopOnFailure: true, log: () => undefined }
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(phase.writeRejected).toBe(1)
|
||||
expect(phase.importedCount).toBe(0)
|
||||
expect(phase.failure).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
// Why: the native path has a staged cold-start replay behind it, so one rejected cookie must not
|
||||
// stop the rest from loading.
|
||||
it('keeps going past a rejection when the caller has a restart fallback', async () => {
|
||||
const store = recordingStore()
|
||||
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
|
||||
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(2)
|
||||
expect(phase.writeRejected).toBe(1)
|
||||
expect(phase.importedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reports the removal key at the identity path so a rollback can undo the write', async () => {
|
||||
const phase = await writeImportedCookies(
|
||||
recordingStore(),
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'scoped',
|
||||
url: 'https://app.example/',
|
||||
path: '/settings',
|
||||
partition: { status: 'unpartitioned' }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(phase.attemptedKeys).toEqual([{ url: 'https://app.example/settings', name: 'scoped' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Cookie } from 'electron'
|
||||
import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieImportWriteStore
|
||||
} from './browser-cookie-import-clear'
|
||||
import { registrableFamily } from './browser-cookie-import-policy'
|
||||
import type { SourcePartitionRead } from './browser-cookie-source-partition'
|
||||
|
||||
export type ImportedCookieFields = {
|
||||
url: string
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
sameSite: Cookie['sameSite']
|
||||
expirationDate: number | undefined
|
||||
}
|
||||
|
||||
export type ImportWritePlan =
|
||||
| { status: 'write'; identity: CookieClearIdentity }
|
||||
| { status: 'skip'; reason: string }
|
||||
|
||||
const HOST_PREFIX = '__Host-'
|
||||
|
||||
export function importedCookieIdentity(
|
||||
cookie: ImportedCookieFields,
|
||||
partitionKey: CookieClearPartitionKey | undefined
|
||||
): CookieClearIdentity {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/; hostOnly is how
|
||||
// the identity says "omit domain", the same contract the CDP restore params already read.
|
||||
const isHostPrefixed = cookie.name.startsWith(HOST_PREFIX)
|
||||
return {
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
hostOnly: isHostPrefixed,
|
||||
path: isHostPrefixed ? '/' : cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }),
|
||||
...(partitionKey ? { partitionKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides how one source cookie is written.
|
||||
*
|
||||
* Why (STA-4300): a cookie whose partition identity is unreadable is skipped and counted, never
|
||||
* written unpartitioned. Downgrading it would import a cookie the site cannot see while reporting a
|
||||
* clean success — the silent-loss shape behind STA-4013/4061/4090/4170.
|
||||
*/
|
||||
export function planImportedCookieWrite(
|
||||
cookie: ImportedCookieFields,
|
||||
partition: SourcePartitionRead
|
||||
): ImportWritePlan {
|
||||
if (partition.status === 'unreadable') {
|
||||
return { status: 'skip', reason: partition.reason }
|
||||
}
|
||||
return {
|
||||
status: 'write',
|
||||
identity: importedCookieIdentity(
|
||||
cookie,
|
||||
partition.status === 'partitioned' ? partition.partitionKey : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the rollback removes by coordinate, and remove() is path-sensitive, so the key has to use
|
||||
// the identity's resolved path rather than the source cookie's.
|
||||
export function importedCookieRemovalKey(identity: CookieClearIdentity): {
|
||||
url: string
|
||||
name: string
|
||||
} {
|
||||
const removalUrl = new URL(identity.url)
|
||||
const path = identity.path ?? '/'
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
return { url: removalUrl.toString(), name: identity.name }
|
||||
}
|
||||
|
||||
export type SourceCookieToWrite = ImportedCookieFields & { partition: SourcePartitionRead }
|
||||
|
||||
export type ImportWritePhase = {
|
||||
attemptedKeys: { url: string; name: string }[]
|
||||
importedCount: number
|
||||
writeRejected: number
|
||||
partitionSkipped: number
|
||||
domains: Set<string>
|
||||
failure: unknown
|
||||
}
|
||||
|
||||
export function emptyImportWritePhase(): ImportWritePhase {
|
||||
return {
|
||||
attemptedKeys: [],
|
||||
importedCount: 0,
|
||||
writeRejected: 0,
|
||||
partitionSkipped: 0,
|
||||
domains: new Set<string>(),
|
||||
failure: null
|
||||
}
|
||||
}
|
||||
|
||||
/** The only fields planning needs — path B plans over rows that have no resolved `url` yet. */
|
||||
export type PlannableCookie = { domain: string; partition: SourcePartitionRead }
|
||||
|
||||
export type ImportWriteSkip<T extends PlannableCookie = SourceCookieToWrite> = {
|
||||
cookie: T
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type ImportWritePlanResult<T extends PlannableCookie = SourceCookieToWrite> = {
|
||||
writes: T[]
|
||||
skips: ImportWriteSkip<T>[]
|
||||
/** registrableFamily values whose partition could not be read faithfully. */
|
||||
skippedFamilies: Set<string>
|
||||
/**
|
||||
* True when a skipped cookie's family cannot be named (registrableFamily returned null). The
|
||||
* caller must refuse the import before mutating anything: a family we cannot name is a family we
|
||||
* cannot exclude from the removal plan, and clearing a family we cannot protect is the P0.
|
||||
*/
|
||||
hasUnrepresentableSkip: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides every source cookie's fate BEFORE any jar mutation (STA-4300 invariant I1).
|
||||
*
|
||||
* Two passes, not one, and the second is not optional. Family-atomic skip is a property of the
|
||||
* whole input: with a readable `mixed.example` row *before* an unreadable `sub.mixed.example` row,
|
||||
* a per-row guard emits the readable one before anything knows the family will be skipped. Pass 1
|
||||
* classifies and collects the skipped families; pass 2 re-filters the provisional writes.
|
||||
*
|
||||
* Pure — no I/O — so the caller cannot mutate anything before the plan exists.
|
||||
*/
|
||||
export function planImportWrites<T extends PlannableCookie>(
|
||||
cookies: readonly T[]
|
||||
): ImportWritePlanResult<T> {
|
||||
const provisional: T[] = []
|
||||
const skips: ImportWriteSkip<T>[] = []
|
||||
const skippedFamilies = new Set<string>()
|
||||
let hasUnrepresentableSkip = false
|
||||
|
||||
// Pass 1: classify, and learn which families cannot be written faithfully.
|
||||
for (const cookie of cookies) {
|
||||
// Why: the skip decision is the partition read itself — the same condition
|
||||
// planImportedCookieWrite uses — so path B can plan before it has resolved a write URL.
|
||||
if (cookie.partition.status === 'unreadable') {
|
||||
const family = registrableFamily(cookie.domain)
|
||||
if (family === null) {
|
||||
hasUnrepresentableSkip = true
|
||||
} else {
|
||||
skippedFamilies.add(family)
|
||||
}
|
||||
skips.push({ cookie, reason: cookie.partition.reason })
|
||||
continue
|
||||
}
|
||||
provisional.push(cookie)
|
||||
}
|
||||
|
||||
// Pass 2: a readable cookie whose family was skipped is suppressed too — otherwise its family's
|
||||
// removal scope would be widened by a domain we then decline to write back (STA-4300 §2b).
|
||||
const writes: T[] = []
|
||||
for (const cookie of provisional) {
|
||||
const family = registrableFamily(cookie.domain)
|
||||
if (family !== null && skippedFamilies.has(family)) {
|
||||
skips.push({ cookie, reason: 'family partition unreadable' })
|
||||
continue
|
||||
}
|
||||
writes.push(cookie)
|
||||
}
|
||||
|
||||
return { writes, skips, skippedFamilies, hasUnrepresentableSkip }
|
||||
}
|
||||
|
||||
// Why: cookie values are secret; only the domain is ever logged or summarized.
|
||||
function summaryDomain(domain: string): string {
|
||||
return domain.startsWith('.') ? domain.slice(1) : domain
|
||||
}
|
||||
|
||||
function firstNonPrintable(value: string): string {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
return `pos=${index} char=U+${code.toString(16).padStart(4, '0')}`
|
||||
}
|
||||
}
|
||||
return 'none found'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one import's cookies through the CDP identity store.
|
||||
*
|
||||
* `stopOnFailure` mirrors the replace path's contract: once existing cookies have been removed, the
|
||||
* first rejection has to stop the run so the caller can roll the whole thing back.
|
||||
*/
|
||||
export async function writeImportedCookies(
|
||||
store: Pick<CookieImportWriteStore, 'writeCookieIdentity'>,
|
||||
cookies: readonly SourceCookieToWrite[],
|
||||
options: { stopOnFailure: boolean; log: (message: string) => void }
|
||||
): Promise<ImportWritePhase> {
|
||||
const phase = emptyImportWritePhase()
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const plan = planImportedCookieWrite(cookie, cookie.partition)
|
||||
if (plan.status === 'skip') {
|
||||
phase.partitionSkipped += 1
|
||||
options.log(
|
||||
` cookie skipped, unreadable partition: domain=${summaryDomain(cookie.domain)} ${plan.reason}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
// Why: a rejected CDP command can still have reached Chromium before the transport failed.
|
||||
// Record the coordinate before dispatch so a replace rollback removes every possible write.
|
||||
phase.attemptedKeys.push(importedCookieRemovalKey(plan.identity))
|
||||
try {
|
||||
await store.writeCookieIdentity(plan.identity)
|
||||
phase.importedCount += 1
|
||||
phase.domains.add(summaryDomain(cookie.domain))
|
||||
} catch (err) {
|
||||
phase.writeRejected += 1
|
||||
phase.failure = err
|
||||
if (phase.writeRejected <= 5) {
|
||||
options.log(
|
||||
` cookie write REJECTED: domain=${summaryDomain(cookie.domain)} valLen=${cookie.value.length} badChar=${firstNonPrintable(cookie.value)} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (options.stopOnFailure) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return phase
|
||||
}
|
||||
@@ -8,7 +8,8 @@ const {
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock
|
||||
clearPendingCookieImportMock,
|
||||
writeCookieIdentityMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
@@ -16,7 +17,8 @@ const {
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn()
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
@@ -56,6 +58,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
|
||||
// method would throw a TypeError the per-cookie catch swallows, quietly turning every write
|
||||
// into a "rejected cookie" while the suite still looked green.
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
@@ -97,6 +103,15 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
|
||||
// the __init probe — which writes no user data — is allowed through; anything else is the
|
||||
// downgrade returning, and it must fail the test rather than quietly succeed.
|
||||
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
|
||||
if (details.name !== '__init') {
|
||||
throw new Error(`cookies.set was called for user cookie ${details.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
const LARGE_SAFARI_COOKIE_COUNT = 150_000
|
||||
|
||||
describe('summarizeCookieImportError', () => {
|
||||
@@ -176,19 +191,21 @@ describe('importCookiesFromFile', () => {
|
||||
let tmpDir: string
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-'))
|
||||
cookiesGetMock = vi.fn().mockResolvedValue([])
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: cookiesGetMock,
|
||||
remove: cookiesRemoveMock,
|
||||
set: cookiesSetMock
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -237,8 +254,8 @@ describe('importCookiesFromFile', () => {
|
||||
expect(result.summary.domains).toContain('github.com')
|
||||
expect(result.summary.domains).toContain('example.com')
|
||||
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
|
||||
const firstCall = cookiesSetMock.mock.calls[0][0]
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
|
||||
const firstCall = cookieWriteMock.mock.calls[0][0]
|
||||
expect(firstCall.name).toBe('_gh_sess')
|
||||
expect(firstCall.domain).toBe('.github.com')
|
||||
expect(firstCall.secure).toBe(true)
|
||||
@@ -262,14 +279,18 @@ describe('importCookiesFromFile', () => {
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const hostCall = cookiesSetMock.mock.calls
|
||||
const hostCall = cookieWriteMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((c) => c.name === '__Host-user_session_same_site')
|
||||
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it.
|
||||
expect(hostCall).not.toHaveProperty('domain')
|
||||
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it. hostOnly is how
|
||||
// the identity says "omit domain"; cdpSetCookieParamsFromIdentity drops it on the wire.
|
||||
expect(hostCall.hostOnly).toBe(true)
|
||||
expect(hostCall.path).toBe('/')
|
||||
|
||||
const normalCall = cookiesSetMock.mock.calls.map((c) => c[0]).find((c) => c.name === '_gh_sess')
|
||||
const normalCall = cookieWriteMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((c) => c.name === '_gh_sess')
|
||||
expect(normalCall.hostOnly).toBe(false)
|
||||
expect(normalCall.domain).toBe('.github.com')
|
||||
expect(normalCall.path).toBe('/settings')
|
||||
})
|
||||
@@ -363,11 +384,11 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(cookiesSetMock.mock.calls[0][0].sameSite).toBe('no_restriction')
|
||||
expect(cookiesSetMock.mock.calls[1][0].sameSite).toBe('lax')
|
||||
expect(cookiesSetMock.mock.calls[2][0].sameSite).toBe('strict')
|
||||
expect(cookiesSetMock.mock.calls[3][0].sameSite).toBe('unspecified')
|
||||
expect(cookiesSetMock.mock.calls[4][0].sameSite).toBe('unspecified')
|
||||
expect(cookieWriteMock.mock.calls[0][0].sameSite).toBe('no_restriction')
|
||||
expect(cookieWriteMock.mock.calls[1][0].sameSite).toBe('lax')
|
||||
expect(cookieWriteMock.mock.calls[2][0].sameSite).toBe('strict')
|
||||
expect(cookieWriteMock.mock.calls[3][0].sameSite).toBe('unspecified')
|
||||
expect(cookieWriteMock.mock.calls[4][0].sameSite).toBe('unspecified')
|
||||
})
|
||||
|
||||
it('derives correct URL from domain and secure flag', async () => {
|
||||
@@ -379,13 +400,13 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(cookiesSetMock.mock.calls[0][0].url).toBe('https://secure.com/')
|
||||
expect(cookiesSetMock.mock.calls[1][0].url).toBe('http://insecure.com/')
|
||||
expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
||||
expect(cookieWriteMock.mock.calls[0][0].url).toBe('https://secure.com/')
|
||||
expect(cookieWriteMock.mock.calls[1][0].url).toBe('http://insecure.com/')
|
||||
expect(cookieWriteMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
||||
})
|
||||
|
||||
it('rolls back replacement when a cookie fails to set', async () => {
|
||||
cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
||||
cookieWriteMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
||||
|
||||
const filePath = writeCookieFile([
|
||||
{ domain: '.a.com', name: 'ok', value: '1' },
|
||||
@@ -400,14 +421,16 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
describe('importCookiesFromBrowser Safari', () => {
|
||||
let tmpDir: string
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-'))
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: { set: cookiesSetMock }
|
||||
cookies: { set: unreachableCookieSet }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -429,13 +452,13 @@ describe('importCookiesFromBrowser Safari', () => {
|
||||
const result = await importCookiesFromBrowser(browser, 'persist:test')
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' })
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('importCookiesFromBrowser Chromium', () => {
|
||||
let tmpDir: string
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
|
||||
let clearDataMock: ReturnType<typeof vi.fn>
|
||||
@@ -443,7 +466,9 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
|
||||
clearDataMock = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -461,19 +486,19 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
set: cookiesSetMock,
|
||||
set: unreachableCookieSet,
|
||||
remove: cookiesRemoveMock,
|
||||
flushStore: cookiesFlushStoreMock
|
||||
},
|
||||
clearData: clearDataMock,
|
||||
setUserAgent: setUserAgentMock
|
||||
setUserAgent: setUserAgentMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
@@ -507,7 +532,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: '.example.com',
|
||||
name: 'sid',
|
||||
@@ -568,7 +593,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
|
||||
)
|
||||
} finally {
|
||||
@@ -623,7 +648,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
// The partial staging file is still discarded, so no stale DB replays on cold start.
|
||||
@@ -653,7 +678,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
// The summary counts importable cookies, not staged rows.
|
||||
@@ -675,7 +700,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
mkdirSync(dirname(targetCookiesPath), { recursive: true })
|
||||
writeFileSync(targetCookiesPath, 'not a sqlite database')
|
||||
// Forces the restart fallback to be the only way these cookies could ever land.
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
@@ -717,7 +742,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
targetDb.close()
|
||||
// Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and
|
||||
// the assertion below would pass even if the insert failure never disabled staging.
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
@@ -727,7 +752,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
@@ -773,7 +798,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
{ name: 'sid', value: 'source-value' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { registrableFamily } from './browser-cookie-import-policy'
|
||||
|
||||
// Why (STA-4300): registrableFamily decides which coordinates a skip-bearing import must leave
|
||||
// alone. A family computed too narrowly fails to preserve a sibling and the user loses a session,
|
||||
// so the adversarial host forms are pinned here rather than argued about in review.
|
||||
describe('registrableFamily', () => {
|
||||
it.each([
|
||||
['sub.example.com', 'example.com'],
|
||||
['a.b.c.example.co.uk', 'example.co.uk'],
|
||||
['example.com', 'example.com'],
|
||||
// Why: leading dots are cookie-domain syntax, not part of the family.
|
||||
['.example.com', 'example.com'],
|
||||
['EXAMPLE.COM', 'example.com'],
|
||||
// Why: a private suffix is its own registrable boundary — user.github.io must not collapse to
|
||||
// github.io, or one user's skip would preserve every other user's cookies.
|
||||
['user.github.io', 'user.github.io'],
|
||||
// Why: unlisted single-label hosts have no registrable parent; they are their own family.
|
||||
['localhost', 'localhost']
|
||||
])('maps %s to %s', (host, expected) => {
|
||||
expect(registrableFamily(host)).toBe(expected)
|
||||
})
|
||||
|
||||
// Why: psl treats an IPv4 literal as a dotted DNS name — psl.parse('127.0.0.1').domain is '0.1'.
|
||||
// These pass only because the IP check runs on normalizeCookieDomain's canonicalised output.
|
||||
// Moving the check before normalisation reintroduces a wrong, destructive family.
|
||||
it.each([
|
||||
['127.0.0.1', '127.0.0.1'],
|
||||
['192.168.1.1', '192.168.1.1'],
|
||||
// Chromium accepts all of these spellings of 127.0.0.1; new URL() canonicalises them.
|
||||
['127.1', '127.0.0.1'],
|
||||
['2130706433', '127.0.0.1'],
|
||||
['0x7f.1', '127.0.0.1'],
|
||||
['127.0.0.1.', '127.0.0.1'],
|
||||
// Octal, and 8.0.0.1 is the correct reading — psl would have produced '0.1'.
|
||||
['010.0.0.1', '8.0.0.1']
|
||||
])('recognises the IPv4 literal %s as %s', (host, expected) => {
|
||||
expect(registrableFamily(host)).toBe(expected)
|
||||
})
|
||||
|
||||
// Why: isIP('[::1]') is 0, so the bracketed form needs its own branch. Without it these fall
|
||||
// through to psl, which throws, which happens to return the host — right answer, wrong reason,
|
||||
// and it stops being right the moment the error branch is touched.
|
||||
it.each([
|
||||
['[::1]', '[::1]'],
|
||||
['[2001:db8::1]', '[2001:db8::1]']
|
||||
])('recognises the IPv6 literal %s', (host, expected) => {
|
||||
expect(registrableFamily(host)).toBe(expected)
|
||||
})
|
||||
|
||||
// Why: a bare public suffix names no family. Returning 'com' here would hand an entire TLD to the
|
||||
// preserve set and turn every import under it into a silent no-op.
|
||||
it.each([['com'], ['co.uk'], ['github.io']])('returns null for the public suffix %s', (host) => {
|
||||
expect(registrableFamily(host)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a domain that cannot be normalised', () => {
|
||||
expect(registrableFamily('')).toBeNull()
|
||||
expect(registrableFamily('http://evil.example/path')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readChromiumRowPartition,
|
||||
readFirefoxRowPartition,
|
||||
readJsonCookiePartition
|
||||
} from './browser-cookie-source-partition'
|
||||
|
||||
const MODERN_COLUMNS = new Set([
|
||||
'host_key',
|
||||
'name',
|
||||
'top_frame_site_key',
|
||||
'has_cross_site_ancestor'
|
||||
])
|
||||
|
||||
describe('readChromiumRowPartition', () => {
|
||||
it('reads a partitioned row as both halves of the partition key', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 1n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a false cross-site-ancestor rather than defaulting it to true', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 0n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads an empty partition site as unpartitioned', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: '', has_cross_site_ancestor: 0n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
// Why: a schema predating cookie partitioning genuinely has no partitioned rows, so importing
|
||||
// every one of its cookies unpartitioned is faithful rather than lossy.
|
||||
it('reads a schema without the partition column as unpartitioned', () => {
|
||||
expect(readChromiumRowPartition({ name: 'sid' }, new Set(['host_key', 'name']))).toEqual({
|
||||
status: 'unpartitioned'
|
||||
})
|
||||
})
|
||||
|
||||
// Why (STA-4300): the ancestor bit selects which partition the cookie lands in. Guessing it files
|
||||
// the cookie under a partition the site never reads — indistinguishable from losing it.
|
||||
it('refuses a partitioned row whose schema has no cross-site-ancestor column', () => {
|
||||
const result = readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example' },
|
||||
new Set(['host_key', 'name', 'top_frame_site_key'])
|
||||
)
|
||||
|
||||
expect(result.status).toBe('unreadable')
|
||||
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
|
||||
})
|
||||
|
||||
it('refuses a partitioned row whose ancestor flag is not an integer', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: null },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it.each([2n, -1n, 2, -1, 0.5])('refuses an out-of-range ancestor flag (%s)', (flag) => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: flag },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses a partition site column that is not text', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 42, has_cross_site_ancestor: 1n },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it.each([null, 'not-a-site', 'ftp://top.example', 'https://top.example/path'])(
|
||||
'refuses an invalid partition site (%s)',
|
||||
(topFrameSiteKey) => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: topFrameSiteKey, has_cross_site_ancestor: 1n },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('readJsonCookiePartition', () => {
|
||||
// Why: every mainstream exporter omits the field for ordinary cookies, so absence has to mean
|
||||
// unpartitioned or whole exports would be rejected.
|
||||
it('reads an absent partitionKey as unpartitioned', () => {
|
||||
expect(readJsonCookiePartition(undefined)).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses an opaque partition key even when its key object is absent or populated', () => {
|
||||
expect(readJsonCookiePartition(undefined, true)).toEqual({
|
||||
status: 'unreadable',
|
||||
reason: 'partition key was opaque'
|
||||
})
|
||||
expect(
|
||||
readJsonCookiePartition(
|
||||
{ topLevelSite: 'https://top.example', hasCrossSiteAncestor: true },
|
||||
true
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('accepts the explicit non-opaque CDP shape and rejects a malformed opaque flag', () => {
|
||||
expect(readJsonCookiePartition(undefined, false)).toEqual({ status: 'unpartitioned' })
|
||||
expect(readJsonCookiePartition(undefined, 'false').status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it.each([null, ''])('refuses a present but empty partitionKey (%s)', (partitionKey) => {
|
||||
expect(readJsonCookiePartition(partitionKey).status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('reads a complete partitionKey object', () => {
|
||||
expect(
|
||||
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: true })
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
})
|
||||
|
||||
// Why: exporters that emit only topLevelSite carry no ancestor bit. Skipping is reported; a
|
||||
// guessed bit would be a silent misfile.
|
||||
it('refuses a partitionKey missing the cross-site-ancestor bit', () => {
|
||||
expect(readJsonCookiePartition({ topLevelSite: 'https://top.example' }).status).toBe(
|
||||
'unreadable'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses the legacy string partitionKey form', () => {
|
||||
expect(readJsonCookiePartition('https://top.example').status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses a partitionKey with a non-boolean ancestor bit', () => {
|
||||
expect(
|
||||
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: 'yes' })
|
||||
.status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses an array or empty-site partitionKey', () => {
|
||||
expect(readJsonCookiePartition([]).status).toBe('unreadable')
|
||||
expect(readJsonCookiePartition({ topLevelSite: '', hasCrossSiteAncestor: true }).status).toBe(
|
||||
'unreadable'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['not-a-site', 'ftp://top.example', 'https://top.example/path'])(
|
||||
'refuses an invalid JSON partition site (%s)',
|
||||
(topLevelSite) => {
|
||||
expect(readJsonCookiePartition({ topLevelSite, hasCrossSiteAncestor: true }).status).toBe(
|
||||
'unreadable'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('readFirefoxRowPartition', () => {
|
||||
// Why: originAttributes partitionKey components describe Firefox storage isolation, not whether
|
||||
// the server declared the cookie Partitioned.
|
||||
it('keeps a dFPI row unpartitioned even with ancestor context', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{
|
||||
originAttributes: '^partitionKey=(https,example.com,f)',
|
||||
isPartitionedAttributeSet: 0n
|
||||
},
|
||||
new Set(['originAttributes', 'isPartitionedAttributeSet'])
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses a server-declared partitioned cookie', () => {
|
||||
const result = readFirefoxRowPartition(
|
||||
{
|
||||
originAttributes: '^partitionKey=(https,example.com)',
|
||||
isPartitionedAttributeSet: 1n
|
||||
},
|
||||
new Set(['originAttributes', 'isPartitionedAttributeSet'])
|
||||
)
|
||||
|
||||
expect(result.status).toBe('unreadable')
|
||||
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
|
||||
})
|
||||
|
||||
it('reads a schema without the partitioned-attribute column as unpartitioned', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{ originAttributes: '^partitionKey=(https,example.com,f)' },
|
||||
new Set(['originAttributes'])
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses an invalid partitioned-attribute flag', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{ isPartitionedAttributeSet: null },
|
||||
new Set(['isPartitionedAttributeSet'])
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { CookieClearPartitionKey } from './browser-cookie-import-clear'
|
||||
|
||||
// Why (STA-4300): a partition identity that cannot be read faithfully must skip the cookie, never
|
||||
// downgrade it. An unpartitioned write of a partitioned cookie looks like a success and leaves the
|
||||
// site unable to see its own session — the failure mode that produced STA-4013/4061/4090/4170.
|
||||
export type SourcePartitionRead =
|
||||
| { status: 'unpartitioned' }
|
||||
| { status: 'partitioned'; partitionKey: CookieClearPartitionKey }
|
||||
| { status: 'unreadable'; reason: string }
|
||||
|
||||
const CHROMIUM_PARTITION_SITE_COLUMN = 'top_frame_site_key'
|
||||
const CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN = 'has_cross_site_ancestor'
|
||||
|
||||
const UNPARTITIONED: SourcePartitionRead = { status: 'unpartitioned' }
|
||||
|
||||
function readSqliteFlag(raw: unknown): boolean | null {
|
||||
if (typeof raw === 'boolean') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'bigint') {
|
||||
return raw === 0n ? false : raw === 1n ? true : null
|
||||
}
|
||||
if (typeof raw === 'number') {
|
||||
return raw === 0 ? false : raw === 1 ? true : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeCookiePartitionSite(raw: string): string | null {
|
||||
try {
|
||||
const site = new URL(raw)
|
||||
if (
|
||||
(site.protocol !== 'http:' && site.protocol !== 'https:') ||
|
||||
!site.hostname ||
|
||||
site.username ||
|
||||
site.password ||
|
||||
site.port ||
|
||||
site.pathname !== '/' ||
|
||||
site.search ||
|
||||
site.hash
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return site.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a Chromium cookie row's partition identity.
|
||||
*
|
||||
* Chromium stores the partition as `top_frame_site_key` (empty string when unpartitioned) plus
|
||||
* `has_cross_site_ancestor`, which older schemas predate. Both halves are required: a partition key
|
||||
* written with the wrong ancestor bit files the cookie under a partition the site never reads, which
|
||||
* is indistinguishable from losing it.
|
||||
*/
|
||||
export function readChromiumRowPartition(
|
||||
sourceRow: Record<string, unknown>,
|
||||
sourceColumns: ReadonlySet<string>
|
||||
): SourcePartitionRead {
|
||||
// Why: a schema without the column predates cookie partitioning, so every row is genuinely
|
||||
// unpartitioned — that is a faithful read, not a missing one.
|
||||
if (!sourceColumns.has(CHROMIUM_PARTITION_SITE_COLUMN)) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
|
||||
const rawSite = sourceRow[CHROMIUM_PARTITION_SITE_COLUMN]
|
||||
if (rawSite === '') {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (typeof rawSite !== 'string') {
|
||||
return { status: 'unreadable', reason: 'partition site column was not text' }
|
||||
}
|
||||
const topLevelSite = normalizeCookiePartitionSite(rawSite)
|
||||
if (!topLevelSite) {
|
||||
return { status: 'unreadable', reason: 'partition site column was not a valid schemeful site' }
|
||||
}
|
||||
|
||||
if (!sourceColumns.has(CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN)) {
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'source schema has no cross-site-ancestor column for a partitioned cookie'
|
||||
}
|
||||
}
|
||||
const hasCrossSiteAncestor = readSqliteFlag(sourceRow[CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN])
|
||||
if (hasCrossSiteAncestor === null) {
|
||||
return { status: 'unreadable', reason: 'cross-site-ancestor column was not an integer flag' }
|
||||
}
|
||||
|
||||
return { status: 'partitioned', partitionKey: { topLevelSite, hasCrossSiteAncestor } }
|
||||
}
|
||||
|
||||
const FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN = 'isPartitionedAttributeSet'
|
||||
|
||||
/**
|
||||
* Reads whether Firefox recorded the server-declared `Partitioned` attribute.
|
||||
*
|
||||
* `originAttributes.partitionKey` is Firefox storage isolation and may include port or ancestor
|
||||
* context. The separate `isPartitionedAttributeSet` column is the server-declared CHIPS signal.
|
||||
*/
|
||||
export function readFirefoxRowPartition(
|
||||
sourceRow: Record<string, unknown>,
|
||||
sourceColumns: ReadonlySet<string>
|
||||
): SourcePartitionRead {
|
||||
if (!sourceColumns.has(FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN)) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
const partitionedAttribute = readSqliteFlag(sourceRow[FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN])
|
||||
if (partitionedAttribute === false) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (partitionedAttribute === null) {
|
||||
return { status: 'unreadable', reason: 'partitioned-attribute column was not an integer flag' }
|
||||
}
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'Firefox partitioned-attribute cookie has no cross-site-ancestor bit to read'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a JSON cookie entry's partition identity.
|
||||
*
|
||||
* Absent means unpartitioned — every mainstream exporter omits the field for ordinary cookies, so
|
||||
* treating absence as unreadable would reject whole exports. Present-but-incomplete is unreadable:
|
||||
* exporters that emit only `topLevelSite` (or the legacy CDP string form) carry no ancestor bit, and
|
||||
* guessing it silently misfiles the cookie.
|
||||
*/
|
||||
export function readJsonCookiePartition(
|
||||
raw: unknown,
|
||||
partitionKeyOpaque: unknown = undefined
|
||||
): SourcePartitionRead {
|
||||
if (partitionKeyOpaque === true) {
|
||||
return { status: 'unreadable', reason: 'partition key was opaque' }
|
||||
}
|
||||
if (partitionKeyOpaque !== undefined && typeof partitionKeyOpaque !== 'boolean') {
|
||||
return { status: 'unreadable', reason: 'partitionKeyOpaque was not a boolean' }
|
||||
}
|
||||
if (raw === undefined) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return { status: 'unreadable', reason: 'partitionKey was not an object with both fields' }
|
||||
}
|
||||
|
||||
const { topLevelSite, hasCrossSiteAncestor } = raw as Record<string, unknown>
|
||||
if (typeof topLevelSite !== 'string' || topLevelSite.length === 0) {
|
||||
return { status: 'unreadable', reason: 'partitionKey.topLevelSite was missing or not text' }
|
||||
}
|
||||
const normalizedTopLevelSite = normalizeCookiePartitionSite(topLevelSite)
|
||||
if (!normalizedTopLevelSite) {
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'partitionKey.topLevelSite was not a valid schemeful site'
|
||||
}
|
||||
}
|
||||
if (typeof hasCrossSiteAncestor !== 'boolean') {
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'partitionKey.hasCrossSiteAncestor was missing or not a boolean'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: normalizedTopLevelSite, hasCrossSiteAncestor }
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu Test',
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
"export CLAUDE_CONFIG_DIR='/home/user/.config/orca auth'; exec claude 'auth' 'status' '--json'"
|
||||
|
||||
@@ -1090,7 +1090,7 @@ export class ClaudeRuntimeAuthService {
|
||||
[
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
|
||||
@@ -667,7 +667,7 @@ export class ClaudeAccountService {
|
||||
[
|
||||
'-d',
|
||||
location.wslDistro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
'mktemp -d "${TMPDIR:-/tmp}/orca-claude-login.XXXXXX"'
|
||||
@@ -698,7 +698,7 @@ export class ClaudeAccountService {
|
||||
[
|
||||
'-d',
|
||||
tempConfig.wslDistro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`rm -rf -- ${shellQuote(tempConfig.linuxPath)}`
|
||||
@@ -915,7 +915,7 @@ export class ClaudeAccountService {
|
||||
const distroArgs = target.wslDistro?.trim() ? ['-d', target.wslDistro.trim()] : []
|
||||
const infoOutput = execFileSync(
|
||||
'wsl.exe',
|
||||
[...distroArgs, '--', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
|
||||
[...distroArgs, '--exec', 'bash', '-lc', 'printf "%s\\n%s\\n" "$WSL_DISTRO_NAME" "$HOME"'],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
const [rawDistro, rawHome] = infoOutput
|
||||
@@ -935,7 +935,7 @@ export class ClaudeAccountService {
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`mkdir -p ${shellQuote(wslLinuxAuthPath)} && printf '%s\\n' ${shellQuote(accountId)} > ${shellQuote(markerPath)}`
|
||||
@@ -974,7 +974,7 @@ export class ClaudeAccountService {
|
||||
[
|
||||
'-d',
|
||||
wslInfo.distro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
buildEncodedWslBashCommand(
|
||||
@@ -1058,7 +1058,7 @@ export class ClaudeAccountService {
|
||||
args: [
|
||||
'-d',
|
||||
configDir.wslDistro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
`export CLAUDE_CONFIG_DIR=${shellQuote(configDir.linuxPath)}; exec claude ${args.map(shellQuote).join(' ')}`
|
||||
|
||||
@@ -484,7 +484,7 @@ async function runWslCommand(distro: string, command: string): Promise<string> {
|
||||
try {
|
||||
child = execFile(
|
||||
'wsl.exe',
|
||||
['-d', distro, '--', 'bash', '-lc', buildEncodedWslBashCommand(command)],
|
||||
['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(command)],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: WSL_COMMAND_TIMEOUT_MS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
|
||||
type HostCodexManagedHomeOwnershipOptions = {
|
||||
candidatePath: string
|
||||
@@ -8,6 +9,38 @@ type HostCodexManagedHomeOwnershipOptions = {
|
||||
expectedAccountId?: string
|
||||
}
|
||||
|
||||
export const MISSING_MANAGED_HOME_MESSAGE = 'Managed Codex home directory does not exist on disk.'
|
||||
|
||||
/**
|
||||
* Why: the gate answers two different questions and callers act on them very
|
||||
* differently. `untrusted` is a *successful observation* that failed a trust
|
||||
* check (or a definitive absence where absence is itself the verdict), and only
|
||||
* it may clear the user's persisted account selection. `indeterminate` means we
|
||||
* could not read the home at all — the home may be perfectly valid, so callers
|
||||
* must refuse to *use* it without erasing durable state (#STA-4422: one EBUSY
|
||||
* from an antivirus lock used to log the user out permanently).
|
||||
*/
|
||||
export type HostCodexManagedHomeVerdict =
|
||||
| { kind: 'owned'; homePath: string }
|
||||
| { kind: 'untrusted'; reason: string }
|
||||
| { kind: 'indeterminate'; error: unknown }
|
||||
|
||||
/** Thrown for a proven trust failure; safe to clear selection on. */
|
||||
export class UntrustedManagedCodexHomeError extends Error {}
|
||||
|
||||
/**
|
||||
* Thrown when the home could not be read. Callers must refuse the operation and
|
||||
* leave persisted selection, credentials, and managed directories untouched.
|
||||
*/
|
||||
export class ManagedCodexHomeTemporarilyUnavailableError extends Error {
|
||||
constructor(
|
||||
message = 'Codex account files are temporarily locked. Retry in a moment.',
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
|
||||
function pathsEqual(left: string, right: string): boolean {
|
||||
const resolvedLeft = resolve(left)
|
||||
const resolvedRight = resolve(right)
|
||||
@@ -24,73 +57,148 @@ function pathIsInsideOrEqual(rootPath: string, candidatePath: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: the system Codex home is optional — a user with no ~/.codex is normal,
|
||||
* and its absence says nothing about the managed candidate. Only a definitive
|
||||
* absence may fall back to the resolved spelling; any other read error is
|
||||
* inconclusive and must reach the caller as `indeterminate`.
|
||||
*/
|
||||
function canonicalizeIfPresent(candidatePath: string): string {
|
||||
const resolvedPath = resolve(candidatePath)
|
||||
return existsSync(resolvedPath) ? realpathSync(resolvedPath) : resolvedPath
|
||||
try {
|
||||
return realpathSync(resolvedPath)
|
||||
} catch (error) {
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
return resolvedPath
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves a host managed home is an Orca-owned account directory and cannot
|
||||
* resolve into the user's real CODEX_HOME before callers write launch state.
|
||||
*/
|
||||
export function assertOwnedHostCodexManagedHomePath({
|
||||
function evaluate({
|
||||
candidatePath,
|
||||
managedAccountsRoot,
|
||||
systemCodexHomePath,
|
||||
expectedAccountId
|
||||
}: HostCodexManagedHomeOwnershipOptions): string {
|
||||
}: HostCodexManagedHomeOwnershipOptions): HostCodexManagedHomeVerdict {
|
||||
const resolvedCandidate = resolve(candidatePath)
|
||||
const resolvedRoot = resolve(managedAccountsRoot)
|
||||
if (!existsSync(resolvedCandidate)) {
|
||||
throw new Error('Managed Codex home directory does not exist on disk.')
|
||||
|
||||
// Why: absence of the candidate IS the structural verdict here, but only a
|
||||
// definitive one. `existsSync` used to fold EPERM into "does not exist", which
|
||||
// is what made an antivirus lock look like a deleted account.
|
||||
let canonicalCandidate: string
|
||||
try {
|
||||
statSync(resolvedCandidate)
|
||||
canonicalCandidate = realpathSync(resolvedCandidate)
|
||||
} catch (error) {
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
return { kind: 'untrusted', reason: MISSING_MANAGED_HOME_MESSAGE }
|
||||
}
|
||||
return { kind: 'indeterminate', error }
|
||||
}
|
||||
// Why: macOS can spell one path as /var and /private/var, while a replaced
|
||||
// path component must still be caught as a canonical containment escape.
|
||||
const canonicalCandidate = realpathSync(resolvedCandidate)
|
||||
const canonicalRoot = realpathSync(resolvedRoot)
|
||||
const canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath)
|
||||
|
||||
let canonicalRoot: string
|
||||
let canonicalSystemHome: string
|
||||
try {
|
||||
canonicalRoot = realpathSync(resolvedRoot)
|
||||
canonicalSystemHome = canonicalizeIfPresent(systemCodexHomePath)
|
||||
} catch (error) {
|
||||
return { kind: 'indeterminate', error }
|
||||
}
|
||||
|
||||
if (expectedAccountId !== undefined) {
|
||||
const candidateUsesManagedRootSpelling =
|
||||
pathIsInsideOrEqual(resolvedRoot, resolvedCandidate) ||
|
||||
pathIsInsideOrEqual(canonicalRoot, resolvedCandidate)
|
||||
const canonicalExpectedHome = canonicalizeIfPresent(
|
||||
join(canonicalRoot, expectedAccountId, 'home')
|
||||
)
|
||||
let canonicalExpectedHome: string
|
||||
try {
|
||||
canonicalExpectedHome = canonicalizeIfPresent(join(canonicalRoot, expectedAccountId, 'home'))
|
||||
} catch (error) {
|
||||
return { kind: 'indeterminate', error }
|
||||
}
|
||||
if (
|
||||
!candidateUsesManagedRootSpelling ||
|
||||
!pathsEqual(canonicalCandidate, canonicalExpectedHome)
|
||||
) {
|
||||
throw new Error('Managed Codex home does not match its persisted account ID.')
|
||||
return {
|
||||
kind: 'untrusted',
|
||||
reason: 'Managed Codex home does not match its persisted account ID.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a replaced codex-accounts directory could otherwise redirect config,
|
||||
// hook, or resource writes into the user's real ~/.codex tree.
|
||||
if (pathIsInsideOrEqual(canonicalSystemHome, canonicalCandidate)) {
|
||||
throw new Error('Managed Codex home resolves inside the system Codex home.')
|
||||
return {
|
||||
kind: 'untrusted',
|
||||
reason: 'Managed Codex home resolves inside the system Codex home.'
|
||||
}
|
||||
}
|
||||
if (
|
||||
!pathIsInsideOrEqual(canonicalRoot, canonicalCandidate) ||
|
||||
canonicalRoot === canonicalCandidate
|
||||
) {
|
||||
throw new Error(
|
||||
`Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
|
||||
)
|
||||
return {
|
||||
kind: 'untrusted',
|
||||
reason: `Managed Codex home is outside current storage root (expected under ${canonicalRoot}).`
|
||||
}
|
||||
}
|
||||
|
||||
const markerPath = join(canonicalCandidate, '.orca-managed-home')
|
||||
let markerIsRegularFile: boolean
|
||||
let markerContents: string
|
||||
try {
|
||||
markerIsRegularFile = lstatSync(markerPath).isFile()
|
||||
markerContents = markerIsRegularFile ? readFileSync(markerPath, 'utf-8') : ''
|
||||
} catch (error) {
|
||||
throw new Error('Managed Codex home is missing Orca ownership marker.', { cause: error })
|
||||
// Why: the marker is required, so its definitive absence is structural — but
|
||||
// an unreadable marker is not evidence of anything.
|
||||
if (isDefinitiveAbsence(error)) {
|
||||
return { kind: 'untrusted', reason: 'Managed Codex home is missing Orca ownership marker.' }
|
||||
}
|
||||
return { kind: 'indeterminate', error }
|
||||
}
|
||||
if (!markerIsRegularFile) {
|
||||
throw new Error('Managed Codex home ownership marker is not a regular file.')
|
||||
return {
|
||||
kind: 'untrusted',
|
||||
reason: 'Managed Codex home ownership marker is not a regular file.'
|
||||
}
|
||||
}
|
||||
const markerContents = readFileSync(markerPath, 'utf-8')
|
||||
if (expectedAccountId !== undefined && markerContents.trim() !== expectedAccountId) {
|
||||
throw new Error('Managed Codex home ownership marker does not match its account ID.')
|
||||
return {
|
||||
kind: 'untrusted',
|
||||
reason: 'Managed Codex home ownership marker does not match its account ID.'
|
||||
}
|
||||
}
|
||||
|
||||
return canonicalCandidate
|
||||
return { kind: 'owned', homePath: canonicalCandidate }
|
||||
}
|
||||
|
||||
/** Non-throwing verdict; callers decide how to treat each kind. */
|
||||
export function resolveHostCodexManagedHomeVerdict(
|
||||
options: HostCodexManagedHomeOwnershipOptions
|
||||
): HostCodexManagedHomeVerdict {
|
||||
return evaluate(options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwing wrapper for write paths (add, re-auth, migration) that must never
|
||||
* proceed on an unproven home. Distinct error classes keep an unreadable home
|
||||
* from being mistaken for a deleted one — `isMissingManagedHomeError` must not
|
||||
* match the temporary case, or re-auth would recreate the home and rewrite its
|
||||
* ownership marker after a transient stat failure.
|
||||
*/
|
||||
export function assertOwnedHostCodexManagedHomePath(
|
||||
options: HostCodexManagedHomeOwnershipOptions
|
||||
): string {
|
||||
const verdict = evaluate(options)
|
||||
if (verdict.kind === 'owned') {
|
||||
return verdict.homePath
|
||||
}
|
||||
if (verdict.kind === 'untrusted') {
|
||||
throw new UntrustedManagedCodexHomeError(verdict.reason)
|
||||
}
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError(undefined, { cause: verdict.error })
|
||||
}
|
||||
|
||||
@@ -101,7 +101,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(managedHomePath1)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: managedHomePath1
|
||||
})
|
||||
expect(service.prepareForCodexLaunch()).toBe(managedHomePath1)
|
||||
expect(existsSync(join(managedHomePath1, 'auth.json'))).toBe(false)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(createStore([], null) as never)
|
||||
|
||||
expect(service.getMirroredHostHomePathForStatus()).toBeNull()
|
||||
expect(service.getMirroredHostHomePathForStatus()).toEqual({ kind: 'ready', homePath: null })
|
||||
})
|
||||
|
||||
it('returns the selected account own home, which is what its mirror targets', async () => {
|
||||
@@ -84,7 +84,10 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(createStore([account], account.id) as never)
|
||||
|
||||
expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath)
|
||||
expect(service.getMirroredHostHomePathForStatus()).toEqual({
|
||||
kind: 'ready',
|
||||
homePath: account.managedHomePath
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => {
|
||||
@@ -95,6 +98,9 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => {
|
||||
|
||||
// Why: compare against the real helper, not a repeated literal, so the
|
||||
// status cannot silently drift if the managed home layout ever moves.
|
||||
expect(service.getMirroredHostHomePathForStatus()).toBe(getOrcaManagedCodexHomePath())
|
||||
expect(service.getMirroredHostHomePathForStatus()).toEqual({
|
||||
kind: 'ready',
|
||||
homePath: getOrcaManagedCodexHomePath()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(home1)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: home1 })
|
||||
})
|
||||
|
||||
it('preserves a managed selection whose auth.json is temporarily missing', async () => {
|
||||
@@ -253,7 +253,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(brokenHome)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(brokenHome)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({ kind: 'ready', codexHomePath: brokenHome })
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1')
|
||||
})
|
||||
|
||||
@@ -435,12 +435,18 @@ describe('CodexRuntimeHomeService', () => {
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
|
||||
const syncSpy = vi.spyOn(service, 'syncForCurrentSelection')
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(syncSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -109,7 +109,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(service.beginHostSystemDefaultSessionMigrationLaunch(getRuntimeCodexHomePath())).toBe(
|
||||
true
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getRuntimeCodexHomePath()
|
||||
})
|
||||
expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()])
|
||||
expect(existsSync(getRuntimeCodexHomePath())).toBe(true)
|
||||
})
|
||||
@@ -176,13 +179,19 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
// Background fetchers prefer ambient CODEX_HOME when passed null, so an
|
||||
// explicit path proves nested Orca launches cannot poll the managed home.
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
process.env.CODEX_HOME = getSystemCodexHomePath()
|
||||
delete process.env.ORCA_CODEX_HOME
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(true)
|
||||
process.env.CODEX_HOME = join(testState.fakeHomeDir, 'user-owned-codex-home')
|
||||
expect(service.isHostSystemDefaultRealHome()).toBe(false)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getRuntimeCodexHomePath()
|
||||
})
|
||||
} finally {
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME
|
||||
@@ -242,7 +251,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
service.reconcileLegacySharedHomeForRetainedPanes()
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
|
||||
expect(syncLegacySharedCodexConfigForRetainedPanes).not.toHaveBeenCalled()
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { realpathSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { CodexRuntimeHomeService } from './runtime-home-service'
|
||||
import { createSettings } from './runtime-home-settings-test-fixtures'
|
||||
import {
|
||||
createCodexAccountRecord,
|
||||
createCodexAuthJson,
|
||||
createManagedAuth,
|
||||
createStore,
|
||||
getSystemCodexAuthPath,
|
||||
setupRuntimeHomeTest,
|
||||
teardownRuntimeHomeTest,
|
||||
testState
|
||||
} from './runtime-home-service-test-harness'
|
||||
|
||||
// STA-4422 P1g: automatic session resume picks the resumed pane's CODEX_HOME,
|
||||
// i.e. its account. The read-only resolver collapses an unreadable home to
|
||||
// `null`, which the ranking reads as "no account selected" — another account's
|
||||
// readable alias then wins and the pane resumes under the wrong credentials.
|
||||
// The resume boundary therefore resolves the selection eagerly through a
|
||||
// refusing gate.
|
||||
|
||||
const lstatFaults = vi.hoisted(() => {
|
||||
const state = {
|
||||
/** paths that fail on EVERY read until released (models a held AV lock) */
|
||||
held: new Set<string>(),
|
||||
reads: new Map<string, number>(),
|
||||
hold(path: string): void {
|
||||
state.held.add(path)
|
||||
},
|
||||
release(path: string): void {
|
||||
state.held.delete(path)
|
||||
},
|
||||
heldReads(path: string): number {
|
||||
return state.reads.get(path) ?? 0
|
||||
},
|
||||
reset(): void {
|
||||
state.held.clear()
|
||||
state.reads.clear()
|
||||
},
|
||||
consume(target: unknown): void {
|
||||
if (typeof target !== 'string' || !state.held.has(target)) {
|
||||
return
|
||||
}
|
||||
state.reads.set(target, (state.reads.get(target) ?? 0) + 1)
|
||||
const error: NodeJS.ErrnoException = new Error(
|
||||
`EPERM: operation not permitted, lstat '${target}'`
|
||||
)
|
||||
error.code = 'EPERM'
|
||||
error.errno = -4048
|
||||
error.syscall = 'lstat'
|
||||
error.path = target
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return state
|
||||
})
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
const original = actual.lstatSync as (...args: unknown[]) => unknown
|
||||
const patched: Record<string, unknown> = {
|
||||
...actual,
|
||||
lstatSync: Object.assign((...args: unknown[]): unknown => {
|
||||
lstatFaults.consume(args[0])
|
||||
return original(...args)
|
||||
}, original)
|
||||
}
|
||||
return { ...patched, default: patched }
|
||||
})
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.userDataDir
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => testState.fakeHomeDir
|
||||
}
|
||||
})
|
||||
|
||||
async function createServiceWithSelectedAccount(): Promise<{
|
||||
service: CodexRuntimeHomeService
|
||||
store: ReturnType<typeof createStore>
|
||||
managedHomePath: string
|
||||
}> {
|
||||
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
|
||||
const managedHomePath = createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'account-a',
|
||||
createCodexAuthJson('a@example.com', 'acct-a', 'refresh-a')
|
||||
)
|
||||
createManagedAuth(
|
||||
testState.userDataDir,
|
||||
'account-b',
|
||||
createCodexAuthJson('b@example.com', 'acct-b', 'refresh-b')
|
||||
)
|
||||
const store = createStore(
|
||||
createSettings({
|
||||
shellStartupEnvProbeSupported: true,
|
||||
codexManagedAccounts: [
|
||||
createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', managedHomePath),
|
||||
createCodexAccountRecord(
|
||||
'account-b',
|
||||
'b@example.com',
|
||||
'acct-b',
|
||||
join(testState.userDataDir, 'codex-accounts', 'account-b', 'home')
|
||||
)
|
||||
],
|
||||
activeCodexManagedAccountId: 'account-a',
|
||||
activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} }
|
||||
})
|
||||
)
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
return { service: new CodexRuntimeHomeService(store as never), store, managedHomePath }
|
||||
}
|
||||
|
||||
describe('CodexRuntimeHomeService.resolveSelectedHostAccountCodexHomePathForResume', () => {
|
||||
beforeEach(() => {
|
||||
lstatFaults.reset()
|
||||
setupRuntimeHomeTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
lstatFaults.reset()
|
||||
teardownRuntimeHomeTest()
|
||||
})
|
||||
|
||||
it('refuses the resume while the selected account marker is locked, instead of reporting no selection', async () => {
|
||||
const { service, store, managedHomePath } = await createServiceWithSelectedAccount()
|
||||
const { ManagedCodexHomeTemporarilyUnavailableError } =
|
||||
await import('./host-codex-managed-home-ownership')
|
||||
|
||||
// Anchor: a readable marker resolves the selected account's own home.
|
||||
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath)
|
||||
|
||||
const markerPath = join(realpathSync(managedHomePath), '.orca-managed-home')
|
||||
lstatFaults.hold(markerPath)
|
||||
|
||||
expect(() => service.resolveSelectedHostAccountCodexHomePathForResume()).toThrow(
|
||||
ManagedCodexHomeTemporarilyUnavailableError
|
||||
)
|
||||
// The injected fault really was consumed by the gate under test.
|
||||
expect(lstatFaults.heldReads(markerPath)).toBeGreaterThan(0)
|
||||
// Why this is the whole point: the read-only resolver still answers `null`,
|
||||
// which the resume ranking cannot distinguish from "system default", so the
|
||||
// resume boundary must not use it.
|
||||
expect(service.getSelectedHostAccountCodexHomePath()).toBeNull()
|
||||
// Refusing is not clearing.
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBe('account-a')
|
||||
expect(store.updateSettings).not.toHaveBeenCalled()
|
||||
|
||||
// Recovery is automatic once the lock clears.
|
||||
lstatFaults.release(markerPath)
|
||||
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBe(managedHomePath)
|
||||
})
|
||||
|
||||
it('reports no managed selection for a proven untrusted home rather than refusing', async () => {
|
||||
const { service, store, managedHomePath } = await createServiceWithSelectedAccount()
|
||||
|
||||
// A successful observation that fails a trust check: the marker names
|
||||
// another account. The resume must proceed on the system/default ranking.
|
||||
writeFileSync(join(managedHomePath, '.orca-managed-home'), 'someone-else\n', 'utf-8')
|
||||
|
||||
expect(service.resolveSelectedHostAccountCodexHomePathForResume()).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountId).toBeNull()
|
||||
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull()
|
||||
expect(store.updateSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -178,7 +178,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
setShellStartupEnvProbeSupportedForTest(true)
|
||||
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
|
||||
})
|
||||
|
||||
@@ -266,9 +269,15 @@ describe('CodexRuntimeHomeService', () => {
|
||||
service.syncForCurrentSelection()
|
||||
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
|
||||
})
|
||||
|
||||
@@ -287,7 +296,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8')
|
||||
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
|
||||
expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
|
||||
})
|
||||
@@ -305,7 +317,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8')
|
||||
rmSync(getSystemCodexAuthPath())
|
||||
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
|
||||
})
|
||||
|
||||
@@ -331,7 +346,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
setShellStartupEnvProbeSupportedForTest(true)
|
||||
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth)
|
||||
})
|
||||
|
||||
@@ -371,7 +389,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
setShellStartupEnvProbeSupportedForTest(true)
|
||||
rmSync(getSystemCodexAuthPath())
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth)
|
||||
})
|
||||
|
||||
@@ -390,7 +411,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
setShellStartupEnvProbeSupportedForTest(true)
|
||||
writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth)
|
||||
})
|
||||
|
||||
@@ -405,11 +429,17 @@ describe('CodexRuntimeHomeService', () => {
|
||||
setShellStartupEnvProbeSupportedForTest(true)
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
rmSync(getSystemCodexAuthPath())
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
|
||||
|
||||
writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth)
|
||||
})
|
||||
|
||||
@@ -443,7 +473,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
|
||||
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
|
||||
writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8')
|
||||
expect(restartedService.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath())
|
||||
expect(restartedService.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getSystemCodexHomePath()
|
||||
})
|
||||
expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(reloginAuth)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -136,7 +136,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
|
||||
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated)
|
||||
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
|
||||
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: account.managedHomePath
|
||||
})
|
||||
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
|
||||
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(migrated)
|
||||
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
|
||||
@@ -157,7 +160,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
|
||||
settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} }
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBeNull()
|
||||
expect(service.prepareForRateLimitFetch()).toBe(systemHome())
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: systemHome()
|
||||
})
|
||||
expect(readFileSync(join(account.managedHomePath, 'auth.json'), 'utf-8')).toBe(fresh)
|
||||
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(mismatch)
|
||||
expect(readFileSync(systemAuthPath(), 'utf-8')).toBe('system auth sentinel\n')
|
||||
@@ -180,7 +186,10 @@ describe('CodexRuntimeHomeService per-account takeover composition', () => {
|
||||
writeFileSync(sharedAuthPath(), laterShared, 'utf-8')
|
||||
|
||||
expect(service.prepareForCodexLaunch()).toBe(account.managedHomePath)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(account.managedHomePath)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: account.managedHomePath
|
||||
})
|
||||
expect(existsSync(accountAuthPath)).toBe(false)
|
||||
expect(settings.activeCodexManagedAccountId).toBe(account.id)
|
||||
expect(readFileSync(sharedAuthPath(), 'utf-8')).toBe(laterShared)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
symlinkSync,
|
||||
unlinkSync
|
||||
} from 'node:fs'
|
||||
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
dirname,
|
||||
@@ -72,7 +73,10 @@ import {
|
||||
invalidateCodexSessionBackfillMarker
|
||||
} from '../codex/codex-session-backfill-marker'
|
||||
import { resolveCodexSessionBackfillPaths } from '../codex/codex-session-backfill'
|
||||
import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership'
|
||||
import {
|
||||
ManagedCodexHomeTemporarilyUnavailableError,
|
||||
resolveHostCodexManagedHomeVerdict
|
||||
} from './host-codex-managed-home-ownership'
|
||||
import {
|
||||
codexAuthCouldBelongToManagedAccount,
|
||||
codexAuthIsFresher,
|
||||
@@ -169,6 +173,20 @@ function codexAuthIsMonotonicallyFresher(
|
||||
return codexAuthIsFresher(candidateAuthJson, baselineAuthJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: a skipped Codex quota poll must be distinguishable from "use the
|
||||
* system-default home". `null` inside `ready` still means the system lane;
|
||||
* `skip` means do not fetch at all this cycle (#STA-4422).
|
||||
*/
|
||||
/** Mirror path for the config-sync status channel; `unavailable` is not `null`. */
|
||||
export type CodexMirroredHomeStatus =
|
||||
| { kind: 'ready'; homePath: string | null }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export type CodexRateLimitHomeResolution =
|
||||
| { kind: 'ready'; codexHomePath: string | null }
|
||||
| { kind: 'skip' }
|
||||
|
||||
export class CodexRuntimeHomeService {
|
||||
// Which managed account runtime auth.json mirrors; null means it follows system-default ~/.codex instead of a managed account.
|
||||
private lastSyncedAccountId: string | null = null
|
||||
@@ -345,11 +363,18 @@ export class CodexRuntimeHomeService {
|
||||
account: CodexManagedAccount,
|
||||
unavailableManagedHomePath?: string
|
||||
): string | null {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
if (!perAccountHome) {
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: refuse the launch rather than silently falling through to the
|
||||
// system default, which would run a different account behind a UI still
|
||||
// showing this one. The selection stays put; a later read may succeed.
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
||||
}
|
||||
if (resolved.kind === 'untrusted') {
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
return null
|
||||
}
|
||||
const perAccountHome = resolved.homePath
|
||||
if (
|
||||
unavailableManagedHomePath &&
|
||||
normalizeRuntimePathForComparison(unavailableManagedHomePath) ===
|
||||
@@ -407,7 +432,13 @@ export class CodexRuntimeHomeService {
|
||||
// hot-swap or token read-back to reconcile. A trusted home remains selected
|
||||
// while Codex atomically replaces auth.json.
|
||||
private syncSelfContainedManagedSelection(account: CodexManagedAccount): void {
|
||||
const perAccountHome = this.getTrustedSelfContainedManagedHomePath(account)
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: a sync runs on every app start, exactly when antivirus is busiest.
|
||||
// An unreadable home must not deselect the account (#STA-4422).
|
||||
return
|
||||
}
|
||||
const perAccountHome = resolved.kind === 'owned' ? resolved.homePath : null
|
||||
if (perAccountHome) {
|
||||
this.lastSyncedAccountId = account.id
|
||||
this.lastHostAccountUsedSelfContainedHome = true
|
||||
@@ -421,21 +452,41 @@ export class CodexRuntimeHomeService {
|
||||
this.clearSelfContainedManagedSelection(account)
|
||||
}
|
||||
|
||||
private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
|
||||
try {
|
||||
assertOwnedHostCodexManagedHomePath({
|
||||
candidatePath: account.managedHomePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
/**
|
||||
* Why: an unreadable home and an untrustworthy one demand opposite responses.
|
||||
* Only `untrusted` may clear the user's selection; `indeterminate` means we
|
||||
* could not tell, so callers refuse the operation and leave durable state
|
||||
* alone (#STA-4422).
|
||||
*/
|
||||
private resolveSelfContainedManagedHome(
|
||||
account: CodexManagedAccount
|
||||
): { kind: 'owned'; homePath: string } | { kind: 'untrusted' } | { kind: 'indeterminate' } {
|
||||
const verdict = resolveHostCodexManagedHomeVerdict({
|
||||
candidatePath: account.managedHomePath,
|
||||
managedAccountsRoot: this.getManagedAccountsRoot(),
|
||||
systemCodexHomePath: getSystemCodexHomePath(),
|
||||
expectedAccountId: account.id
|
||||
})
|
||||
if (verdict.kind === 'owned') {
|
||||
// Preserve the persisted path spelling (notably /var vs /private/var on
|
||||
// macOS) so injected CODEX_HOME stays stable across the rollout.
|
||||
return account.managedHomePath
|
||||
} catch (error) {
|
||||
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', error)
|
||||
return null
|
||||
return { kind: 'owned', homePath: account.managedHomePath }
|
||||
}
|
||||
if (verdict.kind === 'untrusted') {
|
||||
console.warn('[codex-runtime-home] Refusing untrusted managed account home:', verdict.reason)
|
||||
return { kind: 'untrusted' }
|
||||
}
|
||||
console.warn(
|
||||
'[codex-runtime-home] Managed account home is temporarily unreadable; keeping selection:',
|
||||
verdict.error
|
||||
)
|
||||
return { kind: 'indeterminate' }
|
||||
}
|
||||
|
||||
/** Read-only callers that mutate nothing and simply skip an unusable home. */
|
||||
private getTrustedSelfContainedManagedHomePath(account: CodexManagedAccount): string | null {
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
return resolved.kind === 'owned' ? resolved.homePath : null
|
||||
}
|
||||
|
||||
private clearSelfContainedManagedSelection(
|
||||
@@ -539,6 +590,42 @@ export class CodexRuntimeHomeService {
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Same selection, but an unreadable home refuses instead of collapsing to
|
||||
* `null`. Session resume must not read "no managed selection" out of a failed
|
||||
* marker stat: another account's readable alias would then win the legacy
|
||||
* rescan and the pane would resume under that account's credentials while the
|
||||
* UI still shows this one (#STA-4422).
|
||||
*/
|
||||
resolveSelectedHostAccountCodexHomePathForResume(): string | null {
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (!selfContainedAccount) {
|
||||
return null
|
||||
}
|
||||
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
throw new ManagedCodexHomeTemporarilyUnavailableError()
|
||||
}
|
||||
if (resolved.kind === 'untrusted') {
|
||||
this.clearSelfContainedManagedSelection(selfContainedAccount)
|
||||
return null
|
||||
}
|
||||
return resolved.homePath
|
||||
}
|
||||
|
||||
/** Trust-gates host previews without changing WSL routing or durable account state. */
|
||||
resolveCodexManagedAccountHomeForInactiveFetch(
|
||||
account: CodexManagedAccount
|
||||
): { kind: 'ready'; homePath: string } | { kind: 'skip' } {
|
||||
if (account.managedHomeRuntime === 'wsl' || this.getWslManagedHomePath(account)) {
|
||||
return { kind: 'ready', homePath: account.managedHomePath }
|
||||
}
|
||||
const resolved = this.resolveSelfContainedManagedHome(account)
|
||||
return resolved.kind === 'owned'
|
||||
? { kind: 'ready', homePath: resolved.homePath }
|
||||
: { kind: 'skip' }
|
||||
}
|
||||
|
||||
getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute {
|
||||
if (this.getSelfContainedManagedHostAccount()) {
|
||||
return 'account-home'
|
||||
@@ -672,22 +759,32 @@ export class CodexRuntimeHomeService {
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
}
|
||||
|
||||
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): string | null {
|
||||
// Why: `null` is a real value here — it means "use the system-default lane".
|
||||
// A skipped poll needs its own channel or the fetcher silently retargets the
|
||||
// user's real ~/.codex (#STA-4422).
|
||||
prepareForRateLimitFetch(target?: CodexAccountSelectionTarget): CodexRateLimitHomeResolution {
|
||||
if (target?.runtime === 'wsl') {
|
||||
const wslTarget = this.resolveWslDefaultTarget(target)
|
||||
const syncedRuntimeHomePath = this.getPreparedWslRateLimitHomePath(wslTarget)
|
||||
return syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
return {
|
||||
kind: 'ready',
|
||||
codexHomePath: syncedRuntimeHomePath ?? this.getWslSystemCodexHomePath(wslTarget)
|
||||
}
|
||||
}
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
const selfContainedHome = selfContainedAccount
|
||||
? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
|
||||
: null
|
||||
if (selfContainedAccount && selfContainedHome) {
|
||||
// Why: the quota fetch reads the account's own auth.json in place; no
|
||||
// shared-home hot-swap or per-poll resource relink (that is launch prep).
|
||||
return selfContainedHome
|
||||
}
|
||||
if (selfContainedAccount) {
|
||||
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
|
||||
if (resolved.kind === 'owned') {
|
||||
// Why: the quota fetch reads the account's own auth.json in place; no
|
||||
// shared-home hot-swap or per-poll resource relink (that is launch prep).
|
||||
return { kind: 'ready', codexHomePath: resolved.homePath }
|
||||
}
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: returning null here would NOT skip — the fetcher maps null to
|
||||
// ~/.codex and would probe the user's real home with a token-refreshing
|
||||
// app-server. Skip the poll outright and keep the selection.
|
||||
return { kind: 'skip' }
|
||||
}
|
||||
this.clearSelfContainedManagedSelection(selfContainedAccount)
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
@@ -698,12 +795,12 @@ export class CodexRuntimeHomeService {
|
||||
if (hasRecordedLegacySharedCodexPane()) {
|
||||
this.syncLegacySharedSystemDefaultAuthForRetainedPanes()
|
||||
}
|
||||
return getSystemCodexHomePath()
|
||||
return { kind: 'ready', codexHomePath: getSystemCodexHomePath() }
|
||||
}
|
||||
this.syncForCurrentSelection()
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
return this.getRuntimeHomePath()
|
||||
return { kind: 'ready', codexHomePath: this.getRuntimeHomePath() }
|
||||
}
|
||||
|
||||
syncForCurrentSelection(
|
||||
@@ -1116,13 +1213,13 @@ export class CodexRuntimeHomeService {
|
||||
)
|
||||
const nextLinuxPath = `${activeLinuxPath}.next-${process.pid}-${Date.now()}`
|
||||
const activeLinuxParentPath = this.dirnameLinuxPath(activeLinuxPath)
|
||||
// Why: WSL drops bash argv, so keep the script literal; login-shell cleanup turns `exit 0` into status 1, so fall through.
|
||||
// Why: login-shell cleanup turns `exit 0` into status 1, so fall through.
|
||||
execFileSync(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
distro,
|
||||
'--',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-lc',
|
||||
[
|
||||
@@ -1310,15 +1407,24 @@ export class CodexRuntimeHomeService {
|
||||
* real-home lane, which runs Codex directly against ~/.codex — there is no
|
||||
* mirror there, so there is nothing that can fall behind.
|
||||
*/
|
||||
getMirroredHostHomePathForStatus(): string | null {
|
||||
getMirroredHostHomePathForStatus(): CodexMirroredHomeStatus {
|
||||
const selfContainedAccount = this.getSelfContainedManagedHostAccount()
|
||||
if (selfContainedAccount) {
|
||||
return this.getTrustedSelfContainedManagedHomePath(selfContainedAccount)
|
||||
const resolved = this.resolveSelfContainedManagedHome(selfContainedAccount)
|
||||
if (resolved.kind === 'indeterminate') {
|
||||
// Why: `null` here is a positive claim that no mirror exists, which the
|
||||
// status channel reports as healthy. An unreadable home is not that.
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
return { kind: 'ready', homePath: resolved.kind === 'owned' ? resolved.homePath : null }
|
||||
}
|
||||
if (this.isHostSystemDefaultRealHome()) {
|
||||
return null
|
||||
return { kind: 'ready', homePath: null }
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
homePath: join(getOrcaUserDataPath(), 'codex-runtime-home', 'home')
|
||||
}
|
||||
return join(getOrcaUserDataPath(), 'codex-runtime-home', 'home')
|
||||
}
|
||||
|
||||
private getRuntimeAuthPath(): string {
|
||||
@@ -1906,7 +2012,15 @@ export class CodexRuntimeHomeService {
|
||||
}
|
||||
const provenance: CodexSharedRuntimeAuthProvenance =
|
||||
owner.owner === 'system-default' ? { owner: 'system-default', authJson: contents } : owner
|
||||
const runtimeAuthAlreadyMatches = this.fileContentsEqual(runtimeAuthPath, contents)
|
||||
const runtimeAuthComparison = this.compareFileContents(runtimeAuthPath, contents)
|
||||
if (runtimeAuthComparison === null) {
|
||||
// Why: an unreadable runtime auth.json may hold a token Codex rotated a
|
||||
// moment ago. Treating "could not read" as "differs" sent execution to the
|
||||
// unconditional write below, consuming that rotation and logging the user
|
||||
// out for good. Refuse; the next sync retries.
|
||||
return false
|
||||
}
|
||||
const runtimeAuthAlreadyMatches = runtimeAuthComparison
|
||||
if (
|
||||
runtimeAuthAlreadyMatches &&
|
||||
this.sharedRuntimeAuthProvenanceMatches(
|
||||
@@ -1954,16 +2068,31 @@ export class CodexRuntimeHomeService {
|
||||
writeFileAtomically(authPath, contents, { mode: 0o600 })
|
||||
}
|
||||
|
||||
private fileContentsEqual(targetPath: string, contents: string): boolean {
|
||||
/**
|
||||
* `true`/`false` only when the bytes were actually read; `null` when the file
|
||||
* could not be read at all. The old `catch { return false }` reported "these
|
||||
* differ" for a file nobody could open, and every caller reads that as
|
||||
* permission to write.
|
||||
*/
|
||||
private compareFileContents(targetPath: string, contents: string): boolean | null {
|
||||
try {
|
||||
return existsSync(targetPath) && readFileSync(targetPath, 'utf-8') === contents
|
||||
} catch {
|
||||
return false
|
||||
return readFileSync(targetPath, 'utf-8') === contents
|
||||
} catch (error) {
|
||||
return isDefinitiveAbsence(error) ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
private fileContentsEqual(targetPath: string, contents: string): boolean {
|
||||
return this.compareFileContents(targetPath, contents) === true
|
||||
}
|
||||
|
||||
private fileContentsMatchExpected(targetPath: string, expectedContents: string | null): boolean {
|
||||
if (expectedContents === null) {
|
||||
// Why: `!existsSync` does report `true` for a locked file, but this branch
|
||||
// is not where that matters — the write it guards is
|
||||
// `writeFileAtomicallyIfUnchanged`, whose rename-and-compare re-checks the
|
||||
// real file and refuses on its own. Classifying here would be a guard no
|
||||
// test can drive.
|
||||
return !existsSync(targetPath)
|
||||
}
|
||||
return this.fileContentsEqual(targetPath, expectedContents)
|
||||
|
||||
@@ -92,10 +92,14 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(readFileSync(join(wslRuntimeHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
'{"account":"wsl"}\n'
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath())
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
wslRuntimeHomePath
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch()).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: getRuntimeCodexHomePath()
|
||||
})
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: wslRuntimeHomePath
|
||||
})
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('CodexRuntimeHomeService', () => {
|
||||
expect(firstCall).toBeDefined()
|
||||
const [command, args] = firstCall as [string, string[]]
|
||||
expect(command).toBe('wsl.exe')
|
||||
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc'])
|
||||
expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'bash', '-lc'])
|
||||
expect(args).toHaveLength(6)
|
||||
|
||||
const shellCommand = args[5]
|
||||
|
||||
@@ -64,8 +64,14 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
const expectedHome = join(wslHome, '.codex')
|
||||
|
||||
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
|
||||
expect(service.prepareForRateLimitFetch(target)).toBe(expectedHome)
|
||||
expect(service.prepareForRateLimitFetch(target)).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: expectedHome
|
||||
})
|
||||
expect(service.prepareForRateLimitFetch(target)).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: expectedHome
|
||||
})
|
||||
expect(syncWslRuntime).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
@@ -138,9 +144,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toBe(
|
||||
join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: null })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: join(wslHome, '.local', 'share', 'orca', 'codex-runtime-home', 'home')
|
||||
})
|
||||
expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(ubuntuAuth)
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
@@ -194,9 +201,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
try {
|
||||
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
|
||||
const service = new CodexRuntimeHomeService(store as never)
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
systemCodexHomePath
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: systemCodexHomePath
|
||||
})
|
||||
expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth)
|
||||
const externallyRefreshedAuth = createCodexAuthJson(
|
||||
'wsl@example.com',
|
||||
@@ -205,9 +213,10 @@ describe('CodexRuntimeHomeService', () => {
|
||||
3_000
|
||||
)
|
||||
writeFileSync(join(systemCodexHomePath, 'auth.json'), externallyRefreshedAuth, 'utf-8')
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe(
|
||||
systemCodexHomePath
|
||||
)
|
||||
expect(service.prepareForRateLimitFetch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toEqual({
|
||||
kind: 'ready',
|
||||
codexHomePath: systemCodexHomePath
|
||||
})
|
||||
expect(readFileSync(join(systemCodexHomePath, 'auth.json'), 'utf-8')).toBe(
|
||||
externallyRefreshedAuth
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user