Merge remote-tracking branch 'origin/main' into fix14974d-work

# Conflicts:
#	src/main/providers/ssh-pty-provider.test.ts
This commit is contained in:
Brennan Benson
2026-08-18 16:40:38 -07:00
1514 changed files with 97551 additions and 24642 deletions
File diff suppressed because one or more lines are too long
+56
View File
@@ -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
View File
@@ -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 ''
+189
View File
@@ -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)
})
})
+102
View File
@@ -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
}
+1 -1
View File
@@ -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.'
)
})
+170 -2
View File
@@ -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,
+34 -1
View File
@@ -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)')
})
})
+44 -12
View File
@@ -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 }) => {
+15 -5
View File
@@ -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
+13 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
+103 -5
View File
@@ -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', {
+25
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
},
+17 -13
View File
@@ -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')
+1
View File
@@ -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.',
+2 -1
View File
@@ -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',
+10 -2
View File
@@ -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 -5
View File
@@ -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'
]
},
{
+13
View File
@@ -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 =
+26 -14
View File
@@ -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',
() => {
+1 -15
View File
@@ -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
View File
@@ -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)
+10 -2
View File
@@ -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 = [
+9 -2
View File
@@ -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 {}',
')',
+109 -7
View File
@@ -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)
)
+16 -4
View File
@@ -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',
+77 -49
View File
@@ -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)`)
}
}
+75 -19
View File
@@ -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
}
+62 -37
View File
@@ -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(
+6 -6
View File
@@ -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(' ')}`
+1 -1
View File
@@ -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)
+168 -39
View File
@@ -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