mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
test: remove 594 low-value Codex tests (#38775)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -224,8 +224,7 @@
|
||||
1 test-no-real-time-wait:src/main/claude/structured/launch/claude-structured-real-cli-options.test.ts
|
||||
3 test-no-real-time-wait:src/main/cli/packaged-cli-assets.test.ts
|
||||
3 test-no-real-time-wait:src/main/codex/hooks/installation/hook-service-concurrent-launch-install.test.ts
|
||||
1 test-no-real-time-wait:src/main/codex/hooks/installation/hook-service-managed-codex-hashes.test.ts
|
||||
3 test-no-real-time-wait:src/main/codex/hooks/reconciliation/codex-hook-reconcile.test.ts
|
||||
1 test-no-real-time-wait:src/main/codex/hooks/reconciliation/codex-hook-reconcile.test.ts
|
||||
1 test-no-real-time-wait:src/main/codex/hooks/reconciliation/codex-hook-toggle-off-wins.test.ts
|
||||
1 test-no-real-time-wait:src/main/codex/structured/launch/codex-structured-real-cli-command-env.test.ts
|
||||
1 test-no-real-time-wait:src/main/codex/structured/turns/codex-structured-turn-open-wait.test.ts
|
||||
@@ -661,9 +660,6 @@
|
||||
6 test-no-wall-clock:src/main/cli/packaged-cli-assets.test.ts
|
||||
1 test-no-wall-clock:src/main/codex-accounts/legacy-wsl-runtime-auth-drain-rollback-script.test.ts
|
||||
6 test-no-wall-clock:src/main/codex/app-server/codex-app-server-client.test.ts
|
||||
2 test-no-wall-clock:src/main/codex/configuration/settings/config-settings-promotion.test.ts
|
||||
1 test-no-wall-clock:src/main/codex/history/backfill/codex-session-backfill-marker.test.ts
|
||||
1 test-no-wall-clock:src/main/codex/history/backfill/codex-session-backfill.test.ts
|
||||
1 test-no-wall-clock:src/main/codex/structured/catalog/codex-usage-limits.test.ts
|
||||
2 test-no-wall-clock:src/main/codex/structured/launch/codex-structured-real-cli-command-env.test.ts
|
||||
2 test-no-wall-clock:src/main/codex/trust/hooks/codex-hook-trust-derivation.test.ts
|
||||
|
||||
@@ -16462,7 +16462,7 @@
|
||||
"id": "codex.state-db-backfill-recovery-bounds",
|
||||
"title": "Codex state DB backfill recovery has bounded process and timer use",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"protection": "none",
|
||||
"owner": "codex-runtime",
|
||||
"layer": "main-process-supervisor",
|
||||
"surfaces": [
|
||||
@@ -16472,44 +16472,16 @@
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "wsl", "paired-runtime-host"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "wsl"],
|
||||
"coverageNotes": "A deterministic injected claimant and clock cover exits before and after the former ten-second classification boundary, exact spawn/poll/backoff counts, transient exit and spawn-error recovery, owner-lock reacquisition, WSL routing, and cleanup. Live Windows, WSL, and paired-runtime-host process evidence remains uncollected.",
|
||||
"coveredPlatforms": [],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Audit1614 removes mocked backfill retry/private state assertions without equivalent replacement coverage. Retained tests exercise selected real child termination and timeout boundaries; they do not establish the retired gate assertions. Any historical receipts are retired when no current passed evidence remains. Historical scope before this audit: A deterministic injected claimant and clock cover exits before and after the former ten-second classification boundary, exact spawn/poll/backoff counts, transient exit and spawn-error recovery, owner-lock reacquisition, WSL routing, and cleanup. Live Windows, WSL, and paired-runtime-host process evidence remains uncollected.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/releases/tag/v1.4.177-rc.0"],
|
||||
"invariant": "One authoritative Codex state DB backfill supervisor spawns at most five claimants regardless of process lifetime, retains at most one child and one timer, rejects later automatic restarts after claimant exhaustion, and bounds pre-claim coordinator failures to five triggers while transient claimant and owner-lock failures can recover within those budgets.",
|
||||
"oracle": "Use an injected process and clock to exit every claimant at 10,001ms without waiting. Permanent failure must give up after five spawns and exits with exactly 15 poll timers and four backoffs, no live child or pending timer, and no safety-fuse abort; repeated triggers must reuse the exhausted result with zero new lock or runner calls. Separately fail twice at 10,001ms and complete on the third claimant, recover from a first-spawn error on attempt two, reacquire after a transient owner-lock failure, and cap permanent lock-root failures at five attempts across six triggers.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/codex/history/index/codex-state-db-backfill-recovery.test.ts"
|
||||
],
|
||||
"testFiles": ["src/main/codex/history/index/codex-state-db-backfill-recovery.test.ts"],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/codex/history/index/codex-state-db-backfill-recovery.test.ts",
|
||||
"assertions": [
|
||||
"does not restart an exhausted supervisor when later triggers arrive",
|
||||
"releases a completed supervisor entry",
|
||||
"releases a failed owner-lock attempt for later arbitration",
|
||||
"bounds permanent coordinator failures across later triggers",
|
||||
"bounds permanent claimant exits just beyond the fast-failure window",
|
||||
"recovers after claimant exits just beyond the old fast-failure window",
|
||||
"recovers after a transient process spawn error",
|
||||
"routes a WSL managed home through its distro and Linux CODEX_HOME",
|
||||
"does not interfere with a live supervisor from another Orca instance"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/codex/codex-state-db-backfill-recovery.test.ts",
|
||||
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/main/codex/history/index/codex-state-db-backfill-recovery.test.ts",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "Twelve deterministic recovery and owner-lock tests passed, including exact permanent and transient 10,001ms exit accounting, spawn-error recovery, trigger-level exhaustion memoization, bounded coordinator failure, successful owner-lock reacquisition, and zero retained children or timers."
|
||||
}
|
||||
],
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 2,
|
||||
"scope": "deterministic main-process supervisor and owner-lock contract"
|
||||
@@ -16532,6 +16504,7 @@
|
||||
"Keep exact spawn, exit, timer, backoff, cleanup, and transient-recovery assertions green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Audit1614 removes mocked backfill retry/private state assertions without equivalent replacement coverage. Retained tests exercise selected real child termination and timeout boundaries; they do not establish the retired gate assertions. Any historical receipts are retired when no current passed evidence remains.",
|
||||
"Live child-process behavior is represented by an injected EventEmitter claimant.",
|
||||
"Physical Windows, WSL, SSH, and paired-runtime-host journeys were not run."
|
||||
],
|
||||
|
||||
@@ -1503,11 +1503,9 @@
|
||||
"src/main/codex/hooks/reconciliation/codex-account-home-hook.test.ts": 365,
|
||||
"src/main/codex/history/bridges/codex-account-session-bridge-hardlink-only.test.ts": 260,
|
||||
"src/main/codex/history/bridges/codex-account-session-bridge.test.ts": 465,
|
||||
"src/main/codex/history/index/codex-account-session-index-heal.test.ts": 273,
|
||||
"src/main/codex/history/resume/codex-ai-vault-session-resume.test.ts": 131,
|
||||
"src/main/codex/structured/catalog/codex-at-rest-skills-probe.test.ts": 614,
|
||||
"src/main/codex/structured/background-work/codex-background-command-tracker.test.ts": 329,
|
||||
"src/main/codex/structured/background-work/codex-background-task-tracker.test.ts": 219,
|
||||
"src/main/codex/structured/background-work/codex-background-terminals.test.ts": 598,
|
||||
"src/main/codex/structured/turns/codex-close-turn-end.test.ts": 412,
|
||||
"src/main/codex/structured/background-work/codex-collab-call-rows-on-positional-clients.test.ts": 660,
|
||||
@@ -1517,11 +1515,8 @@
|
||||
"src/main/codex/configuration/settings/codex-config-mirror-mcp-ownership.test.ts": 161,
|
||||
"src/main/codex/configuration/settings/codex-config-mirror.test.ts": 246,
|
||||
"src/main/codex/configuration/settings/codex-config-settings-removal.test.ts": 66,
|
||||
"src/main/codex/configuration/shared-server/codex-daemon-socket-path-guard.test.ts": 174,
|
||||
"src/main/codex/structured/background-work/codex-default-mode-subagents.test.ts": 692,
|
||||
"src/main/codex/configuration/homes/codex-home-paths.test.ts": 153,
|
||||
"src/main/codex/trust/hooks/codex-hook-approval-first-write.test.ts": 129,
|
||||
"src/main/codex/hooks/definitions/codex-hook-command-form.test.ts": 163,
|
||||
"src/main/codex/hooks/definitions/codex-hook-file-entry-binary-contract.test.ts": 385,
|
||||
"src/main/codex/trust/hooks/codex-hook-hash-lookup.test.ts": 315,
|
||||
"src/main/codex/hooks/reconciliation/codex-hook-legacy-profile-block.test.ts": 122,
|
||||
@@ -1552,12 +1547,10 @@
|
||||
"src/main/codex/configuration/homes/codex-real-home-path.test.ts": 76,
|
||||
"src/main/codex/hooks/reconciliation/codex-real-home-shared-entry.test.ts": 641,
|
||||
"src/main/codex/history/rollouts/codex-rollout-session-meta.test.ts": 134,
|
||||
"src/main/codex/history/backfill/codex-session-backfill-marker.test.ts": 87,
|
||||
"src/main/codex/history/backfill/codex-session-backfill.test.ts": 310,
|
||||
"src/main/codex/history/bridges/codex-session-bridge.test.ts": 118,
|
||||
"src/main/codex/history/index/codex-session-index-heal-state.test.ts": 363,
|
||||
"src/main/codex/history/index/codex-session-index-heal.test.ts": 2722,
|
||||
"src/main/codex/history/backfill/codex-session-migration-scheduler.test.ts": 297,
|
||||
"src/main/codex/history/resume/codex-session-resume-home.test.ts": 110,
|
||||
"src/main/codex/history/resume/codex-session-resume-preparation.test.ts": 66,
|
||||
"src/main/codex/history/resume/codex-session-resume-windows.test.ts": 57,
|
||||
@@ -1576,7 +1569,6 @@
|
||||
"src/main/codex/structured/turns/codex-structured-dispatch-admission.test.ts": 835,
|
||||
"src/main/codex/structured/turns/codex-structured-dispatch-boundary.test.ts": 776,
|
||||
"src/main/codex/structured/turns/codex-structured-dispatch-echo.test.ts": 50,
|
||||
"src/main/codex/structured/items/codex-structured-item-translation.test.ts": 201,
|
||||
"src/main/codex/structured/journal/codex-structured-journal-compactions.test.ts": 342,
|
||||
"src/main/codex/structured/journal/codex-structured-journal-context-usage.test.ts": 1093,
|
||||
"src/main/codex/structured/journal/codex-structured-journal-exit-convergence.test.ts": 1356,
|
||||
@@ -1607,13 +1599,11 @@
|
||||
"src/main/codex/structured/turns/codex-structured-reasoning-lifecycle.test.ts": 341,
|
||||
"src/main/codex/structured/threads/codex-structured-rewind.test.ts": 154,
|
||||
"src/main/codex/structured/catalog/codex-structured-service-tier-shown.test.ts": 436,
|
||||
"src/main/codex/structured/catalog/codex-structured-service-tier.test.ts": 620,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-adapter-lifecycle.test.ts": 597,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-adapter.test.ts": 837,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-background-tasks.test.ts": 761,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-cancel.test.ts": 647,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-exit-blame.test.ts": 548,
|
||||
"src/main/codex/structured/catalog/codex-structured-session-options.test.ts": 819,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-prompts.test.ts": 609,
|
||||
"src/main/codex/structured/session-lifecycle/codex-structured-session-shutdown.test.ts": 635,
|
||||
"src/main/codex/structured/catalog/codex-structured-skill-report.test.ts": 651,
|
||||
@@ -1634,7 +1624,6 @@
|
||||
"src/main/codex/structured/journal/codex-turn-ordinals.test.ts": 86,
|
||||
"src/main/codex/structured/catalog/codex-usage-limits.test.ts": 312,
|
||||
"src/main/codex/trust/hooks/codex-user-hook-trust-moves.test.ts": 125,
|
||||
"src/main/codex/hooks/installation/codex-wsl-hook-install-plan.test.ts": 890,
|
||||
"src/main/codex/configuration/settings/config-features-settings-promotion.test.ts": 179,
|
||||
"src/main/codex/configuration/settings/config-plugin-registration-promotion.test.ts": 306,
|
||||
"src/main/codex/configuration/settings/config-settings-baseline-upgrade.test.ts": 196,
|
||||
@@ -1668,7 +1657,6 @@
|
||||
"src/main/codex/configuration/homes/sta-4823-shared-state-survives-a-failed-read.test.ts": 242,
|
||||
"src/main/codex/hooks/installation/windows-hook-command.test.ts": 129,
|
||||
"src/main/codex/hooks/installation/windows-hook-upgrade.test.ts": 312,
|
||||
"src/main/codex/history/bridges/wsl-codex-session-bridge-index.test.ts": 229,
|
||||
"src/main/codex/history/bridges/wsl-codex-session-bridge-script.test.ts": 190,
|
||||
"src/main/codex/history/bridges/wsl-codex-session-bridge.test.ts": 346,
|
||||
"src/main/command-code/hook-service.test.ts": 271,
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { describe, it } from 'vitest'
|
||||
import {
|
||||
PROVIDER_STDIN_END_GRACE_MS,
|
||||
PROVIDER_SUPERVISOR_MAX_STOP_MS
|
||||
} from '../../provider-process/provider-process-supervisor'
|
||||
import type { CodexAppServerSpawn } from './codex-app-server-process-tree-kill'
|
||||
import { CodexAppServerTimeoutError, runCodexAppServerSession } from './codex-app-server-session'
|
||||
import { classifyCodexTrustGrantError } from '../trust/grants/codex-trust-grant-telemetry'
|
||||
import { PROVIDER_SPAWN_FAILURE_MARKER } from '../../provider-process/provider-spawn-failure-report'
|
||||
import {
|
||||
alive,
|
||||
createSupervisedProbeRig,
|
||||
@@ -36,115 +32,6 @@ void runCodexAppServerSession(
|
||||
setInterval(() => {}, 60_000)
|
||||
`
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
type CapturedSpawn = { program: string; args: string[]; options: Record<string, unknown> }
|
||||
|
||||
function captureSpawn(): { spawnImpl: CodexAppServerSpawn; calls: CapturedSpawn[] } {
|
||||
const calls: CapturedSpawn[] = []
|
||||
return {
|
||||
calls,
|
||||
spawnImpl: (program, args, options) => {
|
||||
calls.push({ program, args, options })
|
||||
throw new Error('spawn captured')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodedSupervisorSpec(env: unknown): unknown {
|
||||
const encoded =
|
||||
typeof env === 'object' && env !== null && 'ORCA_PROVIDER_SUPERVISOR_SPEC' in env
|
||||
? String(env.ORCA_PROVIDER_SUPERVISOR_SPEC)
|
||||
: ''
|
||||
return JSON.parse(Buffer.from(encoded, 'base64').toString() || 'null')
|
||||
}
|
||||
|
||||
describe('short-lived Codex app-server session spawn', () => {
|
||||
afterEach(() => {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs a POSIX session under the provider supervisor in session lifetime', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const { spawnImpl, calls } = captureSpawn()
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSession(
|
||||
{
|
||||
command: '/bin/codex',
|
||||
cliPath: null,
|
||||
args: ['app-server'],
|
||||
cwd: '/workspace',
|
||||
timeoutMs: 1_000
|
||||
},
|
||||
async () => null,
|
||||
spawnImpl
|
||||
)
|
||||
).rejects.toThrow('spawn captured')
|
||||
|
||||
const [{ program, args, options }] = calls
|
||||
expect(program).toBe(process.execPath)
|
||||
expect(args.slice(args.indexOf('--') + 1)).toEqual(['/bin/codex', 'app-server'])
|
||||
expect(options.detached).toBe(true)
|
||||
expect(options.cwd).toBe('/workspace')
|
||||
expect(decodedSupervisorSpec(options.env)).toMatchObject({
|
||||
lifetime: 'session',
|
||||
cwd: '/workspace',
|
||||
ownerPid: process.pid
|
||||
})
|
||||
})
|
||||
|
||||
it('never shows the supervisor spawn-failure marker in an early-exit error', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
const report = { thrown: false, code: 'ENOENT', message: 'spawn /bin/codex ENOENT' }
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 4242,
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
kill: () => true
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the session reads only pid, stdio, kill and the exit/close/error events, which the fake implements.
|
||||
const spawnImpl: CodexAppServerSpawn = () => child as never
|
||||
// A report on an exit other than 127 is no spawn failure, but its text still reaches the user.
|
||||
child.stderr.once('end', () => {
|
||||
child.emit('exit', 1, null)
|
||||
child.emit('close', 1, null)
|
||||
})
|
||||
child.stderr.end(`${PROVIDER_SPAWN_FAILURE_MARKER}${JSON.stringify(report)}\n`)
|
||||
|
||||
const error = await runCodexAppServerSession(
|
||||
{ command: '/bin/codex', cliPath: null, args: ['app-server'], timeoutMs: 5_000 },
|
||||
async () => null,
|
||||
spawnImpl
|
||||
).catch((caught: unknown) => caught)
|
||||
|
||||
expect(String(error)).toContain('spawn /bin/codex ENOENT')
|
||||
expect(String(error)).not.toContain(PROVIDER_SPAWN_FAILURE_MARKER)
|
||||
})
|
||||
|
||||
it('spawns a Windows session directly, as before', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const { spawnImpl, calls } = captureSpawn()
|
||||
|
||||
await expect(
|
||||
runCodexAppServerSession(
|
||||
{ command: 'C:\\codex.exe', cliPath: null, args: ['app-server'], timeoutMs: 1_000 },
|
||||
async () => null,
|
||||
spawnImpl
|
||||
)
|
||||
).rejects.toThrow('spawn captured')
|
||||
|
||||
const [{ program, args, options }] = calls
|
||||
expect(decodedSupervisorSpec(options.env)).toBeNull()
|
||||
expect(program).toBe('C:\\codex.exe')
|
||||
expect(args).toEqual(['app-server'])
|
||||
expect(options).not.toHaveProperty('detached')
|
||||
})
|
||||
})
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('supervised Codex app-server sessions', () => {
|
||||
it.concurrent('stops a wedged session at its deadline with SIGTERM to its whole group', async ({
|
||||
expect,
|
||||
|
||||
@@ -137,67 +137,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('syncSystemCodexResourcesIntoManagedHome', () => {
|
||||
it('uses ORCA_USER_DATA_PATH when Electron cannot be required', async () => {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => {
|
||||
throw new Error('electron unavailable in packaged CLI')
|
||||
})
|
||||
const previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
try {
|
||||
const { getOrcaManagedCodexHomePath: getCliSafeManagedPath } =
|
||||
await import('./codex-home-paths')
|
||||
|
||||
expect(getCliSafeManagedPath()).toBe(join(userDataDir, 'codex-runtime-home', 'home'))
|
||||
} finally {
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
mockElectronAppPaths()
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('mirrors only user resource entries into the managed runtime home', () => {
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true })
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'plugins'), { recursive: true })
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'sessions'), { recursive: true })
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n')
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'plugins', 'plugin.json'), '{}\n')
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'auth.json'), '{"account":"system"}\n')
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'hooks.json'), '{"hooks":{}}\n')
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'history.jsonl'), '{}\n')
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
|
||||
const runtimeSkillsPath = join(getRuntimeCodexHomePath(), 'skills')
|
||||
const runtimePluginsPath = join(getRuntimeCodexHomePath(), 'plugins')
|
||||
expect(readFileSync(join(runtimeSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n')
|
||||
expect(readFileSync(join(runtimePluginsPath, 'plugin.json'), 'utf-8')).toBe('{}\n')
|
||||
expectSymbolicLinkTargetIfLinked(runtimeSkillsPath, join(getSystemCodexHomePath(), 'skills'))
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'sessions'))).toBe(false)
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'auth.json'))).toBe(false)
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'hooks.json'))).toBe(false)
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'history.jsonl'))).toBe(false)
|
||||
})
|
||||
|
||||
it('mirrors resources into an explicit per-account home without mutating ~/.codex', () => {
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true })
|
||||
writeFileSync(join(getSystemCodexHomePath(), 'skills', 'review', 'SKILL.md'), 'skill\n')
|
||||
const perAccountHome = join(userDataDir, 'codex-accounts', 'account-1', 'home')
|
||||
mkdirSync(perAccountHome, { recursive: true })
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome(perAccountHome)
|
||||
|
||||
const perAccountSkillsPath = join(perAccountHome, 'skills')
|
||||
expect(readFileSync(join(perAccountSkillsPath, 'review', 'SKILL.md'), 'utf-8')).toBe('skill\n')
|
||||
expectSymbolicLinkTargetIfLinked(perAccountSkillsPath, join(getSystemCodexHomePath(), 'skills'))
|
||||
expect(existsSync(join(getRuntimeCodexHomePath(), 'skills'))).toBe(false)
|
||||
expect(existsSync(join(getSystemCodexHomePath(), 'codex-accounts'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not replace an existing runtime-owned resource entry', () => {
|
||||
mkdirSync(join(getSystemCodexHomePath(), 'skills'), { recursive: true })
|
||||
mkdirSync(join(getRuntimeCodexHomePath(), 'skills'), { recursive: true })
|
||||
@@ -288,113 +227,7 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => {
|
||||
expect(existsSync(join(systemRulesPath, '_orca-global-default.rules'))).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes owned fallback copies when symlinks are unavailable', () => {
|
||||
fsMockState.failSymlink = true
|
||||
const systemProfilePath = join(getSystemCodexHomePath(), 'profile-v2')
|
||||
const runtimeProfilePath = join(getRuntimeCodexHomePath(), 'profile-v2')
|
||||
writeFileSync(systemProfilePath, 'first\n', 'utf-8')
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
writeFileSync(systemProfilePath, 'second\n', 'utf-8')
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
|
||||
expect(lstatSync(runtimeProfilePath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(runtimeProfilePath, 'utf-8')).toBe('second\n')
|
||||
})
|
||||
|
||||
it('mirrors hooks directory into the managed runtime home (regression test for 127 exit)', () => {
|
||||
const systemHooksPath = join(getSystemCodexHomePath(), 'hooks')
|
||||
const runtimeHooksPath = join(getRuntimeCodexHomePath(), 'hooks')
|
||||
mkdirSync(systemHooksPath, { recursive: true })
|
||||
writeFileSync(join(systemHooksPath, 'observe-tool-use.sh'), '#!/bin/bash\necho ok\n')
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
|
||||
expect(lstatSync(runtimeHooksPath).isSymbolicLink()).toBe(true)
|
||||
expectSymbolicLinkTargetIfLinked(runtimeHooksPath, systemHooksPath)
|
||||
})
|
||||
|
||||
it('mirrors the global AGENTS.md into the managed runtime home so user instructions survive', () => {
|
||||
const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md')
|
||||
const runtimeAgentsPath = join(getRuntimeCodexHomePath(), 'AGENTS.md')
|
||||
writeFileSync(systemAgentsPath, '# Global instructions\n')
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# Global instructions\n')
|
||||
expectSymbolicLinkTargetIfLinked(runtimeAgentsPath, systemAgentsPath)
|
||||
})
|
||||
|
||||
it('skips unchanged global-instruction fallback copies when symlinks fail', () => {
|
||||
fsMockState.failSymlink = true
|
||||
const systemAgentsPath = join(getSystemCodexHomePath(), 'AGENTS.md')
|
||||
writeFileSync(systemAgentsPath, 'first\n')
|
||||
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
expect(fsMockState.copyCount).toBe(1)
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
expect(fsMockState.copyCount).toBe(1)
|
||||
writeFileSync(systemAgentsPath, 'second\n')
|
||||
syncSystemCodexResourcesIntoManagedHome()
|
||||
|
||||
expect(fsMockState.copyCount).toBe(2)
|
||||
expect(readFileSync(join(getRuntimeCodexHomePath(), 'AGENTS.md'), 'utf-8')).toBe('second\n')
|
||||
})
|
||||
|
||||
it('mirrors only global instructions when explicit Codex homes are provided', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
mkdirSync(join(systemHomePath, 'skills'), { recursive: true })
|
||||
writeFileSync(join(systemHomePath, 'skills', 'system.md'), 'skill\n')
|
||||
writeFileSync(join(systemHomePath, 'AGENTS.md'), '# WSL instructions\n')
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('# WSL instructions\n')
|
||||
// WSL cannot resolve host-side symlink targets, even when the host permits symlinks.
|
||||
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
|
||||
expect(existsSync(join(managedHomePath, 'skills'))).toBe(false)
|
||||
})
|
||||
|
||||
// Windows file symlinks require developer mode; the integration test still checks real copies.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'materializes symlinked global instructions as a real file for WSL',
|
||||
() => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
const instructionSourcePath = join(userDataDir, 'global-instructions.md')
|
||||
writeFileSync(instructionSourcePath, 'linked instructions\n')
|
||||
symlinkSync(instructionSourcePath, join(systemHomePath, 'AGENTS.md'))
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('linked instructions\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'replaces an existing system-instruction link despite a malformed marker directory',
|
||||
() => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
const systemAgentsPath = join(systemHomePath, 'AGENTS.md')
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
mkdirSync(managedHomePath, { recursive: true })
|
||||
writeFileSync(systemAgentsPath, 'system\n')
|
||||
symlinkSync(systemAgentsPath, runtimeAgentsPath)
|
||||
mkdirSync(join(managedHomePath, '.orca-resource-copies', 'AGENTS.md.json'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n')
|
||||
}
|
||||
)
|
||||
|
||||
it('removes an unowned copy when recording copy ownership fails', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
@@ -408,77 +241,6 @@ describe('syncSystemCodexResourcesIntoManagedHome', () => {
|
||||
expect(existsSync(join(managedHomePath, 'AGENTS.md'))).toBe(false)
|
||||
})
|
||||
|
||||
it('skips unchanged copies, then refreshes and removes owned global instructions', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
const systemAgentsPath = join(systemHomePath, 'AGENTS.md')
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
writeFileSync(systemAgentsPath, 'first\n')
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
expect(fsMockState.copyCount).toBe(1)
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
expect(fsMockState.copyCount).toBe(1)
|
||||
writeFileSync(systemAgentsPath, 'second\n')
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
expect(fsMockState.copyCount).toBe(2)
|
||||
expect(lstatSync(runtimeAgentsPath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('second\n')
|
||||
rmSync(systemAgentsPath)
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
expect(existsSync(runtimeAgentsPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces an owned non-file instruction entry without reading it', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n')
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
rmSync(runtimeAgentsPath)
|
||||
mkdirSync(runtimeAgentsPath)
|
||||
fsMockState.trackedReadPath = runtimeAgentsPath
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
expect(fsMockState.trackedReadCount).toBe(0)
|
||||
expect(lstatSync(runtimeAgentsPath).isFile()).toBe(true)
|
||||
expect(readFileSync(runtimeAgentsPath, 'utf-8')).toBe('system\n')
|
||||
})
|
||||
|
||||
it('removes owned instructions instead of mirroring a non-file source', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
const systemAgentsPath = join(systemHomePath, 'AGENTS.md')
|
||||
const runtimeAgentsPath = join(managedHomePath, 'AGENTS.md')
|
||||
writeFileSync(systemAgentsPath, 'system\n')
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
rmSync(systemAgentsPath)
|
||||
mkdirSync(systemAgentsPath)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
expect(existsSync(runtimeAgentsPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves runtime-owned global instructions in an explicit managed home', () => {
|
||||
const systemHomePath = getSystemCodexHomePath()
|
||||
const managedHomePath = join(userDataDir, 'wsl-runtime-home')
|
||||
mkdirSync(managedHomePath, { recursive: true })
|
||||
writeFileSync(join(systemHomePath, 'AGENTS.md'), 'system\n')
|
||||
writeFileSync(join(managedHomePath, 'AGENTS.md'), 'runtime\n')
|
||||
|
||||
syncCodexGlobalInstructionsIntoManagedHome({ systemHomePath, managedHomePath })
|
||||
|
||||
expect(readFileSync(join(managedHomePath, 'AGENTS.md'), 'utf-8')).toBe('runtime\n')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves an unowned dangling runtime instruction symlink',
|
||||
() => {
|
||||
|
||||
@@ -2,34 +2,16 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
_internals,
|
||||
forgetCodexPaneAccount,
|
||||
getCodexPaneAccount,
|
||||
hasAnyRecordedLegacyWslCodexPane,
|
||||
hasRecordedLegacySharedCodexPane,
|
||||
hasRecordedLegacyWslCodexPane,
|
||||
hasRecordedManagedHostCodexPane,
|
||||
isCodexPaneHomeRouteProvenAwayFromSharedHome,
|
||||
reconcileCodexPaneAccountsWithLivePtys,
|
||||
recordCodexPaneAccount
|
||||
} from './codex-pane-account-registry'
|
||||
import { forgetStaleCodexPanes, listStaleCodexPanes } from './codex-stale-pane-accounts'
|
||||
|
||||
let userDataPath: string
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
function settingsWithSelection(
|
||||
host: string | null,
|
||||
wsl: Record<string, string | null> = {}
|
||||
): GlobalSettings {
|
||||
return {
|
||||
activeCodexManagedAccountId: host,
|
||||
activeCodexManagedAccountIdsByRuntime: { host, wsl }
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-codex-pane-accounts-'))
|
||||
@@ -48,238 +30,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('codex pane account registry', () => {
|
||||
it.each([
|
||||
['real-home', true],
|
||||
['account-home', true],
|
||||
['wsl-home', true],
|
||||
['shared-home', false],
|
||||
[undefined, false]
|
||||
] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => {
|
||||
expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected)
|
||||
})
|
||||
|
||||
it('survives a process restart so a daemon-backed shell stays attributable', () => {
|
||||
recordCodexPaneAccount('pty-1', {
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-a',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toEqual({
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-a',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps pre-route records readable without inventing provenance', () => {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'codex-pane-accounts.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
panes: { 'pty-1': { selectionKey: 'host', accountId: null } }
|
||||
})
|
||||
)
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: null })
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('reads a custom-home record from an older build as the shared home it was', () => {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'codex-pane-accounts.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
panes: { 'pty-1': { selectionKey: 'host', accountId: null, homeRoute: 'custom-home' } }
|
||||
})
|
||||
)
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toEqual({
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
})
|
||||
|
||||
it('runs legacy reconciliation only for host panes that may use the shared home', () => {
|
||||
recordCodexPaneAccount('pty-real', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'real-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-account', {
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-a',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-wsl', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: null,
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
|
||||
|
||||
recordCodexPaneAccount('pty-shared', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies only legacy runtime-home panes on the requested WSL lane', () => {
|
||||
recordCodexPaneAccount('pty-legacy', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-old',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-direct', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-new',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-other-distro', {
|
||||
selectionKey: 'wsl:Debian',
|
||||
accountId: 'account-debian',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-default', {
|
||||
selectionKey: 'wsl:__default__',
|
||||
accountId: null,
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:ubuntu')).toBe(true)
|
||||
forgetCodexPaneAccount('pty-legacy')
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(true)
|
||||
forgetCodexPaneAccount('pty-default')
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false)
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Debian')).toBe(true)
|
||||
})
|
||||
|
||||
it('requests daemon reconciliation for WSL-only legacy records', () => {
|
||||
recordCodexPaneAccount('pty-direct', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-new',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
expect(hasAnyRecordedLegacyWslCodexPane()).toBe(false)
|
||||
|
||||
recordCodexPaneAccount('pty-legacy', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-old',
|
||||
homeRoute: 'wsl-home'
|
||||
})
|
||||
expect(hasAnyRecordedLegacyWslCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('requests startup inventory only for managed host panes', () => {
|
||||
recordCodexPaneAccount('pty-real', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'real-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-wsl', {
|
||||
selectionKey: 'wsl:Ubuntu',
|
||||
accountId: 'account-wsl',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedManagedHostCodexPane()).toBe(false)
|
||||
|
||||
recordCodexPaneAccount('pty-shared', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedManagedHostCodexPane()).toBe(true)
|
||||
|
||||
forgetCodexPaneAccount('pty-shared')
|
||||
expect(hasRecordedManagedHostCodexPane()).toBe(false)
|
||||
|
||||
recordCodexPaneAccount('pty-account', {
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-host',
|
||||
homeRoute: 'account-home'
|
||||
})
|
||||
|
||||
expect(hasRecordedManagedHostCodexPane()).toBe(true)
|
||||
})
|
||||
|
||||
it('drops leaked records that are absent from the authoritative daemon inventory', () => {
|
||||
recordCodexPaneAccount('pty-live', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
recordCodexPaneAccount('pty-dead', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
|
||||
reconcileCodexPaneAccountsWithLivePtys(['pty-live'])
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-live')).not.toBeNull()
|
||||
expect(getCodexPaneAccount('pty-dead')).toBeNull()
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(true)
|
||||
|
||||
reconcileCodexPaneAccountsWithLivePtys([])
|
||||
expect(hasRecordedLegacySharedCodexPane()).toBe(false)
|
||||
})
|
||||
|
||||
it('forgets a PTY so a reused id cannot inherit a dead pane account', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
|
||||
forgetCodexPaneAccount('pty-1')
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps every record below the tracking cap', () => {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
recordCodexPaneAccount(`pty-${index}`, { selectionKey: 'host', accountId: 'account-a' })
|
||||
}
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-0')).not.toBeNull()
|
||||
expect(getCodexPaneAccount('pty-19')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reads back nothing when the registry file is missing', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
rmSync(join(userDataPath, 'codex-pane-accounts.json'))
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', '{ not json'],
|
||||
['a non-object document', '"panes"'],
|
||||
['an array document', '[]'],
|
||||
['a missing panes map', '{"version":1}'],
|
||||
['a non-object panes map', '{"version":1,"panes":[]}']
|
||||
])('reads back nothing and still records when the file holds %s', (_label, contents) => {
|
||||
writeFileSync(join(userDataPath, 'codex-pane-accounts.json'), contents)
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-1')).toBeNull()
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
_internals.resetCache()
|
||||
expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', '{ not json'],
|
||||
['a malformed pane record', '{"version":2,"panes":{"pty-1":{"selectionKey":7}}}'],
|
||||
@@ -317,122 +67,4 @@ describe('codex pane account registry', () => {
|
||||
_internals.resetCache()
|
||||
expect(hasRecordedLegacyWslCodexPane('wsl:Ubuntu')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a malformed record without discarding its valid siblings', () => {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'codex-pane-accounts.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
panes: {
|
||||
'pty-bad': { selectionKey: 7, accountId: 'account-a' },
|
||||
'pty-good': { selectionKey: 'host', accountId: 'account-a' }
|
||||
}
|
||||
})
|
||||
)
|
||||
_internals.resetCache()
|
||||
|
||||
expect(getCodexPaneAccount('pty-bad')).toBeNull()
|
||||
expect(getCodexPaneAccount('pty-good')).toEqual({
|
||||
selectionKey: 'host',
|
||||
accountId: 'account-a'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('listStaleCodexPanes', () => {
|
||||
it('reports a pane launched under a now-deselected account', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({
|
||||
ptyIds: ['pty-1'],
|
||||
settings: settingsWithSelection('account-b')
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
launchAccountId: 'account-a',
|
||||
activeAccountId: 'account-b'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a managed pane after the selection drops to the system default', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) })
|
||||
).toEqual([
|
||||
{
|
||||
ptyId: 'pty-1',
|
||||
launchAccountId: 'account-a',
|
||||
activeAccountId: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a pane alone when its launch account is still selected', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection('account-a') })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a pane on an older Codex home alone while its account is unchanged', () => {
|
||||
recordCodexPaneAccount('pty-1', {
|
||||
selectionKey: 'host',
|
||||
accountId: null,
|
||||
homeRoute: 'shared-home'
|
||||
})
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('never reports an unrecorded PTY, so an upgrade cannot invent a prompt', () => {
|
||||
expect(
|
||||
listStaleCodexPanes({ ptyIds: ['pty-unknown'], settings: settingsWithSelection('account-b') })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('stops reporting a pane the user chose to keep on the old account', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a' })
|
||||
recordCodexPaneAccount('pty-2', { selectionKey: 'host', accountId: 'account-a' })
|
||||
|
||||
forgetStaleCodexPanes(['pty-1'])
|
||||
_internals.resetCache()
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({
|
||||
ptyIds: ['pty-1', 'pty-2'],
|
||||
settings: settingsWithSelection('account-b')
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
ptyId: 'pty-2',
|
||||
launchAccountId: 'account-a',
|
||||
activeAccountId: 'account-b'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('compares a WSL pane against its own distro selection', () => {
|
||||
recordCodexPaneAccount('pty-1', { selectionKey: 'wsl:Ubuntu', accountId: 'account-a' })
|
||||
recordCodexPaneAccount('pty-2', { selectionKey: 'wsl:Debian', accountId: 'account-c' })
|
||||
|
||||
expect(
|
||||
listStaleCodexPanes({
|
||||
ptyIds: ['pty-1', 'pty-2'],
|
||||
settings: settingsWithSelection('account-b', { Ubuntu: 'account-a', Debian: 'account-d' })
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
ptyId: 'pty-2',
|
||||
launchAccountId: 'account-c',
|
||||
activeAccountId: 'account-d'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
+5
-105
@@ -109,19 +109,16 @@ vi.mock('node:os', async () => {
|
||||
|
||||
const realFs = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
|
||||
const { upsertHookTrustEntries, upsertProjectTrustLevel, readHookTrustEntries } =
|
||||
const { upsertHookTrustEntries, upsertProjectTrustLevel } =
|
||||
await import('../../trust/toml/config-toml-trust')
|
||||
const {
|
||||
writeCodexTrustGrantLedgerHome,
|
||||
readCodexTrustGrantLedgerHome,
|
||||
getCodexTrustGrantLedgerPath
|
||||
} = await import('../../trust/grants/codex-trust-grant-ledger')
|
||||
const { readHooksJson } = await import('../../../agent-hooks/hooks-json-read')
|
||||
const { writeCodexTrustGrantLedgerHome, getCodexTrustGrantLedgerPath } =
|
||||
await import('../../trust/grants/codex-trust-grant-ledger')
|
||||
await import('../../../agent-hooks/hooks-json-read')
|
||||
const { MAX_AGENT_STATE_FILE_BYTES } = await import('../agent-state-file-reader')
|
||||
const { observeCodexSettingsBaseline } = await import('../settings/config-settings-baseline')
|
||||
const { snapshotCodexRuntimeSettingsBaseline } =
|
||||
await import('../settings/config-settings-promotion')
|
||||
const { getCodexConfigSyncStatus } = await import('../settings/config-sync-stall')
|
||||
await import('../settings/config-sync-stall')
|
||||
const paneRegistry = await import('./codex-pane-account-registry')
|
||||
|
||||
let fakeHomeDir: string
|
||||
@@ -196,14 +193,6 @@ describe('STA-4823 D29 — an unreadable config.toml must not become a trust-onl
|
||||
expect(realFs.readFileSync(configPath, 'utf-8')).toBe(USER_CONFIG)
|
||||
})
|
||||
|
||||
it('still seeds a config.toml that does not exist yet', () => {
|
||||
const configPath = join(runtimeHomePath, 'config.toml')
|
||||
|
||||
upsertHookTrustEntries(configPath, ENTRY)
|
||||
|
||||
expect(readHookTrustEntries(configPath).size).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('also refuses a project-trust write when the existing config cannot be read', () => {
|
||||
const configPath = join(runtimeHomePath, 'config.toml')
|
||||
realFs.writeFileSync(configPath, USER_CONFIG, 'utf-8')
|
||||
@@ -212,14 +201,6 @@ describe('STA-4823 D29 — an unreadable config.toml must not become a trust-onl
|
||||
expect(() => upsertProjectTrustLevel(configPath, '/tmp/project', 'trusted')).toThrow('EPERM')
|
||||
expect(realFs.readFileSync(configPath, 'utf-8')).toBe(USER_CONFIG)
|
||||
})
|
||||
|
||||
it('still seeds project trust when config.toml is genuinely absent', () => {
|
||||
const configPath = join(runtimeHomePath, 'config.toml')
|
||||
|
||||
upsertProjectTrustLevel(configPath, '/tmp/project', 'trusted')
|
||||
|
||||
expect(realFs.readFileSync(configPath, 'utf-8')).toContain('trust_level = "trusted"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4823 D30 — an unreadable trust-grant ledger must not be rewritten from empty', () => {
|
||||
@@ -248,33 +229,6 @@ describe('STA-4823 D30 — an unreadable trust-grant ledger must not be rewritte
|
||||
|
||||
expect(realFs.readFileSync(ledgerPath, 'utf-8')).toBe(before)
|
||||
})
|
||||
|
||||
it('still records a home when the ledger is readable', () => {
|
||||
const ledgerPath = seedLedgerWithAnotherHome()
|
||||
|
||||
writeCodexTrustGrantLedgerHome(
|
||||
runtimeHomePath,
|
||||
{ entries: { 'c:d': { trustedHash: 'sha256:mine' } } } as never,
|
||||
ledgerPath
|
||||
)
|
||||
|
||||
expect(readCodexTrustGrantLedgerHome(runtimeHomePath, ledgerPath)).not.toBeNull()
|
||||
expect(readCodexTrustGrantLedgerHome(OTHER_HOME, ledgerPath)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4823 D33 — an unreadable hooks.json is not an empty hooks.json', () => {
|
||||
it('reports a failed read as unknown rather than as no hooks configured', () => {
|
||||
const hooksPath = join(runtimeHomePath, 'hooks.json')
|
||||
realFs.writeFileSync(hooksPath, JSON.stringify({ hooks: { Stop: [] } }), 'utf-8')
|
||||
denials.deny(hooksPath)
|
||||
|
||||
expect(readHooksJson(hooksPath)).toBeNull()
|
||||
})
|
||||
|
||||
it('still reports a genuinely absent hooks.json as an empty config', () => {
|
||||
expect(readHooksJson(join(runtimeHomePath, 'hooks.json'))).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror', () => {
|
||||
@@ -310,24 +264,6 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror
|
||||
expect(realFs.readFileSync(baselinePath(), 'utf-8')).toBe(before)
|
||||
})
|
||||
|
||||
it('still replaces a baseline that is present but malformed', () => {
|
||||
realFs.writeFileSync(baselinePath(), '{ not json', 'utf-8')
|
||||
realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8')
|
||||
|
||||
snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)
|
||||
|
||||
expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
})
|
||||
|
||||
it('still replaces a fully-read baseline rejected by the JSON structure limit', () => {
|
||||
realFs.writeFileSync(baselinePath(), '['.repeat(129), 'utf-8')
|
||||
realFs.writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "m"\n', 'utf-8')
|
||||
|
||||
snapshotCodexRuntimeSettingsBaseline(runtimeHomePath)
|
||||
|
||||
expect(JSON.parse(realFs.readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
})
|
||||
|
||||
it('rebuilds an oversized baseline previously produced from a bounded runtime config', () => {
|
||||
const escapedValue = '\\'.repeat(MAX_AGENT_STATE_FILE_BYTES / 2 + 64)
|
||||
const oversizedRuntimeConfig = `model = "${escapedValue}"\n`
|
||||
@@ -348,38 +284,6 @@ describe('STA-4823 D26 — an unreadable settings baseline must stall the mirror
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4823 D15 — the sync status must not report synced while the mirror refuses', () => {
|
||||
it('reports the managed home as unavailable when its config cannot be read', () => {
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
realFs.writeFileSync(runtimeConfigPath, 'model = "m"\n', 'utf-8')
|
||||
realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8')
|
||||
denials.deny(runtimeConfigPath)
|
||||
|
||||
const status = getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() })
|
||||
|
||||
expect(status).toMatchObject({ state: 'stalled', reason: 'managed-home-unavailable' })
|
||||
})
|
||||
|
||||
it('reports the managed home as unavailable when only its content read is denied', () => {
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
realFs.writeFileSync(runtimeConfigPath, 'model = "m"\n', 'utf-8')
|
||||
realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8')
|
||||
denials.denyReads(runtimeConfigPath)
|
||||
|
||||
expect(
|
||||
getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() })
|
||||
).toMatchObject({ state: 'stalled', reason: 'managed-home-unavailable' })
|
||||
})
|
||||
|
||||
it('still reports synced when no runtime config exists yet', () => {
|
||||
realFs.writeFileSync(join(systemHome(), 'config.toml'), 'model = "s"\n', 'utf-8')
|
||||
|
||||
expect(
|
||||
getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath: systemHome() })
|
||||
).toMatchObject({ state: 'synced', reason: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('STA-4823 D31 — an unreadable pane registry must not erase every attribution', () => {
|
||||
const PANE = 'pty-1'
|
||||
const OTHER_PANE = 'pty-2'
|
||||
@@ -435,8 +339,4 @@ describe('STA-4823 D31 — an unreadable pane registry must not erase every attr
|
||||
|
||||
expect(paneRegistry.getCodexPaneAccount(PANE)).toMatchObject({ accountId: 'account-1' })
|
||||
})
|
||||
|
||||
it('still reports no attribution when the registry genuinely does not exist', () => {
|
||||
expect(paneRegistry.getCodexPaneAccount(PANE)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -34,8 +34,6 @@ import { parseTomlTableHeaderPath } from '../toml/config-toml-key-path'
|
||||
import { getTomlSections } from '../toml/config-toml-runtime-owned-sections'
|
||||
import '../../trust/toml/config-toml-trust'
|
||||
import { escapeTomlBasicString as escapeTomlString } from '../toml/config-toml-syntax'
|
||||
|
||||
const PLUGIN_KEY = 'demo@mkt:hooks/hooks.json:stop:0:0'
|
||||
const PROJECT_KEY = '/repo/.codex/hooks.json:stop:0:0'
|
||||
|
||||
let fakeHomeDir: string
|
||||
@@ -124,46 +122,6 @@ afterEach(() => {
|
||||
|
||||
describe('carrying plain-Codex hook trust into the managed Codex home', () => {
|
||||
// Why: Codex >=0.145 adds session_end and >=0.150 interrupt, which Orca's own parser rejects.
|
||||
it.each(
|
||||
['session_end', 'interrupt'].flatMap((event) =>
|
||||
[
|
||||
['plugin', `demo@mkt:hooks/hooks.json:${event}:0:0`],
|
||||
['project', `/repo/.codex/hooks.json:${event}:0:0`]
|
||||
].flatMap(([kind, key]) => [
|
||||
[event, kind, 'bare parent', key, bareParent] as const,
|
||||
[event, kind, 'fully quoted', key, fullyQuoted] as const
|
||||
])
|
||||
)
|
||||
)('carries %s %s hook trust in the %s spelling', (_event, _kind, _spelling, key, spell) => {
|
||||
const block = trustBlock(spell(key), 'sha256:newer-event')
|
||||
writeSystemConfig([block])
|
||||
|
||||
mirror()
|
||||
const runtimeConfig = mirror()
|
||||
|
||||
expect(runtimeConfig).toContain(block)
|
||||
expect(countHookTrustTables(runtimeConfig, key)).toBe(1)
|
||||
expectNoDuplicateTables(runtimeConfig)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['hooks.json', bareParent],
|
||||
['hooks.json', fullyQuoted],
|
||||
['config.toml', bareParent],
|
||||
['config.toml', fullyQuoted]
|
||||
])('leaves out user-layer trust keyed by ~/.codex/%s', (file, spell) => {
|
||||
const logicalKey = `${join(systemCodexHome(), file)}:stop:0:0`
|
||||
const realKey = `${join(realpathSync.native(systemCodexHome()), file)}:stop:1:0`
|
||||
writeSystemConfig([
|
||||
trustBlock(spell(logicalKey), 'sha256:user-hook'),
|
||||
trustBlock(spell(realKey), 'sha256:user-hook-realpath')
|
||||
])
|
||||
|
||||
const runtimeConfig = mirror()
|
||||
|
||||
expect(runtimeConfig).toContain('model = "system-model"')
|
||||
expect(runtimeConfig).not.toContain('sha256:user-hook')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bare parent', 'fully quoted', bareParent, fullyQuoted],
|
||||
@@ -183,42 +141,4 @@ describe('carrying plain-Codex hook trust into the managed Codex home', () => {
|
||||
expectNoDuplicateTables(runtimeConfig)
|
||||
}
|
||||
)
|
||||
|
||||
it('treats basic and literal spellings of a Windows key as one table', () => {
|
||||
const windowsKey = 'C:\\r\\.codex\\hooks.json:stop:0:0'
|
||||
const runtimeBlock = trustBlock(`[hooks.state.'${windowsKey}']`, 'sha256:runtime')
|
||||
writeRuntimeConfig(['[hooks.state]', runtimeBlock])
|
||||
writeSystemConfig([
|
||||
'["hooks"."state"]',
|
||||
trustBlock(fullyQuoted(windowsKey), 'sha256:system-same-key'),
|
||||
trustBlock(bareParent('C:/r/.codex/hooks.json:stop:0:0'), 'sha256:system-slash-variant')
|
||||
])
|
||||
|
||||
const runtimeConfig = mirror()
|
||||
|
||||
expect(runtimeConfig).toContain(runtimeBlock)
|
||||
expect(runtimeConfig).not.toContain('sha256:system-same-key')
|
||||
// Why: Codex 0.140 reads the other slash variant, which is a distinct table.
|
||||
expect(runtimeConfig).toContain('sha256:system-slash-variant')
|
||||
expect(countHookTrustTables(runtimeConfig, windowsKey)).toBe(1)
|
||||
expectNoDuplicateTables(runtimeConfig)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['[hooks.state]', '["hooks"."state"]'],
|
||||
['["hooks"."state"]', '[hooks.state]']
|
||||
])('keeps only the runtime %s parent table over the system %s', (runtimeParent, systemParent) => {
|
||||
writeRuntimeConfig([`${runtimeParent}\n# runtime parent`])
|
||||
writeSystemConfig([
|
||||
`${systemParent}\n# system parent`,
|
||||
trustBlock(bareParent(PLUGIN_KEY), 'sha256:plain-codex')
|
||||
])
|
||||
|
||||
const runtimeConfig = mirror()
|
||||
|
||||
expect(runtimeConfig).toContain('# runtime parent')
|
||||
expect(runtimeConfig).not.toContain('# system parent')
|
||||
expect(runtimeConfig).toContain('sha256:plain-codex')
|
||||
expectNoDuplicateTables(runtimeConfig)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,36 +23,7 @@ afterEach(() => rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
const BASELINE_FILE = '.orca-config-settings-baseline.json'
|
||||
|
||||
type StoredBaselineFixture = {
|
||||
version: 1 | 3
|
||||
settings: Record<string, string | null>
|
||||
mcpServers?: string[]
|
||||
}
|
||||
|
||||
function writeBaseline(baseline: StoredBaselineFixture): string {
|
||||
const serialized = JSON.stringify(baseline)
|
||||
writeFileSync(join(runtimeHomePath, BASELINE_FILE), serialized)
|
||||
return serialized
|
||||
}
|
||||
|
||||
describe('canonical MCP ownership during config mirroring', () => {
|
||||
it('does not duplicate a server defined inline in the canonical MCP table', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
'[mcp_servers.shared]\ncommand = "runtime"\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'[mcp_servers]\nshared = { command = "system" }\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
|
||||
expect(runtimeConfig).toContain('shared = { command = "system" }')
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers.shared]')
|
||||
})
|
||||
|
||||
it('preserves deletion after a canonical inline server is rewritten by the runtime', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
@@ -75,116 +46,9 @@ describe('canonical MCP ownership during config mirroring', () => {
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers.shared]')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.runtime_only]')
|
||||
})
|
||||
|
||||
it('honors a closed canonical MCP root and its later removal', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
'[mcp_servers.runtime_only]\ncommand = "runtime"\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'mcp_servers = { shared = { enabled = false } }\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8')
|
||||
)
|
||||
).toMatchObject({ mcpServerRoot: true })
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.shared]\nenabled = false\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MCP ownership migration', () => {
|
||||
it('keeps a pre-ownership baseline canonical for one pass', () => {
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, '.orca-config-settings-baseline.json'),
|
||||
JSON.stringify({ version: 1, settings: {} })
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(join(runtimeHomePath, '.orca-config-settings-baseline.json'), 'utf-8')
|
||||
)
|
||||
).toMatchObject({ mcpServers: [] })
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.added]\ncommand = "new"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain(
|
||||
'[mcp_servers.added]'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the retained shared home one-way without an ownership baseline', () => {
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the retained shared home canonical under a pre-ownership baseline', () => {
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), '[mcp_servers.removed]\ncommand = "old"\n')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
writeBaseline({ version: 1, settings: {} })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Orca-only servers in the retained shared home and drops ones removed from ~/.codex', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
[
|
||||
'[mcp_servers.demo-mcp]',
|
||||
'command = "orca-only"',
|
||||
'[mcp_servers.removed]',
|
||||
'command = "mirrored"',
|
||||
'[mcp_servers.kept]',
|
||||
'command = "stale"',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'model = "system"\n[mcp_servers.kept]\ncommand = "system"\n'
|
||||
)
|
||||
const baseline = writeBaseline({ version: 3, settings: {}, mcpServers: ['removed', 'kept'] })
|
||||
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
const runtimeConfig = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.demo-mcp]\ncommand = "orca-only"')
|
||||
expect(runtimeConfig).not.toContain('[mcp_servers.removed]')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.kept]\ncommand = "system"')
|
||||
expect(runtimeConfig).not.toContain('"stale"')
|
||||
expect(readFileSync(join(runtimeHomePath, BASELINE_FILE), 'utf-8')).toBe(baseline)
|
||||
})
|
||||
|
||||
it('leaves the retained shared home untouched when its baseline cannot be read', () => {
|
||||
const runtimeConfig = 'model = "retained"\n[mcp_servers.demo-mcp]\ncommand = "orca-only"\n'
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), runtimeConfig)
|
||||
@@ -197,25 +61,4 @@ describe('MCP ownership migration', () => {
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(runtimeConfig)
|
||||
})
|
||||
|
||||
it('tracks commented CRLF names so their later removal remains authoritative', () => {
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
'[mcp_servers.shared]\ncommand = "runtime"\n'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemHomePath, 'config.toml'),
|
||||
'[mcp_servers.shared] # see [docs]\r\ncommand = "system"\r\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain('"runtime"')
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "system"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).not.toContain(
|
||||
'[mcp_servers.shared]'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,15 +28,11 @@ vi.mock('node:os', async () => {
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
prepareSystemConfigForFreshRuntimeMirror,
|
||||
resolveCodexConfigMirrorSourceDirectory,
|
||||
syncSystemConfigIntoLegacySharedCodexHome,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from './codex-config-mirror'
|
||||
import '../../trust/toml/config-toml-trust'
|
||||
import { escapeTomlBasicString as escapeTomlString } from '../toml/config-toml-syntax'
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
@@ -54,14 +50,6 @@ function getRuntimeConfigPath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home', 'config.toml')
|
||||
}
|
||||
|
||||
function systemUserHookTrustHeader(): string {
|
||||
return `[hooks.state."${escapeTomlString(join(getSystemCodexHomePath(), 'hooks.json'))}:stop:0:0"]`
|
||||
}
|
||||
|
||||
function getRuntimeBaselinePath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home', '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-config-user-data-'))
|
||||
@@ -89,174 +77,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
it('seeds a missing runtime config without copying system user-hook trust', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
systemUserHookTrustHeader(),
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
'',
|
||||
'[projects."/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "system-model"')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).not.toContain(systemUserHookTrustHeader())
|
||||
})
|
||||
|
||||
it('normalizes deprecated codex_hooks feature flag only in runtime config', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[features]\nhooks = true')
|
||||
expect(runtimeConfig).not.toContain('codex_hooks')
|
||||
expect(readFileSync(getSystemConfigPath(), 'utf-8')).toContain('codex_hooks = true')
|
||||
})
|
||||
|
||||
it('preserves system-home relative path references in the runtime config copy', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model_instructions_file = "instructions.md"',
|
||||
"model_catalog_json = 'catalogs/models.json'",
|
||||
'experimental_compact_prompt_file = "prompts/compact.md"',
|
||||
'experimental_instructions_file = "legacy-instructions.md"',
|
||||
'log_dir = "logs"',
|
||||
'sqlite_home = "state"',
|
||||
'',
|
||||
'[agents.reviewer]',
|
||||
'config_file = "agents/reviewer.toml"',
|
||||
'',
|
||||
'[model_providers.qwen.auth]',
|
||||
'cwd = "auth"',
|
||||
'',
|
||||
'[[skills.config]]',
|
||||
'path = "skills/local"',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_instructions_file = '${join(getSystemCodexHomePath(), 'instructions.md')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`model_catalog_json = '${join(getSystemCodexHomePath(), 'catalogs', 'models.json')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`experimental_compact_prompt_file = '${join(
|
||||
getSystemCodexHomePath(),
|
||||
'prompts',
|
||||
'compact.md'
|
||||
)}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(
|
||||
`experimental_instructions_file = '${join(
|
||||
getSystemCodexHomePath(),
|
||||
'legacy-instructions.md'
|
||||
)}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
|
||||
expect(runtimeConfig).toContain(`sqlite_home = '${join(getSystemCodexHomePath(), 'state')}'`)
|
||||
expect(runtimeConfig).toContain(
|
||||
`config_file = '${join(getSystemCodexHomePath(), 'agents', 'reviewer.toml')}'`
|
||||
)
|
||||
expect(runtimeConfig).toContain(`cwd = '${join(getSystemCodexHomePath(), 'auth')}'`)
|
||||
expect(runtimeConfig).toContain(`path = '${join(getSystemCodexHomePath(), 'skills', 'local')}'`)
|
||||
})
|
||||
|
||||
it('does not treat lines inside multiline arrays as headers or path keys', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['notify = [', ' ["custom", 1]', ']', 'log_dir = "logs"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain(' ["custom", 1]')
|
||||
expect(runtimeConfig).toContain(`log_dir = '${join(getSystemCodexHomePath(), 'logs')}'`)
|
||||
})
|
||||
|
||||
it('escapes control characters instead of emitting them raw in rewritten paths', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\bdir"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('log_dir = "')
|
||||
expect(runtimeConfig).toContain('\\u0008')
|
||||
expect(runtimeConfig).not.toContain('\b')
|
||||
})
|
||||
|
||||
it('leaves values with lone-surrogate unicode escapes untouched', () => {
|
||||
writeFileSync(getSystemConfigPath(), 'log_dir = "logs\\uD800dir"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('log_dir = "logs\\uD800dir"')
|
||||
})
|
||||
|
||||
it('leaves absolute, home-prefixed, env-shaped, and URL path references untouched', () => {
|
||||
const passthroughLines = [
|
||||
'model_instructions_file = "~/notes/instructions.md"',
|
||||
'model_catalog_json = "$CODEX_ASSETS/models.json"',
|
||||
'experimental_instructions_file = "%USERPROFILE%\\\\instructions.md"',
|
||||
'log_dir = "/var/log/codex"',
|
||||
"sqlite_home = 'C:\\Users\\example\\state'",
|
||||
'experimental_compact_prompt_file = "file://server/prompts/compact.md"'
|
||||
]
|
||||
writeFileSync(getSystemConfigPath(), `${passthroughLines.join('\n')}\n`, 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
for (const line of passthroughLines) {
|
||||
expect(runtimeConfig).toContain(line)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an existing runtime config when the system config is missing', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
const runtimeConfig = [
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[features]',
|
||||
'hooks = true',
|
||||
'',
|
||||
'[projects."/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n')
|
||||
writeFileSync(getRuntimeConfigPath(), runtimeConfig, 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(runtimeConfig)
|
||||
expect(existsSync(getSystemConfigPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves an existing runtime config when the system config is blank', () => {
|
||||
// A zero-byte cloud-synced config may be half-written or unhydrated.
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
@@ -271,181 +91,6 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(runtimeConfig)
|
||||
})
|
||||
|
||||
it('mirrors system config updates while preserving runtime-owned trust sections', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[hooks.state]',
|
||||
'# runtime-owned parent',
|
||||
'',
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
'',
|
||||
'[projects."/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
'[projects."/runtime-only"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'model = "system-model"',
|
||||
'',
|
||||
'[hooks.state]',
|
||||
'# system-owned parent',
|
||||
'',
|
||||
'[projects."/repo"] # explicit revocation',
|
||||
'trust_level = "untrusted"',
|
||||
'',
|
||||
'[projects."/system-only"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
systemUserHookTrustHeader(),
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "system-model"')
|
||||
expect(runtimeConfig).not.toContain('model = "runtime-model"')
|
||||
expect(runtimeConfig).toContain('[projects."/repo"]')
|
||||
expect(runtimeConfig).toContain('[projects."/runtime-only"]')
|
||||
expect(runtimeConfig).toContain('[projects."/system-only"]')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).not.toContain(systemUserHookTrustHeader())
|
||||
expect(runtimeConfig).toContain('# runtime-owned parent')
|
||||
expect(runtimeConfig).not.toContain('# system-owned parent')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves runtime-only MCP servers and nested descendants with system precedence', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'[mcp_servers.runtime_only]',
|
||||
'command = "runtime-command"',
|
||||
'',
|
||||
'[mcp_servers.runtime_only.env]',
|
||||
'MODE = "runtime"',
|
||||
'',
|
||||
'[mcp_servers.shared]',
|
||||
'command = "runtime-shared"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'[mcp_servers."shared"]',
|
||||
'command = "system-shared"',
|
||||
'',
|
||||
'[mcp_servers.system_only]',
|
||||
'command = "system-only"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.runtime_only]')
|
||||
expect(runtimeConfig).toContain('[mcp_servers.runtime_only.env]')
|
||||
expect(runtimeConfig).toContain('MODE = "runtime"')
|
||||
expect(runtimeConfig).toContain('command = "system-shared"')
|
||||
expect(runtimeConfig).not.toContain('runtime-shared')
|
||||
expect(runtimeConfig.match(/\[mcp_servers\.(?:shared|"shared")\]/g)).toHaveLength(1)
|
||||
expect(runtimeConfig).toContain('[mcp_servers.system_only]')
|
||||
expect(JSON.parse(readFileSync(getRuntimeBaselinePath(), 'utf-8'))).toMatchObject({
|
||||
mcpServers: ['shared', 'system_only']
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes a previously mirrored MCP server when the system source deletes it', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), '[mcp_servers.revoked]\ncommand = "run"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).not.toContain('[mcp_servers.revoked]')
|
||||
})
|
||||
|
||||
it('keeps runtime-only MCP additions after a source refresh and remains byte-idempotent', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(getRuntimeConfigPath(), '[mcp_servers.runtime_only]\ncommand = "run"\n', 'utf-8')
|
||||
writeFileSync(getSystemConfigPath(), 'model = "system"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const first = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toBe(first)
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8')).toContain('[mcp_servers.runtime_only]')
|
||||
})
|
||||
|
||||
it('keeps an explicit system MCP disable canonical', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
'[mcp_servers.blocked]\ncommand = "runtime"\nenabled = true\n',
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
'[mcp_servers."blocked"]\ncommand = "system"\nenabled = false\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('enabled = false')
|
||||
expect(runtimeConfig).not.toContain('enabled = true')
|
||||
})
|
||||
|
||||
it('self-heals duplicate project tables in a CRLF runtime config', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
const projectHeader = '[projects."C:/Users/jinwo/orca/workspaces/orca/repo"]'
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
projectHeader,
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
projectHeader,
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(getSystemConfigPath(), 'model = "gpt-5"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1)
|
||||
expect(runtimeConfig).toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('applies a CRLF system revocation to an LF runtime project', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -488,90 +133,6 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(runtimeConfig).not.toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('keeps runtime trust when the system config re-trusts the exact-cased WSL project', () => {
|
||||
// Re-grants append an exact-cased block beside legacy revocations.
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
["[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']", 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
"[projects.'\\\\wsl$\\Ubuntu\\home\\u\\repo']",
|
||||
'trust_level = "untrusted"',
|
||||
'',
|
||||
"[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']",
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain("[projects.'\\\\wsl$\\Ubuntu\\home\\u\\Repo']")
|
||||
expect(runtimeConfig).toContain('trust_level = "trusted"')
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
})
|
||||
|
||||
it('does not let a case-distinct POSIX system revocation clobber runtime trust', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
['[projects."/home/u/Repo"]', 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
['[projects."/home/u/repo"]', 'trust_level = "untrusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(2)
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'drive-letter casing and separators',
|
||||
runtimePath: 'c:\\work\\repo',
|
||||
systemPath: 'C:/work/repo'
|
||||
},
|
||||
{
|
||||
name: 'WSL UNC path',
|
||||
runtimePath: '\\\\wsl$\\Ubuntu\\home\\u\\proj',
|
||||
systemPath: '//WSL$/ubuntu/home/u/proj'
|
||||
},
|
||||
{
|
||||
name: 'server UNC path',
|
||||
runtimePath: '\\\\server\\share\\proj',
|
||||
systemPath: '//SERVER/share/proj'
|
||||
}
|
||||
])('deduplicates $name across mirror inputs', ({ runtimePath, systemPath }) => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[`[projects.'${runtimePath}']`, 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[`[projects."${systemPath}"]`, 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps untrusted when self-healing duplicate project tables', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
@@ -595,128 +156,6 @@ describe('syncSystemConfigIntoManagedCodexHome', () => {
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).not.toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('keeps untrusted when self-healing duplicate system project tables', () => {
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'[projects."c:\\\\gemini_etl"]',
|
||||
'trust_level = "trusted"',
|
||||
'',
|
||||
"[projects.'C:\\gemini_etl']",
|
||||
'trust_level = "untrusted"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1)
|
||||
expect(runtimeConfig).toContain('trust_level = "untrusted"')
|
||||
expect(runtimeConfig).not.toContain('trust_level = "trusted"')
|
||||
})
|
||||
|
||||
it('deduplicates a POSIX project path containing a quote and backslash', () => {
|
||||
const projectPath = '/tmp/with"quote\\and-backslash'
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[`[projects.'${projectPath}']`, 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[`[projects."/tmp/with\\"quote\\\\and-backslash"]`, 'trust_level = "trusted"', ''].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not treat TOML table headers inside multiline strings as sections', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'instructions = """',
|
||||
'[hooks.state."inside-basic-string"]',
|
||||
'trusted_hash = "not-a-section"',
|
||||
'"""',
|
||||
'',
|
||||
"literal_instructions = '''",
|
||||
'[hooks.state."inside-literal-string"]',
|
||||
"'''",
|
||||
'',
|
||||
'[model_providers.openai]',
|
||||
'name = "OpenAI"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('[hooks.state."inside-basic-string"]')
|
||||
expect(runtimeConfig).toContain('[hooks.state."inside-literal-string"]')
|
||||
expect(runtimeConfig).toContain('[model_providers.openai]')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
})
|
||||
|
||||
it('does not let triple quotes in comments affect runtime-owned section mirroring', () => {
|
||||
mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true })
|
||||
writeFileSync(
|
||||
getRuntimeConfigPath(),
|
||||
[
|
||||
'# example: """ in a comment',
|
||||
"# example: ''' in a comment",
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[hooks.state."runtime-hooks:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
getSystemConfigPath(),
|
||||
[
|
||||
'# system example: """ in a comment',
|
||||
"# system example: ''' in a comment",
|
||||
'model = "system-model"',
|
||||
'',
|
||||
systemUserHookTrustHeader(),
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:system"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8')
|
||||
expect(runtimeConfig).toContain('model = "system-model"')
|
||||
expect(runtimeConfig).toContain('[hooks.state."runtime-hooks:stop:0:0"]')
|
||||
expect(runtimeConfig).toContain('trusted_hash = "sha256:runtime"')
|
||||
expect(runtimeConfig).not.toContain(systemUserHookTrustHeader())
|
||||
expect(runtimeConfig).not.toContain('trusted_hash = "sha256:system"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncSystemConfigIntoLegacySharedCodexHome', () => {
|
||||
@@ -735,45 +174,3 @@ describe('syncSystemConfigIntoLegacySharedCodexHome', () => {
|
||||
expect(existsSync(heldConfigPath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSystemConfigForFreshRuntimeMirror', () => {
|
||||
it('uses the Linux-side directory for WSL UNC source homes', () => {
|
||||
const sourceDir = resolveCodexConfigMirrorSourceDirectory(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex'
|
||||
)
|
||||
|
||||
expect(sourceDir).toBe('/home/alice/.codex')
|
||||
expect(
|
||||
prepareSystemConfigForFreshRuntimeMirror(
|
||||
'model_instructions_file = "instructions.md"\n',
|
||||
sourceDir
|
||||
)
|
||||
).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
})
|
||||
|
||||
it('rewrites relative paths against a Linux-side home and strips user-hook trust', () => {
|
||||
const prepared = prepareSystemConfigForFreshRuntimeMirror(
|
||||
[
|
||||
'model_instructions_file = "instructions.md"',
|
||||
'',
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
'[hooks.state."/home/alice/.codex/hooks.json:stop:0:0"]',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[projects."/home/alice/repo"]',
|
||||
'trust_level = "trusted"',
|
||||
''
|
||||
].join('\r\n'),
|
||||
'/home/alice/.codex'
|
||||
)
|
||||
|
||||
// WSL consumes the config inside Linux, so rewritten paths must use POSIX semantics.
|
||||
expect(prepared).toContain("model_instructions_file = '/home/alice/.codex/instructions.md'")
|
||||
expect(prepared).toContain('hooks = true')
|
||||
expect(prepared).not.toContain('codex_hooks')
|
||||
expect(prepared).toContain('[projects."/home/alice/repo"]')
|
||||
expect(prepared).not.toContain('[hooks.state."/home/alice/.codex/hooks.json:stop:0:0"]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,6 @@ vi.mock('../../../codex-accounts/fs-utils', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import { getCodexRegistrationKey } from '../toml/config-toml-plugin-registration-tables'
|
||||
|
||||
// Registration tables written by Codex 0.145 marketplace and plugin additions.
|
||||
const MARKETPLACE_TABLE = [
|
||||
@@ -48,9 +47,6 @@ const PLUGIN_TABLE = ['[plugins."ponytail@ponytail"]', 'enabled = true', 'versio
|
||||
'\n'
|
||||
)
|
||||
|
||||
const MARKETPLACE_KEY = getCodexRegistrationKey('marketplaces', 'ponytail')
|
||||
const PLUGIN_KEY = getCodexRegistrationKey('plugins', 'ponytail@ponytail')
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
@@ -86,10 +82,6 @@ function runtimeHomeDir(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home')
|
||||
}
|
||||
|
||||
function runtimeConfigPath(): string {
|
||||
return join(runtimeHomeDir(), 'config.toml')
|
||||
}
|
||||
|
||||
function baselinePath(homePath = runtimeHomeDir()): string {
|
||||
return join(homePath, '.orca-config-settings-baseline.json')
|
||||
}
|
||||
@@ -114,35 +106,12 @@ function simulateCodexRegistrationWrite(block: string, homePath = runtimeHomeDir
|
||||
writeFileSync(configPath, `${existing.trimEnd()}\n\n${block}\n`, 'utf-8')
|
||||
}
|
||||
|
||||
function simulateCodexRegistrationFieldWrite(
|
||||
field: string,
|
||||
rawValue: string,
|
||||
homePath = runtimeHomeDir()
|
||||
): void {
|
||||
const configPath = join(homePath, 'config.toml')
|
||||
const pattern = new RegExp(`^${field}[ \\t]*=.*$`, 'm')
|
||||
const existing = readFileSync(configPath, 'utf-8')
|
||||
writeFileSync(configPath, existing.replace(pattern, `${field} = ${rawValue}`), 'utf-8')
|
||||
}
|
||||
|
||||
function mirrorTwice(): void {
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
}
|
||||
|
||||
describe('codex plugin registration survives the managed-home mirror', () => {
|
||||
it('promotes a runtime-only marketplace that a metadata-only policy would drop', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[marketplaces.other]\nsource = "other"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE)
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('[marketplaces.ponytail]')
|
||||
expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]')
|
||||
expect(readRuntimeConfig()).toContain('[marketplaces.other]')
|
||||
})
|
||||
|
||||
it('honors a canonical removal instead of resurrecting the registration', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
@@ -158,188 +127,11 @@ describe('codex plugin registration survives the managed-home mirror', () => {
|
||||
expect(readRuntimeConfig()).not.toContain('ponytail@ponytail')
|
||||
})
|
||||
|
||||
it('re-mirrors a canonical registration the managed home deleted rather than propagating the delete', () => {
|
||||
writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n', 'utf-8')
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]')
|
||||
expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]')
|
||||
})
|
||||
|
||||
it('promotes an in-Codex plugin disable and keeps it disabled', () => {
|
||||
writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('enabled', 'false')
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('enabled = false')
|
||||
expect(readRuntimeConfig()).toContain('enabled = false')
|
||||
})
|
||||
|
||||
// Include absence as the third enabled state so both sides can diverge.
|
||||
it('lets the canonical config win when enablement changed to a different value on each side', () => {
|
||||
writeSystemConfig(
|
||||
`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE.replace('enabled = true\n', '')}\n`
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readFileSync(baselinePath(), 'utf-8')).not.toContain('"enabled"')
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readRuntimeConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = false'),
|
||||
'utf-8'
|
||||
)
|
||||
writeSystemConfig(
|
||||
readSystemConfig().replace('version = "4.8.4"', 'version = "4.8.4"\nenabled = true')
|
||||
)
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('enabled = true')
|
||||
expect(readSystemConfig()).not.toContain('enabled = false')
|
||||
expect(readRuntimeConfig()).toContain('enabled = true')
|
||||
})
|
||||
|
||||
it('lets the canonical config win when the registration has no mirrored ancestor', () => {
|
||||
writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}\n`)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('enabled', 'false')
|
||||
// v2 and rebuilt baselines have no registration history.
|
||||
const baseline = JSON.parse(readFileSync(baselinePath(), 'utf-8'))
|
||||
delete baseline.registrations
|
||||
writeFileSync(baselinePath(), `${JSON.stringify(baseline, null, 2)}\n`, 'utf-8')
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('enabled = true')
|
||||
expect(readRuntimeConfig()).toContain('enabled = true')
|
||||
})
|
||||
|
||||
it('keeps an unrelated canonical edit authoritative while a registration is promoted', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE)
|
||||
writeSystemConfig('model = "gpt-5-canonical"\n\n[features]\nhooks = true\n')
|
||||
mirrorTwice()
|
||||
|
||||
expect(readRuntimeConfig()).toContain('model = "gpt-5-canonical"')
|
||||
expect(readRuntimeConfig()).toContain('hooks = true')
|
||||
expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex marketplace refresh metadata promotion', () => {
|
||||
function seedMirroredMarketplace(): void {
|
||||
writeSystemConfig(
|
||||
`# user comment\nmodel = "gpt-5"\n\n${MARKETPLACE_TABLE}\n\n[mcp_servers.docs]\ncommand = "docs"\n`
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
}
|
||||
|
||||
it('promotes a newer last_updated with its paired last_revision', () => {
|
||||
seedMirroredMarketplace()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"')
|
||||
simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system).toContain('last_updated = "2026-02-01T09:30:00Z"')
|
||||
expect(system).toContain('last_revision = "bbbb222"')
|
||||
expect(system).toContain('# user comment')
|
||||
expect(system).toContain('ref_name = "main"')
|
||||
expect(system).toContain('[mcp_servers.docs]')
|
||||
expect(system).toContain('source = "https://github.com/DietrichGebert/ponytail.git"')
|
||||
})
|
||||
|
||||
it('skips an older managed timestamp', () => {
|
||||
seedMirroredMarketplace()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('last_updated', '"2020-01-01T00:00:00Z"')
|
||||
simulateCodexRegistrationFieldWrite('last_revision', '"stale99"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"')
|
||||
expect(readSystemConfig()).toContain('last_revision = "aaaa111"')
|
||||
})
|
||||
|
||||
// Date.parse rolls impossible dates forward, potentially making them look newer.
|
||||
it('rejects an impossible calendar date instead of rolling it forward', () => {
|
||||
seedMirroredMarketplace()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-30T00:00:00Z"')
|
||||
simulateCodexRegistrationFieldWrite('last_revision', '"rolled99"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"')
|
||||
expect(readSystemConfig()).toContain('last_revision = "aaaa111"')
|
||||
})
|
||||
|
||||
it('skips the refresh when the runtime cannot supply the paired last_revision', () => {
|
||||
seedMirroredMarketplace()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readRuntimeConfig()
|
||||
.replace('last_updated = "2026-01-05T10:00:00Z"', 'last_updated = "2026-09-01T00:00:00Z"')
|
||||
.replace('last_revision = "aaaa111"\n', ''),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"')
|
||||
expect(readSystemConfig()).toContain('last_revision = "aaaa111"')
|
||||
})
|
||||
|
||||
it('leaves the canonical config in control when the marketplace source changed', () => {
|
||||
seedMirroredMarketplace()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"')
|
||||
simulateCodexRegistrationFieldWrite('last_revision', '"bbbb222"')
|
||||
writeSystemConfig(
|
||||
readSystemConfig().replace(
|
||||
'source = "https://github.com/DietrichGebert/ponytail.git"',
|
||||
'source = "https://github.com/DietrichGebert/ponytail-fork.git"'
|
||||
)
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('last_updated = "2026-01-05T10:00:00Z"')
|
||||
expect(readSystemConfig()).toContain('ponytail-fork.git')
|
||||
expect(readRuntimeConfig()).toContain('ponytail-fork.git')
|
||||
})
|
||||
|
||||
it('promotes only last_updated and last_revision, never another refreshed field', () => {
|
||||
writeSystemConfig(`model = "gpt-5"\n\n${MARKETPLACE_TABLE}\ndescription = "canonical"\n`)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexRegistrationFieldWrite('last_updated', '"2026-02-01T09:30:00Z"')
|
||||
simulateCodexRegistrationFieldWrite('description', '"runtime"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('last_updated = "2026-02-01T09:30:00Z"')
|
||||
expect(readSystemConfig()).toContain('description = "canonical"')
|
||||
expect(readRuntimeConfig()).toContain('description = "canonical"')
|
||||
})
|
||||
|
||||
it('seeds an absent canonical config from the runtime without duplicating its tables', () => {
|
||||
writeSystemConfig('[features]\nhooks = true\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
rmSync(join(systemHomeDir(), 'config.toml'))
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE)
|
||||
writeFileSync(runtimeConfigPath(), `model = "o4"\n${readRuntimeConfig()}`, 'utf-8')
|
||||
mirrorTwice()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system).toContain('model = "o4"')
|
||||
expect(system.match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1)
|
||||
expect(readRuntimeConfig().match(/\[marketplaces\.ponytail\]/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the managed config and its baseline when the promotion write fails', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
@@ -362,108 +154,3 @@ describe('codex marketplace refresh metadata promotion', () => {
|
||||
expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex registration reconciliation isolates accounts and source homes', () => {
|
||||
function accountHome(name: string): string {
|
||||
return join(userDataDir, 'codex-accounts', name)
|
||||
}
|
||||
|
||||
it('promotes each managed account registration into the shared source without crossing baselines', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
const accounts = [accountHome('a'), accountHome('b')]
|
||||
for (const runtimeHomePath of accounts) {
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath: systemHomeDir()
|
||||
})
|
||||
}
|
||||
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE, accounts[0]!)
|
||||
simulateCodexRegistrationWrite(
|
||||
'[marketplaces.beta]\nsource_type = "git"\nsource = "https://example.test/beta.git"',
|
||||
accounts[1]!
|
||||
)
|
||||
for (const runtimeHomePath of [...accounts, ...accounts]) {
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath,
|
||||
systemHomePath: systemHomeDir()
|
||||
})
|
||||
}
|
||||
|
||||
expect(readSystemConfig()).toContain('[marketplaces.ponytail]')
|
||||
expect(readSystemConfig()).toContain('[marketplaces.beta]')
|
||||
for (const runtimeHomePath of accounts) {
|
||||
expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.ponytail]')
|
||||
expect(readRuntimeConfig(runtimeHomePath)).toContain('[marketplaces.beta]')
|
||||
expect(existsSync(baselinePath(runtimeHomePath))).toBe(true)
|
||||
}
|
||||
expect(readFileSync(baselinePath(accounts[0]!), 'utf-8')).toContain('marketplaces:ponytail')
|
||||
})
|
||||
|
||||
it('promotes a WSL-lane registration into that distro source home, never the host one', () => {
|
||||
const wslSourceHome = join(userDataDir, 'wsl-home', '.codex')
|
||||
const wslRuntimeHome = accountHome('wsl')
|
||||
writeSystemConfig('model = "host"\n')
|
||||
writeSystemConfig('model = "wsl"\n', wslSourceHome)
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: wslRuntimeHome,
|
||||
systemHomePath: wslSourceHome
|
||||
})
|
||||
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE, wslRuntimeHome)
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
syncSystemConfigIntoManagedCodexHome({
|
||||
runtimeHomePath: wslRuntimeHome,
|
||||
systemHomePath: wslSourceHome
|
||||
})
|
||||
}
|
||||
|
||||
expect(readSystemConfig(wslSourceHome)).toContain('[marketplaces.ponytail]')
|
||||
expect(readRuntimeConfig(wslRuntimeHome)).toContain('[marketplaces.ponytail]')
|
||||
expect(readSystemConfig()).toBe('model = "host"\n')
|
||||
})
|
||||
|
||||
it('heals a registration held only by a runtime home still on the v2 baseline schema', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
const { settings } = JSON.parse(readFileSync(baselinePath(), 'utf-8'))
|
||||
writeFileSync(baselinePath(), `${JSON.stringify({ version: 2, settings }, null, 2)}\n`, 'utf-8')
|
||||
|
||||
simulateCodexRegistrationWrite(`${MARKETPLACE_TABLE}\n\n${PLUGIN_TABLE}`)
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('[marketplaces.ponytail]')
|
||||
expect(readSystemConfig()).toContain('[plugins."ponytail@ponytail"]')
|
||||
expect(readRuntimeConfig()).toContain('[plugins."ponytail@ponytail"]')
|
||||
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({
|
||||
version: 3,
|
||||
registrations: { [MARKETPLACE_KEY]: {}, [PLUGIN_KEY]: { enabled: 'true' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a settled config byte-identical when the baseline is lost with no removal pending', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
simulateCodexRegistrationWrite(MARKETPLACE_TABLE)
|
||||
mirrorTwice()
|
||||
const settledSystem = readSystemConfig()
|
||||
const settledRuntime = readRuntimeConfig()
|
||||
|
||||
rmSync(baselinePath())
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toBe(settledSystem)
|
||||
expect(readRuntimeConfig()).toBe(settledRuntime)
|
||||
})
|
||||
|
||||
it('treats a runtime home seeded without a baseline as holding additions, not removals', () => {
|
||||
mkdirSync(runtimeHomeDir(), { recursive: true })
|
||||
writeFileSync(runtimeConfigPath(), `model = "gpt-5"\n\n${MARKETPLACE_TABLE}\n`, 'utf-8')
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
|
||||
mirrorTwice()
|
||||
|
||||
expect(readSystemConfig()).toContain('[marketplaces.ponytail]')
|
||||
expect(readRuntimeConfig()).toContain('[marketplaces.ponytail]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -78,19 +78,6 @@ function readBaseline(): {
|
||||
}
|
||||
|
||||
describe('Codex settings baseline schema upgrade', () => {
|
||||
it('upgrades an aligned legacy baseline without creating a conflict', () => {
|
||||
const config = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n'
|
||||
prepareLegacyState(config, config)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline()).toMatchObject({
|
||||
version: 3,
|
||||
settings: { model: '"gpt-5"', 'tui.theme': '"dark"' }
|
||||
})
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors a schema-new conflict while promoting an unrelated known key', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
@@ -107,117 +94,6 @@ describe('Codex settings baseline schema upgrade', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes the runtime side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace('theme = "runtime"', 'theme = "chosen"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBe('"chosen"')
|
||||
})
|
||||
|
||||
it('accepts the system side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "outside"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores unrelated config writes while a value pair is anchored', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
`${readFileSync(runtimeConfigPath(), 'utf-8')}\n[projects."/tmp/repo"]\ntrust_level = "trusted"\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
`${readFileSync(systemConfigPath(), 'utf-8')}\n[features]\nhooks = true\n`,
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('[projects."/tmp/repo"]')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime"', system: '"system"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors two new divergent values until one side changes again', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace(
|
||||
'theme = "runtime"',
|
||||
'theme = "runtime-2"'
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "system-2"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime-2"', system: '"system-2"' }
|
||||
})
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime-2"')
|
||||
})
|
||||
|
||||
it('preserves an absent runtime value until the user chooses one', () => {
|
||||
prepareLegacyState('model = "gpt-5"\n\n[tui]\ntheme = "system"\n', 'model = "gpt-5"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme =')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: null, system: '"system"' }
|
||||
})
|
||||
|
||||
writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n\n[tui]\ntheme = "chosen"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies the migration rule to future top-level schema additions', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\napproval_policy = "never"\n',
|
||||
@@ -232,32 +108,4 @@ describe('Codex settings baseline schema upgrade', () => {
|
||||
approval_policy: { runtime: '"on-request"', system: '"never"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an incompatible system TOML shape win instead of stranding a conflict', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\ntui = { animations = false }\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('tui = { animations = false }')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme = "runtime"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBeNull()
|
||||
})
|
||||
|
||||
it('does not require filesystem timestamp mutation during migration', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
const baselineBefore = readFileSync(baselinePath(), 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
expect(readFileSync(baselinePath(), 'utf-8')).not.toBe(baselineBefore)
|
||||
expect(readBaseline().conflicts?.['tui.theme']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
rmSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
@@ -51,11 +50,6 @@ vi.mock('../../../codex-accounts/fs-utils', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
|
||||
|
||||
// Exact [tui] block written by Codex 0.144.6 config/batchWrite.
|
||||
const CODEX_TUI_BLOCK =
|
||||
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
@@ -125,82 +119,7 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
|
||||
writeFileSync(runtimeConfigPath(), next, 'utf-8')
|
||||
}
|
||||
|
||||
function setRuntimeConfig(content: string): void {
|
||||
mkdirSync(runtimeHomeDir(), { recursive: true })
|
||||
writeFileSync(runtimeConfigPath(), content, 'utf-8')
|
||||
}
|
||||
|
||||
function simulateCodexSettingRemoval(key: string): void {
|
||||
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
|
||||
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
|
||||
writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8')
|
||||
}
|
||||
|
||||
describe('codex settings write-back promotion', () => {
|
||||
it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => {
|
||||
writeSystemConfig(
|
||||
'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
|
||||
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
|
||||
)
|
||||
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
|
||||
|
||||
const settledSystem = readSystemConfig()
|
||||
const settledRuntime = readRuntimeConfig()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe(settledSystem)
|
||||
expect(readRuntimeConfig()).toBe(settledRuntime)
|
||||
})
|
||||
|
||||
it('does not promote on the first pass without a baseline, then promotes after one', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
mkdirSync(runtimeHomeDir(), { recursive: true })
|
||||
writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
})
|
||||
|
||||
it('treats a corrupt baseline as missing and rewrites it', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
writeFileSync(baselinePath(), 'not json', 'utf-8')
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 3 })
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
})
|
||||
|
||||
it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('model', '"in-codex-choice"')
|
||||
writeSystemConfig('model = "outside-edit"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "outside-edit"\n')
|
||||
expect(readRuntimeConfig()).toContain('model = "outside-edit"')
|
||||
})
|
||||
|
||||
it('retries a runtime change after a missing system config returns', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
@@ -221,111 +140,6 @@ describe('codex settings write-back promotion', () => {
|
||||
expect(readRuntimeConfig()).toContain('model = "o4"')
|
||||
})
|
||||
|
||||
it('bootstraps a baseline while the system config is missing so promotion still arms', () => {
|
||||
setRuntimeConfig('model = "seeded"\n\n[features]\nhooks = true\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(existsSync(systemConfigPath())).toBe(false)
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
expect(readRuntimeConfig()).toContain('[features]')
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
writeSystemConfig('model = "seeded"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
expect(readRuntimeConfig()).toContain('model = "o4"')
|
||||
})
|
||||
|
||||
it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => {
|
||||
writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('approval_policy', '"on-request"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
|
||||
expect(existsSync(join(tmpHome, '.codex'))).toBe(false)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
|
||||
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n')
|
||||
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
|
||||
})
|
||||
|
||||
it('keeps runtime-only settings when promotion has to create ~/.codex/config.toml', () => {
|
||||
expect(existsSync(join(tmpHome, '.codex'))).toBe(false)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'[features]\nhooks = true\n\n[mcp_servers.linear]\ncommand = "npx"\n\n[projects."/repo"]\ntrust_level = "trusted"\n'
|
||||
)
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readRuntimeConfig()).toContain('[mcp_servers.linear]')
|
||||
expect(readRuntimeConfig()).toContain('[features]')
|
||||
expect(readRuntimeConfig()).toContain('model = "o4"')
|
||||
expect(readRuntimeConfig()).toContain('[projects."/repo"]')
|
||||
expect(readSystemConfig()).not.toContain('[projects."/repo"]')
|
||||
expect(readSystemConfig()).toContain('[mcp_servers.linear]')
|
||||
})
|
||||
|
||||
it('does not promote a key deletion', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingRemoval('model')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
|
||||
})
|
||||
|
||||
it('ignores keys outside the allowlist', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
simulateCodexSettingWrite('notify', '["custom-notifier"]')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
})
|
||||
|
||||
it('ignores allowlisted keys inside tables such as [profiles.*]', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const runtime = readRuntimeConfig()
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
runtime.replace('model = "profile-model"', 'model = "profile-changed"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
|
||||
})
|
||||
|
||||
it('never rewrites a multiline system value', () => {
|
||||
writeSystemConfig('model = """\nodd\nmultiline\n"""\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'preserves a restrictive mode on the existing ~/.codex/config.toml',
|
||||
() => {
|
||||
@@ -388,17 +202,6 @@ describe('codex settings write-back promotion', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('does not rewrite an unchanged settings baseline', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const past = new Date(Date.now() - 120_000)
|
||||
utimesSync(baselinePath(), past, past)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(statSync(baselinePath()).mtimeMs).toBeLessThan(Date.now() - 60_000)
|
||||
})
|
||||
|
||||
it('keeps the old baseline and retries after a transient promotion failure', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
@@ -417,176 +220,3 @@ describe('codex settings write-back promotion', () => {
|
||||
expect(readSystemConfig()).toBe('model = "o4"\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex [tui] settings write-back promotion', () => {
|
||||
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
|
||||
const runtime = readRuntimeConfig()
|
||||
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
|
||||
expect(runtime).toContain('status_line_use_colors = true')
|
||||
expect(runtime).toContain('terminal_title = ["model"]')
|
||||
expect(runtime).toContain('theme = "dark-photon"')
|
||||
|
||||
const settledSystem = readSystemConfig()
|
||||
const settledRuntime = readRuntimeConfig()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe(settledSystem)
|
||||
expect(readRuntimeConfig()).toBe(settledRuntime)
|
||||
})
|
||||
|
||||
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes a model change and a status-line change in one pass into their regions', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('promotes through a quoted tui table without creating a duplicate table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('\n[tui]\n')
|
||||
})
|
||||
|
||||
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
|
||||
// A dotted tui key already defines the table; appending [tui] would duplicate it.
|
||||
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
|
||||
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system).toContain('status_line = ["model"]\r\n')
|
||||
expect(system).toBe(
|
||||
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('never appends a [tui] table when the system config defines tui inline', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// An inline tui table prevents appending [tui] for the concurrent runtime edit.
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
promotionTestState.atomicWritePaths.length = 0
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
|
||||
})
|
||||
|
||||
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertPromotedSettingsInContent', () => {
|
||||
it('drops an absent scalar that would redefine an existing tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui."theme"]\nvariant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('[tui."theme"]\nvariant = "dark"\n')
|
||||
})
|
||||
|
||||
it('drops an absent scalar that would redefine a dotted tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui.theme.variant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('tui.theme.variant = "dark"\n')
|
||||
})
|
||||
|
||||
it('does not mistake a dotted tui key inside an array table for a root key', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[profiles]]\ntui.theme = "profile-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
|
||||
})
|
||||
|
||||
it('does not append a table beside a root tui array-of-tables', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[tui]]\ntheme = "array-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[tui]]\ntheme = "array-theme"\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => {
|
||||
throw new Error('tests pass explicit homes')
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
applyCodexDaemonSocketGuard,
|
||||
codexDaemonSocketPath,
|
||||
codexDaemonSocketPathExceedsLimit,
|
||||
stripCodexDaemonOverride
|
||||
} from './codex-daemon-socket-path-guard'
|
||||
import {
|
||||
syncSystemConfigIntoLegacySharedCodexHome,
|
||||
syncSystemConfigIntoManagedCodexHome
|
||||
} from '../settings/codex-config-mirror'
|
||||
import { getCodexConfigSyncStatus } from '../settings/config-sync-stall'
|
||||
import { getCodexSettingsBaselinePath } from '../settings/config-settings-baseline'
|
||||
import { extractOrdinaryCodexSettings } from '../toml/config-toml-runtime-owned-sections'
|
||||
|
||||
const UUID = '9dd962e2-449d-44c4-9733-0633f255064a'
|
||||
const MAC_MANAGED_HOME = `/Users/john/Library/Application Support/orca/codex-accounts/${UUID}/home`
|
||||
const OVERRIDE_LINE = 'daemon_auto_start = false # orca: CODEX_HOME too long for the daemon socket'
|
||||
|
||||
// The socket suffix is 43 bytes: sun_path 104 (macOS) / 108 (Linux) leaves 60 / 64 for the home.
|
||||
function homeOfLength(length: number): string {
|
||||
return `/${'a'.repeat(length - 1)}`
|
||||
}
|
||||
|
||||
describe('codexDaemonSocketPathExceedsLimit', () => {
|
||||
it('matches the reproduced Codex ceiling on macOS and Linux', () => {
|
||||
expect(codexDaemonSocketPathExceedsLimit(homeOfLength(60), 'darwin')).toBe(false)
|
||||
expect(codexDaemonSocketPathExceedsLimit(homeOfLength(61), 'darwin')).toBe(true)
|
||||
expect(codexDaemonSocketPathExceedsLimit(homeOfLength(64), 'linux')).toBe(false)
|
||||
expect(codexDaemonSocketPathExceedsLimit(homeOfLength(65), 'linux')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts bytes, not characters', () => {
|
||||
const home = `/${'é'.repeat(30)}`
|
||||
expect(home.length).toBe(31)
|
||||
expect(codexDaemonSocketPathExceedsLimit(home, 'darwin')).toBe(true)
|
||||
})
|
||||
|
||||
it('measures WSL homes by their Linux path and limit', () => {
|
||||
const shortWsl = '\\\\wsl.localhost\\Ubuntu\\home\\u\\.codex-orca'
|
||||
expect(codexDaemonSocketPath(shortWsl, 'win32')).toBe(
|
||||
'/home/u/.codex-orca/app-server-control/app-server-control.sock'
|
||||
)
|
||||
expect(codexDaemonSocketPathExceedsLimit(shortWsl, 'win32')).toBe(false)
|
||||
const managedWsl = `\\\\wsl.localhost\\Ubuntu\\home\\u\\.local\\share\\orca\\codex-accounts\\${UUID}\\home`
|
||||
expect(codexDaemonSocketPathExceedsLimit(managedWsl, 'win32')).toBe(true)
|
||||
})
|
||||
|
||||
it('flags the Windows managed home against the 108-byte uds_windows limit', () => {
|
||||
const home = `C:\\Users\\neil\\AppData\\Roaming\\orca\\codex-accounts\\${UUID}\\home`
|
||||
expect(codexDaemonSocketPathExceedsLimit(home, 'win32')).toBe(true)
|
||||
expect(codexDaemonSocketPathExceedsLimit('C:\\Users\\neil\\.codex', 'win32')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCodexDaemonSocketGuard', () => {
|
||||
it('appends a [features] table when none exists and strips back to the original', () => {
|
||||
const config = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n'
|
||||
const guarded = applyCodexDaemonSocketGuard(config, MAC_MANAGED_HOME, 'darwin')
|
||||
expect(guarded).toBe(`${config}\n[features]\n${OVERRIDE_LINE}\n`)
|
||||
expect(applyCodexDaemonSocketGuard(guarded, MAC_MANAGED_HOME, 'darwin')).toBe(guarded)
|
||||
expect(stripCodexDaemonOverride(guarded)).toBe(config)
|
||||
})
|
||||
|
||||
it('writes into an existing [features] table and overrides an explicit true', () => {
|
||||
const config = '[features]\nhooks = true\ndaemon_auto_start = true\n\n[tui]\ntheme = "dark"\n'
|
||||
const guarded = applyCodexDaemonSocketGuard(config, MAC_MANAGED_HOME, 'darwin')
|
||||
expect(guarded).toBe(`[features]\nhooks = true\n${OVERRIDE_LINE}\n\n[tui]\ntheme = "dark"\n`)
|
||||
expect(stripCodexDaemonOverride(guarded)).toBe(
|
||||
'[features]\nhooks = true\n\n[tui]\ntheme = "dark"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a dotted key beside dotted features keys so the table is not defined twice', () => {
|
||||
const config = 'features.hooks = true\n\n[tui]\ntheme = "dark"\n'
|
||||
expect(applyCodexDaemonSocketGuard(config, MAC_MANAGED_HOME, 'darwin')).toBe(
|
||||
`features.hooks = true\nfeatures.${OVERRIDE_LINE}\n\n[tui]\ntheme = "dark"\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a config for an empty home and keeps CRLF files CRLF', () => {
|
||||
expect(applyCodexDaemonSocketGuard('', MAC_MANAGED_HOME, 'darwin')).toBe(
|
||||
`[features]\n${OVERRIDE_LINE}\n`
|
||||
)
|
||||
const crlf = 'model = "gpt-5"\r\n'
|
||||
const guarded = applyCodexDaemonSocketGuard(crlf, MAC_MANAGED_HOME, 'darwin')
|
||||
expect(guarded).toBe(`model = "gpt-5"\r\n\r\n[features]\r\n${OVERRIDE_LINE}\r\n`)
|
||||
expect(stripCodexDaemonOverride(guarded)).toBe(crlf)
|
||||
})
|
||||
|
||||
it('removes a stale override once the home fits', () => {
|
||||
const guarded = applyCodexDaemonSocketGuard('model = "m"\n', MAC_MANAGED_HOME, 'darwin')
|
||||
expect(applyCodexDaemonSocketGuard(guarded, homeOfLength(20), 'darwin')).toBe('model = "m"\n')
|
||||
expect(applyCodexDaemonSocketGuard('model = "m"\n', homeOfLength(20), 'darwin')).toBe(
|
||||
'model = "m"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('warns once instead of failing silently when inline features block the override', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const home = `${MAC_MANAGED_HOME}-inline`
|
||||
const config = 'features = { hooks = true }\n'
|
||||
expect(applyCodexDaemonSocketGuard(config, home, 'darwin')).toBe(config)
|
||||
applyCodexDaemonSocketGuard(config, home, 'darwin')
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('Could not turn off Codex daemon auto-start')
|
||||
const alreadyOff = 'features = { daemon_auto_start = false }\n'
|
||||
applyCodexDaemonSocketGuard(alreadyOff, `${home}-off`, 'darwin')
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('never promotes the override into a seeded ~/.codex', () => {
|
||||
const guarded = applyCodexDaemonSocketGuard('model = "m"\n', MAC_MANAGED_HOME, 'darwin')
|
||||
expect(extractOrdinaryCodexSettings(guarded)).toBe('model = "m"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('syncSystemConfigIntoManagedCodexHome daemon guard', () => {
|
||||
let root: string
|
||||
let systemHomePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
// /tmp keeps the short home below POSIX socket limits.
|
||||
root = mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'cx-'))
|
||||
systemHomePath = join(root, 's')
|
||||
mkdirSync(systemHomePath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeHome(name: string): string {
|
||||
const home = join(root, name)
|
||||
mkdirSync(home, { recursive: true })
|
||||
return home
|
||||
}
|
||||
|
||||
const longHome = (): string =>
|
||||
makeHome(join('Library', 'Application Support', 'orca', 'codex-accounts', UUID, 'home'))
|
||||
|
||||
it('turns daemon auto-start off in a long managed home without touching ~/.codex', () => {
|
||||
const systemConfig = 'model = "gpt-5"\n'
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), systemConfig)
|
||||
const runtimeHomePath = longHome()
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
const first = readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')
|
||||
expect(first).toBe(`model = "gpt-5"\n\n[features]\n${OVERRIDE_LINE}\n`)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(first)
|
||||
expect(readFileSync(join(systemHomePath, 'config.toml'), 'utf-8')).toBe(systemConfig)
|
||||
})
|
||||
|
||||
it('still guards a long home when the user has no ~/.codex/config.toml', () => {
|
||||
const runtimeHomePath = longHome()
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(
|
||||
`[features]\n${OVERRIDE_LINE}\n`
|
||||
)
|
||||
// A guard-only config has no settings to lose, so missing source does not indicate a stall.
|
||||
expect(getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath }).state).toBe('synced')
|
||||
})
|
||||
|
||||
it('still guards an existing home when a stalled settings write-back skips the mirror', () => {
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5"\n')
|
||||
const runtimeHomePath = longHome()
|
||||
writeFileSync(join(runtimeHomePath, 'config.toml'), 'model = "runtime"\n')
|
||||
// An unreadable baseline makes promotion refuse, so no mirror pass runs.
|
||||
mkdirSync(getCodexSettingsBaselinePath(runtimeHomePath))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
warn.mockRestore()
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(
|
||||
`model = "runtime"\n\n[features]\n${OVERRIDE_LINE}\n`
|
||||
)
|
||||
expect(readFileSync(join(systemHomePath, 'config.toml'), 'utf-8')).toBe('model = "gpt-5"\n')
|
||||
})
|
||||
|
||||
it('mirrors a later-created ~/.codex/config.toml into a guard-only home', () => {
|
||||
const runtimeHomePath = longHome()
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe(
|
||||
`model = "gpt-5"\n\n[features]\n${OVERRIDE_LINE}\n`
|
||||
)
|
||||
expect(readFileSync(join(systemHomePath, 'config.toml'), 'utf-8')).toBe('model = "gpt-5"\n')
|
||||
})
|
||||
|
||||
it('still reports a real stall when the runtime holds user settings and the source is gone', () => {
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5"\n')
|
||||
const runtimeHomePath = longHome()
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
rmSync(join(systemHomePath, 'config.toml'))
|
||||
expect(getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath })).toMatchObject({
|
||||
state: 'stalled',
|
||||
reason: 'missing-source'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the guard in the legacy shared home when a system launch refreshes it', () => {
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5"\n')
|
||||
const runtimeHomePath = longHome()
|
||||
writeFileSync(
|
||||
join(runtimeHomePath, 'config.toml'),
|
||||
`model = "old"\n\n[features]\n${OVERRIDE_LINE}\n`
|
||||
)
|
||||
syncSystemConfigIntoLegacySharedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain(OVERRIDE_LINE)
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toContain('model = "gpt-5"')
|
||||
expect(readFileSync(join(systemHomePath, 'config.toml'), 'utf-8')).toBe('model = "gpt-5"\n')
|
||||
})
|
||||
|
||||
it('leaves a short home on the default daemon behavior', () => {
|
||||
writeFileSync(join(systemHomePath, 'config.toml'), 'model = "gpt-5"\n')
|
||||
const runtimeHomePath = makeHome('h')
|
||||
syncSystemConfigIntoManagedCodexHome({ runtimeHomePath, systemHomePath })
|
||||
expect(readFileSync(join(runtimeHomePath, 'config.toml'), 'utf-8')).toBe('model = "gpt-5"\n')
|
||||
})
|
||||
})
|
||||
@@ -1,242 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
captureCodexSessionBackfillMarkerGeneration,
|
||||
hasCompletedCodexSessionBackfillMarker,
|
||||
markCodexSessionBackfillMarkerNeedsFullScan,
|
||||
markCodexSessionBackfillMarkerPending,
|
||||
readCodexSessionBackfillBaseline,
|
||||
writeCodexSessionBackfillMarker
|
||||
} from './codex-session-backfill-marker'
|
||||
import {
|
||||
getCodexSessionBackfillDate,
|
||||
getCodexSessionBackfillDatesBetween
|
||||
} from './codex-session-backfill-scan-dates'
|
||||
import type {
|
||||
CodexSessionBackfillDate,
|
||||
CodexSessionBackfillSummary
|
||||
} from './codex-session-backfill-types'
|
||||
|
||||
const WINDOWS_ROOT = 'C:\\Users\\Me\\.codex\\sessions'
|
||||
const TODAY = getCodexSessionBackfillDate()
|
||||
const LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '08', '05']
|
||||
|
||||
let stateDir: string
|
||||
let markerPath: string
|
||||
|
||||
function createSummary(scannedFiles = 3): CodexSessionBackfillSummary {
|
||||
return {
|
||||
stopped: false,
|
||||
scannedFiles,
|
||||
linkedFiles: scannedFiles,
|
||||
copiedFiles: 0,
|
||||
skippedExistingFiles: 0,
|
||||
skippedUnexpectedFiles: 0,
|
||||
skippedSymlinkFiles: 0,
|
||||
skippedUnsupportedFilesystemFiles: 0,
|
||||
failedDirectories: 0,
|
||||
failedFiles: 0,
|
||||
failedHealAuditRecords: 0
|
||||
}
|
||||
}
|
||||
|
||||
function writeFullBaseline(
|
||||
root: string,
|
||||
options: { coveredScanDates?: readonly CodexSessionBackfillDate[]; retain?: boolean } = {}
|
||||
): void {
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
root,
|
||||
createSummary(),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{
|
||||
coverage: 'full',
|
||||
coveredScanDates: options.coveredScanDates ?? [],
|
||||
retainPendingScanDates: options.retain
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function overflowingDates(): CodexSessionBackfillDate[] {
|
||||
return getCodexSessionBackfillDatesBetween(
|
||||
new Date(Date.UTC(2026, 6, 1)),
|
||||
new Date(Date.UTC(2026, 7, 9))
|
||||
)
|
||||
}
|
||||
|
||||
function readMarker(): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(markerPath, 'utf-8')) as Record<string, unknown>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = mkdtempSync(join(tmpdir(), 'orca-codex-marker-'))
|
||||
markerPath = join(stateDir, 'backfill-complete.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(stateDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('codex session backfill marker', () => {
|
||||
it('treats Windows spellings of one directory as the same target', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
for (const alias of ['C:/Users/Me/.codex/sessions', 'c:\\users\\me\\.codex\\sessions']) {
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, alias)).toBe(true)
|
||||
}
|
||||
expect(
|
||||
hasCompletedCodexSessionBackfillMarker(markerPath, 'C:\\Users\\Me\\other-codex\\sessions')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a legacy v3 marker as a certified baseline with nothing pending', () => {
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: WINDOWS_ROOT,
|
||||
completedAt: Date.now(),
|
||||
summary: { scannedFiles: 12 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, 'c:/users/me/.codex/sessions')).toEqual({
|
||||
pendingScanDates: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps honoring the v3 empty-source guard', () => {
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: WINDOWS_ROOT,
|
||||
summary: { scannedFiles: 0 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat a bounded pass that scanned nothing as an empty source', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
WINDOWS_ROOT,
|
||||
createSummary(0),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{ coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] }
|
||||
)
|
||||
|
||||
expect(readMarker()).toMatchObject({ baselineScannedFiles: 3 })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to let a bounded pass invent a baseline it never verified', () => {
|
||||
writeCodexSessionBackfillMarker(
|
||||
markerPath,
|
||||
WINDOWS_ROOT,
|
||||
createSummary(),
|
||||
captureCodexSessionBackfillMarkerGeneration(),
|
||||
{ coverage: 'bounded', coveredScanDates: [LAUNCH_DATE] }
|
||||
)
|
||||
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a marker for a different history untouched', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
markCodexSessionBackfillMarkerPending(markerPath, 'D:\\other\\.codex\\sessions', [LAUNCH_DATE])
|
||||
|
||||
expect(readMarker()).toMatchObject({ systemSessionsRoot: WINDOWS_ROOT, pendingScanDates: [] })
|
||||
})
|
||||
|
||||
it('keeps a racing launch date pending when an older pass publishes', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
const staleGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE])
|
||||
|
||||
writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, {
|
||||
coverage: 'bounded',
|
||||
coveredScanDates: [LAUNCH_DATE]
|
||||
})
|
||||
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [LAUNCH_DATE] })
|
||||
})
|
||||
|
||||
it('keeps the pane date pending while the pane is still live', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT, { coveredScanDates: [LAUNCH_DATE], retain: true })
|
||||
|
||||
expect(readMarker()).toMatchObject({ launchActive: true, pendingScanDates: [LAUNCH_DATE] })
|
||||
})
|
||||
|
||||
it('demands a full walk once the pending window outgrows its bound', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
expect(
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
).toBe(true)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps owing that full walk when the next launch records its own date', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
expect(markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY])).toBe(true)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the full-walk demand when a later launch overtook the walk', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
const staleGeneration = captureCodexSessionBackfillMarkerGeneration()
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
writeCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT, createSummary(), staleGeneration, {
|
||||
coverage: 'full',
|
||||
coveredScanDates: []
|
||||
})
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: true })
|
||||
})
|
||||
|
||||
it('clears the full-walk demand only once a full walk certifies the tree', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, overflowingDates())
|
||||
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
|
||||
expect(readMarker()).toMatchObject({ needsFullScan: false, pendingScanDates: [] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a launch date even before any baseline exists', () => {
|
||||
markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [TODAY])
|
||||
|
||||
expect(readMarker()).toMatchObject({ coverage: 'bounded', pendingScanDates: [TODAY] })
|
||||
expect(hasCompletedCodexSessionBackfillMarker(markerPath, WINDOWS_ROOT)).toBe(false)
|
||||
})
|
||||
|
||||
it('a full-scan demand outlives later launches and only a full pass clears it', () => {
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
markCodexSessionBackfillMarkerNeedsFullScan(markerPath)
|
||||
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toBeNull()
|
||||
expect(markCodexSessionBackfillMarkerPending(markerPath, WINDOWS_ROOT, [LAUNCH_DATE])).toBe(
|
||||
true
|
||||
)
|
||||
writeFullBaseline(WINDOWS_ROOT)
|
||||
expect(readCodexSessionBackfillBaseline(markerPath, WINDOWS_ROOT)).toEqual({
|
||||
pendingScanDates: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
@@ -46,12 +45,8 @@ import {
|
||||
resolveCodexSessionBackfillPaths,
|
||||
startCodexSessionBackfillInBackground
|
||||
} from './codex-session-backfill'
|
||||
import {
|
||||
markCodexSessionBackfillMarkerPending,
|
||||
readCodexSessionBackfillBaseline
|
||||
} from './codex-session-backfill-marker'
|
||||
import { markCodexSessionBackfillMarkerPending } from './codex-session-backfill-marker'
|
||||
import { fsMockState, resetCodexSessionBackfillFsMocks } from './codex-session-backfill-fs-mocks'
|
||||
import { getCodexSessionBackfillDate } from './codex-session-backfill-scan-dates'
|
||||
import type { CodexSessionBackfillDate } from './codex-session-backfill-types'
|
||||
|
||||
const FIXTURE_LAUNCH_DATE: CodexSessionBackfillDate = ['2026', '05', '26']
|
||||
@@ -176,22 +171,6 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
||||
expect(readAuditActions()).toEqual(['existing', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps recovery records parseable after a torn audit tail', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n')
|
||||
const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl')
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
writeFileSync(targetPath, 'existing target\n', 'utf-8')
|
||||
mkdirSync(dirname(getAuditLogPath()), { recursive: true })
|
||||
writeFileSync(getAuditLogPath(), '{"torn":', 'utf-8')
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
resolveCodexSessionBackfillPaths()
|
||||
)
|
||||
|
||||
expect(summary).toMatchObject({ skippedExistingFiles: 1, failedHealAuditRecords: 0 })
|
||||
expect(readAuditActions()).toEqual(['existing', 'run-summary'])
|
||||
})
|
||||
|
||||
it('treats a broken symlink at the target as taken', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), 'managed contents\n')
|
||||
const collidingPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl')
|
||||
@@ -211,27 +190,6 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
||||
expect(lstatSync(collidingPath).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not backfill symlinked managed session files', async () => {
|
||||
const realSource = join(fakeHomeDir, 'outside.jsonl')
|
||||
writeFileSync(realSource, 'outside contents\n', 'utf-8')
|
||||
const managedLinkPath = join(getManagedSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl')
|
||||
mkdirSync(dirname(managedLinkPath), { recursive: true })
|
||||
try {
|
||||
symlinkSync(realSource, managedLinkPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const summary = await backfillManagedCodexSessionsIntoSystemHome(
|
||||
resolveCodexSessionBackfillPaths()
|
||||
)
|
||||
|
||||
// The walker skips symlink dirents before they can count as scanned or skipped files.
|
||||
expect(summary).toMatchObject({ linkedFiles: 0, copiedFiles: 0, failedFiles: 0 })
|
||||
const targetPath = join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl')
|
||||
expect(existsSync(targetPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the target filesystem cannot install without overwrite', async () => {
|
||||
fsMockState.failLink = true
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
@@ -251,31 +209,6 @@ describe('backfillManagedCodexSessionsIntoSystemHome', () => {
|
||||
expect(readdirSync(dirname(targetPath))).toEqual([])
|
||||
expect(readAuditActions()).toEqual(['copy-unsupported', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps target directory permission failures retryable', async () => {
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
fsMockState.failMkdirPath = dirname(join(getSystemSessionsRoot(), relativePath))
|
||||
|
||||
const summary = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
expect(readAuditActions()).toEqual(['failed', 'run-summary'])
|
||||
})
|
||||
|
||||
it('keeps hardlink permission failures retryable', async () => {
|
||||
fsMockState.failLinkPermission = true
|
||||
const relativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
|
||||
const summary = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(summary).toMatchObject({ failedFiles: 1, skippedUnsupportedFilesystemFiles: 0 })
|
||||
expect(existsSync(join(getSystemSessionsRoot(), relativePath))).toBe(false)
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startCodexSessionBackfillInBackground', () => {
|
||||
@@ -297,34 +230,6 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('certifies the historical baseline while a launch lease is still active', async () => {
|
||||
const today = getCodexSessionBackfillDate()
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-history.jsonl'), 'history\n')
|
||||
writeManagedSession(join(...today, 'rollout-live.jsonl'), 'live\n')
|
||||
markLaunchPending(today)
|
||||
|
||||
const active = await startCodexSessionBackfillInBackground({
|
||||
ignoreCompletionMarker: true,
|
||||
retainPendingScanDates: true
|
||||
})
|
||||
|
||||
expect(active).toMatchObject({ scannedFiles: 2, linkedFiles: 2 })
|
||||
expect(readMarker()).toMatchObject({
|
||||
version: 4,
|
||||
coverage: 'full',
|
||||
launchActive: true,
|
||||
pendingScanDates: [today]
|
||||
})
|
||||
expect(readCodexSessionBackfillBaseline(getMarkerPath(), getSystemSessionsRoot())).toEqual({
|
||||
pendingScanDates: [today]
|
||||
})
|
||||
|
||||
const completed = await startCodexSessionBackfillInBackground()
|
||||
expect(completed).toMatchObject({ scannedFiles: 1, skippedExistingFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ pendingScanDates: [], launchActive: false })
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a racing launch date pending without discarding the baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
let raceStarted = false
|
||||
@@ -355,48 +260,6 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(readFileSync(getAuditLogPath(), 'utf-8')).not.toBe(racedAudit)
|
||||
})
|
||||
|
||||
it('falls back to a full rescan when the pending rewrite fails', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
fsMockState.failMarkerReplacement = true
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
|
||||
expect(existsSync(getMarkerPath())).toBe(false)
|
||||
fsMockState.failMarkerReplacement = false
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
expect(recovered).toMatchObject({ skippedExistingFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('reads a legacy v3 marker as a certified baseline', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
mkdirSync(dirname(getMarkerPath()), { recursive: true })
|
||||
writeFileSync(
|
||||
getMarkerPath(),
|
||||
`${JSON.stringify({
|
||||
version: 3,
|
||||
systemSessionsRoot: getSystemSessionsRoot(),
|
||||
completedAt: Date.now(),
|
||||
summary: { scannedFiles: 1 }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect(await startCodexSessionBackfillInBackground()).toBeNull()
|
||||
expect(existsSync(join(getSystemSessionsRoot(), '2026', '05', '26', 'rollout-a.jsonl'))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
markLaunchPending()
|
||||
const bounded = await startCodexSessionBackfillInBackground()
|
||||
expect(bounded).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
})
|
||||
|
||||
it('does not let a bounded pass certify history no baseline ever covered', async () => {
|
||||
const missedRelativePath = join('2026', '06', '01', 'rollout-missed.jsonl')
|
||||
writeManagedSession(missedRelativePath, 'missed\n')
|
||||
@@ -415,40 +278,6 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(readMarker()).toMatchObject({ version: 4, coverage: 'full' })
|
||||
})
|
||||
|
||||
it('recovers a bounded window after an abnormal exit instead of a full walk', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-baseline.jsonl'), 'baseline\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
markLaunchPending(['2026', '08', '05'])
|
||||
writeManagedSession(join('2026', '06', '01', 'rollout-untouched.jsonl'), 'untouched\n')
|
||||
writeManagedSession(join('2026', '08', '05', 'rollout-launch.jsonl'), 'launch\n')
|
||||
|
||||
const recovered = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(recovered).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(
|
||||
existsSync(join(getSystemSessionsRoot(), '2026', '08', '05', 'rollout-launch.jsonl'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('records a new heal event when a linked rollout grows in place', async () => {
|
||||
const relativePath = join('2026', '05', '26', 'rollout-growing.jsonl')
|
||||
const managedPath = writeManagedSession(relativePath, '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const firstRecord = readBackfillAuditRecords().find((record) => record.action === 'hardlink')
|
||||
markLaunchPending()
|
||||
appendFileSync(managedPath, '{"event":"later"}\n', 'utf-8')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
|
||||
const fileRecords = readBackfillAuditRecords().filter((record) =>
|
||||
['hardlink', 'existing'].includes(record.action)
|
||||
)
|
||||
expect(fileRecords).toHaveLength(2)
|
||||
expect(fileRecords[1]).toMatchObject({ action: 'existing' })
|
||||
expect(fileRecords[1]?.fileEventId).not.toBe(firstRecord?.fileEventId)
|
||||
})
|
||||
|
||||
it('recovers a post-install audit interruption without duplicating prior events', async () => {
|
||||
const firstRelativePath = join('2026', '05', '26', 'rollout-a.jsonl')
|
||||
const secondRelativePath = join('2026', '05', '26', 'rollout-b.jsonl')
|
||||
@@ -490,22 +319,6 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('self-heals a zero-file marker when managed rollouts appear later', async () => {
|
||||
const empty = await startCodexSessionBackfillInBackground()
|
||||
expect(empty).toMatchObject({ scannedFiles: 0, linkedFiles: 0 })
|
||||
expect(JSON.parse(readFileSync(getMarkerPath(), 'utf-8'))).toMatchObject({
|
||||
summary: { scannedFiles: 0 }
|
||||
})
|
||||
|
||||
writeManagedSession(join('2026', '07', '28', 'rollout-later.jsonl'), '{"id":"later"}\n')
|
||||
const healed = await startCodexSessionBackfillInBackground()
|
||||
|
||||
expect(healed).toMatchObject({ scannedFiles: 1, linkedFiles: 1 })
|
||||
expect(
|
||||
existsSync(join(getSystemSessionsRoot(), '2026', '07', '28', 'rollout-later.jsonl'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers an installed rollout after the completion marker write fails', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
mkdirSync(getMarkerPath(), { recursive: true })
|
||||
@@ -557,18 +370,4 @@ describe('startCodexSessionBackfillInBackground', () => {
|
||||
expect(second).toMatchObject({ linkedFiles: 1, failedDirectories: 0 })
|
||||
expect(existsSync(getMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
it('re-runs when the configured real Codex home changes', async () => {
|
||||
writeManagedSession(join('2026', '05', '26', 'rollout-a.jsonl'), '{"id":"a"}\n')
|
||||
await startCodexSessionBackfillInBackground()
|
||||
const customHome = join(fakeHomeDir, 'custom Codex ü')
|
||||
|
||||
const moved = await startCodexSessionBackfillInBackground({}, customHome)
|
||||
|
||||
expect(moved).toMatchObject({ linkedFiles: 1, failedFiles: 0 })
|
||||
expect(existsSync(join(customHome, 'sessions', '2026', '05', '26', 'rollout-a.jsonl'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(await startCodexSessionBackfillInBackground({}, customHome)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CodexSessionBackfillOptions } from './codex-session-backfill-types'
|
||||
import { createCodexSessionMigrationScheduler } from './codex-session-migration-scheduler'
|
||||
|
||||
describe('createCodexSessionMigrationScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('publishes the baseline while a Codex pane is still open', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T10:00:00Z'))
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
|
||||
const options = startBackfill.mock.calls[0]?.[0]
|
||||
expect(options?.retainPendingScanDates).toBe(true)
|
||||
expect(options?.canWriteCompletionMarker?.()).toBe(true)
|
||||
expect(prepareScheduledRun).toHaveBeenCalledWith([['2026', '08', '05']])
|
||||
})
|
||||
|
||||
it('keeps launch passes full when no completed baseline can cover older failures', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun(true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('upgrades a bounded pass when its target changed before the timer fired', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => '/moved-history',
|
||||
prepareScheduledRun: () => true,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined }),
|
||||
'/moved-history'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a delayed launch rerun while an earlier migration is active', async () => {
|
||||
let releaseFirstIndexHeal: (() => void) | undefined
|
||||
const prepareScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const startIndexHeal = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirstIndexHeal = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun).not.toHaveBeenCalled()
|
||||
|
||||
releaseFirstIndexHeal?.()
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledTimes(2))
|
||||
expect(prepareScheduledRun).toHaveBeenCalledOnce()
|
||||
expect(prepareScheduledRun.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
startBackfill.mock.invocationCallOrder[1]!
|
||||
)
|
||||
})
|
||||
|
||||
it('coalesces concurrent run requests and stops before index heal after opt-out', async () => {
|
||||
let eligible = true
|
||||
let releaseBackfill: (() => void) | undefined
|
||||
const startBackfill = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseBackfill = resolve
|
||||
})
|
||||
)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => '/custom/history',
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
scheduler.requestRun()
|
||||
expect(startBackfill).toHaveBeenCalledOnce()
|
||||
expect(startBackfill).toHaveBeenCalledWith(expect.any(Object), '/custom/history')
|
||||
|
||||
eligible = false
|
||||
releaseBackfill?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(startIndexHeal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reruns after a stopping migration becomes eligible again', async () => {
|
||||
let eligible = true
|
||||
let releaseFirstBackfill: ((result: { stopped: boolean }) => void) | undefined
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
(_options) =>
|
||||
new Promise<{ stopped: boolean }>((resolve) => {
|
||||
releaseFirstBackfill = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ stopped: false })
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => eligible,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal
|
||||
})
|
||||
|
||||
scheduler.requestRun()
|
||||
const firstRunOptions = startBackfill.mock.calls[0]?.[0]
|
||||
eligible = false
|
||||
expect(firstRunOptions?.shouldStop()).toBe(true)
|
||||
eligible = true
|
||||
scheduler.requestRun()
|
||||
releaseFirstBackfill?.({ stopped: true })
|
||||
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('finishes a launch generation only after its PTY exits and every date is rescanned', async () => {
|
||||
vi.setSystemTime(new Date('2026-08-05T23:59:59Z'))
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun: vi.fn(),
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
expect(finishScheduledRun).not.toHaveBeenCalled()
|
||||
|
||||
vi.setSystemTime(new Date('2026-08-07T01:00:00Z'))
|
||||
scheduler.finishLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
scanDates: [
|
||||
['2026', '08', '05'],
|
||||
['2026', '08', '06'],
|
||||
['2026', '08', '07']
|
||||
],
|
||||
ignoreCompletionMarker: true,
|
||||
retainPendingScanDates: false,
|
||||
fullScanRequired: false
|
||||
}),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('keeps a failed full scan required for the final launch pass', async () => {
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ stopped: false, failedFiles: 1 })
|
||||
.mockResolvedValueOnce({ stopped: false, failedFiles: 0 })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
prepareScheduledRun: () => false,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.beginLaunch('pty-1', true)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledOnce())
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('pty-1')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.waitFor(() => expect(startBackfill).toHaveBeenCalledTimes(2))
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('blocks marker publication when a newer launch pass is pending', async () => {
|
||||
let releaseBackfill: ((result: { stopped: boolean }) => void) | undefined
|
||||
const startBackfill = vi.fn(
|
||||
(_options: CodexSessionBackfillOptions) =>
|
||||
new Promise<{ stopped: boolean }>((resolve) => {
|
||||
releaseBackfill = resolve
|
||||
})
|
||||
)
|
||||
const startIndexHeal = vi.fn().mockResolvedValue(null)
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal,
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.scheduleRun()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
const firstOptions = startBackfill.mock.calls[0]?.[0]
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(true)
|
||||
|
||||
scheduler.beginLaunch('pty-2')
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
|
||||
scheduler.finishLaunch('pty-2')
|
||||
expect(firstOptions?.canWriteCompletionMarker?.()).toBe(false)
|
||||
|
||||
releaseBackfill?.({ stopped: false })
|
||||
await vi.waitFor(() => expect(startIndexHeal).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('turns an exit-before-begin race into a full recovery pass', async () => {
|
||||
const finishScheduledRun = vi.fn()
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
finishScheduledRun,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.finishLaunch('pty-fast-exit', 2)
|
||||
scheduler.beginLaunch('pty-fast-exit', false, new Date(), 1)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scanDates: undefined, fullScanRequired: true }),
|
||||
undefined
|
||||
)
|
||||
await vi.waitFor(() => expect(finishScheduledRun).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('keeps a newer same-ID launch active while the ignored incarnation exits', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.ignoreLaunch('stable-pty', 1)
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
|
||||
scheduler.finishLaunch('stable-pty', 3)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 4)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('releases a stranded active launch once the ignored incarnation ages out', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.ignoreLaunch('stable-pty', 1)
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
|
||||
await vi.advanceTimersByTimeAsync(61_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 3)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(startBackfill).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: false }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('does not consume an exit from an earlier stable-id incarnation', async () => {
|
||||
const startBackfill = vi.fn().mockResolvedValue({ stopped: false })
|
||||
const scheduler = createCodexSessionMigrationScheduler({
|
||||
isEligible: () => true,
|
||||
isQuitting: () => false,
|
||||
resolveSystemCodexHomePathOverride: () => undefined,
|
||||
startBackfill,
|
||||
startIndexHeal: vi.fn().mockResolvedValue(null),
|
||||
initialDelayMs: 1_000
|
||||
})
|
||||
|
||||
scheduler.finishLaunch('stable-pty', 1)
|
||||
scheduler.beginLaunch('stable-pty', false, new Date(), 2)
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(startBackfill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ retainPendingScanDates: true }),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
@@ -17,7 +9,6 @@ import {
|
||||
stopCodexAccountSessionBridges
|
||||
} from './codex-account-session-bridge'
|
||||
import { writeCodexStateDbBackfillStatus } from '../index/codex-state-db-test-fixture'
|
||||
import SyncDatabase from '../../../sqlite/sync-database'
|
||||
|
||||
let workspaceRoot: string
|
||||
const healIndexStub = vi.fn(async () => ({
|
||||
@@ -37,8 +28,6 @@ function writeRollout(homePath: string, relativePath: string, contents: string):
|
||||
function rolloutPath(homePath: string, relativePath: string): string {
|
||||
return join(homePath, 'sessions', relativePath)
|
||||
}
|
||||
|
||||
const THREAD_A = '019a0000-0000-7000-8000-00000000000a'
|
||||
const THREAD_B = '019a0000-0000-7000-8000-00000000000b'
|
||||
const ROLLOUT_A = join(
|
||||
'2026',
|
||||
@@ -64,101 +53,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('bridgeCodexSessionsIntoAccountHome', () => {
|
||||
it('links every source home rollout into the target account home', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const otherAccountHome = join(workspaceRoot, 'account-a')
|
||||
const targetHome = join(workspaceRoot, 'account-b')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'system session\n')
|
||||
writeRollout(otherAccountHome, ROLLOUT_B, 'account a session\n')
|
||||
|
||||
const summary = await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [systemHome, otherAccountHome],
|
||||
options: { batchSize: 1, yieldMs: 0 }
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 2,
|
||||
linkedFiles: 2,
|
||||
skippedFiles: 0,
|
||||
bridgedThreads: new Map([
|
||||
[THREAD_A, '2026-07-20T10-00-00'],
|
||||
[THREAD_B, '2026-07-21T10-00-00']
|
||||
])
|
||||
})
|
||||
expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('system session\n')
|
||||
expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('account a session\n')
|
||||
})
|
||||
|
||||
it('shares one physical log so an appended resume is visible from both homes', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
const sourcePath = writeRollout(systemHome, ROLLOUT_A, 'first\n')
|
||||
|
||||
await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [systemHome]
|
||||
})
|
||||
|
||||
const targetPath = rolloutPath(targetHome, ROLLOUT_A)
|
||||
// Appends under either home must update the same log.
|
||||
expect(statSync(targetPath).ino).toBe(statSync(sourcePath).ino)
|
||||
})
|
||||
|
||||
it('bridges compressed rollouts so archived history still resumes', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
const compressed = join('2026', '07', '19', 'rollout-2026-07-19T10-00-00-cccc.jsonl.zst')
|
||||
writeRollout(systemHome, compressed, 'compressed\n')
|
||||
|
||||
const summary = await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [systemHome]
|
||||
})
|
||||
|
||||
expect(summary.linkedFiles).toBe(1)
|
||||
expect(readFileSync(rolloutPath(targetHome, compressed), 'utf-8')).toBe('compressed\n')
|
||||
})
|
||||
|
||||
it('leaves an already-bridged rollout untouched on a later launch', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'first\n')
|
||||
await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [systemHome]
|
||||
})
|
||||
|
||||
const second = await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [systemHome]
|
||||
})
|
||||
|
||||
expect(second).toEqual({
|
||||
scannedFiles: 1,
|
||||
linkedFiles: 0,
|
||||
skippedFiles: 0,
|
||||
bridgedThreads: new Map([[THREAD_A, '2026-07-20T10-00-00']])
|
||||
})
|
||||
})
|
||||
|
||||
it('never links a home into itself or scans a duplicate source twice', async () => {
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(targetHome, ROLLOUT_A, 'own session\n')
|
||||
|
||||
const summary = await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [targetHome, targetHome]
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 0,
|
||||
linkedFiles: 0,
|
||||
skippedFiles: 0,
|
||||
bridgedThreads: new Map()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hand a rollout it failed to link to the index heal', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
@@ -178,21 +72,6 @@ describe('bridgeCodexSessionsIntoAccountHome', () => {
|
||||
expect(summary.linkedFiles).toBe(1)
|
||||
expect(summary.bridgedThreads).toEqual(new Map([[THREAD_B, '2026-07-21T10-00-00']]))
|
||||
})
|
||||
|
||||
it('skips a source home that has no sessions tree', async () => {
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
const summary = await bridgeCodexSessionsIntoAccountHome({
|
||||
targetCodexHomePath: targetHome,
|
||||
sourceCodexHomePaths: [join(workspaceRoot, 'missing')]
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 0,
|
||||
linkedFiles: 0,
|
||||
skippedFiles: 0,
|
||||
bridgedThreads: new Map()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type BridgeDependencies = NonNullable<
|
||||
@@ -212,122 +91,7 @@ function startBridge(
|
||||
}
|
||||
|
||||
describe('startCodexAccountSessionBridgeInBackground', () => {
|
||||
it('shares one in-flight task per target home', () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
writeCodexStateDbBackfillStatus(targetHome, 'complete')
|
||||
|
||||
const first = startBridge(targetHome, [systemHome])
|
||||
const second = startBridge(targetHome, [systemHome])
|
||||
|
||||
expect(second).toBe(first)
|
||||
return first
|
||||
})
|
||||
|
||||
it('runs separate targets independently', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const firstTarget = join(workspaceRoot, 'account-a')
|
||||
const secondTarget = join(workspaceRoot, 'account-b')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
writeCodexStateDbBackfillStatus(firstTarget, 'complete')
|
||||
writeCodexStateDbBackfillStatus(secondTarget, 'complete')
|
||||
|
||||
await Promise.all([
|
||||
startBridge(firstTarget, [systemHome]),
|
||||
startBridge(secondTarget, [systemHome])
|
||||
])
|
||||
|
||||
expect(readFileSync(rolloutPath(firstTarget, ROLLOUT_A), 'utf-8')).toBe('session\n')
|
||||
expect(readFileSync(rolloutPath(secondTarget, ROLLOUT_A), 'utf-8')).toBe('session\n')
|
||||
})
|
||||
|
||||
it('lets Codex index a new home before linking history into it', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
const createStateDb = vi.fn(async (home: string) => {
|
||||
expect(existsSync(rolloutPath(home, ROLLOUT_A))).toBe(false)
|
||||
writeCodexStateDbBackfillStatus(home, 'complete')
|
||||
return true
|
||||
})
|
||||
|
||||
await startBridge(targetHome, [systemHome], { createStateDb })
|
||||
|
||||
expect(createStateDb).toHaveBeenCalledWith(targetHome)
|
||||
expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('session\n')
|
||||
})
|
||||
|
||||
// Older CLIs and sqlite_home can leave the home without a DB across launches.
|
||||
it('keeps linking new history on later launches when no state DB lives in the home', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'first\n')
|
||||
const createStateDb = vi.fn(async () => true)
|
||||
|
||||
await startBridge(targetHome, [systemHome], { createStateDb })
|
||||
writeRollout(systemHome, ROLLOUT_B, 'second\n')
|
||||
await startBridge(targetHome, [systemHome], { createStateDb })
|
||||
|
||||
expect(readFileSync(rolloutPath(targetHome, ROLLOUT_A), 'utf-8')).toBe('first\n')
|
||||
expect(readFileSync(rolloutPath(targetHome, ROLLOUT_B), 'utf-8')).toBe('second\n')
|
||||
expect(createStateDb).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start Codex on an empty home when no source has history', async () => {
|
||||
const createStateDb = vi.fn(async () => true)
|
||||
|
||||
await startBridge(join(workspaceRoot, 'account'), [join(workspaceRoot, 'system')], {
|
||||
createStateDb
|
||||
})
|
||||
|
||||
expect(createStateDb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not link history when Codex could not create the state DB', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
|
||||
await startBridge(targetHome, [systemHome], { createStateDb: async () => false })
|
||||
|
||||
expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false)
|
||||
expect(healIndexStub).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bridges compressed-only history into a new home and indexes it', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
const compressed = `${ROLLOUT_A}.zst`
|
||||
writeRollout(systemHome, compressed, 'compressed\n')
|
||||
const createStateDb = vi.fn(async (home: string) => {
|
||||
writeCodexStateDbBackfillStatus(home, 'complete')
|
||||
return true
|
||||
})
|
||||
|
||||
await startBridge(targetHome, [systemHome], { createStateDb })
|
||||
|
||||
expect(readFileSync(rolloutPath(targetHome, compressed), 'utf-8')).toBe('compressed\n')
|
||||
expect(healIndexStub).toHaveBeenCalledWith(
|
||||
targetHome,
|
||||
new Map([[THREAD_A, '2026-07-20T10-00-00']]),
|
||||
{
|
||||
shouldStop: expect.any(Function)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('skips linking into a state DB that predates backfill tracking', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
mkdirSync(targetHome, { recursive: true })
|
||||
new SyncDatabase(join(targetHome, 'state_5.sqlite')).close()
|
||||
|
||||
await startBridge(targetHome, [systemHome])
|
||||
|
||||
expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false)
|
||||
})
|
||||
|
||||
it('skips linking while Codex is still indexing the home', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
@@ -359,23 +123,6 @@ describe('startCodexAccountSessionBridgeInBackground', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('indexes the bridged threads once linking finishes', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
writeCodexStateDbBackfillStatus(targetHome, 'complete')
|
||||
|
||||
await startBridge(targetHome, [systemHome])
|
||||
|
||||
expect(healIndexStub).toHaveBeenCalledWith(
|
||||
targetHome,
|
||||
new Map([[THREAD_A, '2026-07-20T10-00-00']]),
|
||||
{
|
||||
shouldStop: expect.any(Function)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('links nothing when the app quits while Codex creates the state DB', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
@@ -392,37 +139,4 @@ describe('startCodexAccountSessionBridgeInBackground', () => {
|
||||
expect(existsSync(rolloutPath(targetHome, ROLLOUT_A))).toBe(false)
|
||||
expect(healIndexStub).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start Codex on a new home after the app quits', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
const createStateDb = vi.fn(async () => true)
|
||||
stopCodexAccountSessionBridges()
|
||||
|
||||
await startBridge(join(workspaceRoot, 'account'), [systemHome], { createStateDb })
|
||||
|
||||
expect(createStateDb).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops indexing and starts no new bridge once the app quits', async () => {
|
||||
const systemHome = join(workspaceRoot, 'system')
|
||||
const targetHome = join(workspaceRoot, 'account')
|
||||
const laterTarget = join(workspaceRoot, 'later-account')
|
||||
writeRollout(systemHome, ROLLOUT_A, 'session\n')
|
||||
writeCodexStateDbBackfillStatus(targetHome, 'complete')
|
||||
writeCodexStateDbBackfillStatus(laterTarget, 'complete')
|
||||
let shouldStop: (() => boolean) | undefined
|
||||
|
||||
await startBridge(targetHome, [systemHome], {
|
||||
healIndex: async (_home, _threads, options) => {
|
||||
shouldStop = options?.shouldStop
|
||||
stopCodexAccountSessionBridges()
|
||||
return { outcome: 'stopped', healedThreads: 0, missingThreads: 0, failedThreads: 0 }
|
||||
}
|
||||
})
|
||||
await startBridge(laterTarget, [systemHome])
|
||||
|
||||
expect(shouldStop?.()).toBe(true)
|
||||
expect(existsSync(rolloutPath(laterTarget, ROLLOUT_A))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import type * as NodeOs from 'node:os'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -102,11 +92,7 @@ vi.mock('node:os', async () => {
|
||||
homedir: homedirMock
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
syncSystemCodexSessionsIntoManagedHome,
|
||||
syncSystemCodexSessionsIntoManagedHomeIncrementally
|
||||
} from './codex-session-bridge'
|
||||
import { syncSystemCodexSessionsIntoManagedHome } from './codex-session-bridge'
|
||||
|
||||
let fakeHomeDir: string
|
||||
let userDataDir: string
|
||||
@@ -120,20 +106,6 @@ function getRuntimeCodexHomePath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home')
|
||||
}
|
||||
|
||||
function normalizeLinkTarget(linkTarget: string): string {
|
||||
return process.platform === 'win32'
|
||||
? linkTarget.replace(/^\\\\\?\\/, '').toLowerCase()
|
||||
: linkTarget
|
||||
}
|
||||
|
||||
function expectResourceLinked(targetPath: string, sourcePath: string): void {
|
||||
if (lstatSync(targetPath).isSymbolicLink()) {
|
||||
expect(normalizeLinkTarget(readlinkSync(targetPath))).toBe(normalizeLinkTarget(sourcePath))
|
||||
return
|
||||
}
|
||||
expect(lstatSync(targetPath).ino).toBe(lstatSync(sourcePath).ino)
|
||||
}
|
||||
|
||||
function writeLegacyCopyMarker(relativePath: string, sourcePath: string, targetPath: string): void {
|
||||
const sourceStat = lstatSync(sourcePath)
|
||||
const targetStat = lstatSync(targetPath)
|
||||
@@ -180,112 +152,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('syncSystemCodexSessionsIntoManagedHome', () => {
|
||||
it('bridges system Codex session jsonl files into the managed runtime home', () => {
|
||||
const systemSessionPath = join(
|
||||
getSystemCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-old.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(systemSessionPath), { recursive: true })
|
||||
writeFileSync(systemSessionPath, '{"type":"session_meta","id":"old"}\n', 'utf-8')
|
||||
writeFileSync(
|
||||
join(getSystemCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt'),
|
||||
'not a session\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome()
|
||||
|
||||
const runtimeSessionPath = join(
|
||||
getRuntimeCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-old.jsonl'
|
||||
)
|
||||
expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"type":"session_meta","id":"old"}\n')
|
||||
expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false)
|
||||
expectResourceLinked(runtimeSessionPath, systemSessionPath)
|
||||
expect(
|
||||
existsSync(join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'scratch.txt'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('bridges from a custom source home override instead of ~/.codex', () => {
|
||||
const customSourceHome = join(fakeHomeDir, 'custom-codex')
|
||||
const customSessionPath = join(
|
||||
customSourceHome,
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-custom.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(customSessionPath), { recursive: true })
|
||||
writeFileSync(customSessionPath, '{"id":"custom"}\n', 'utf-8')
|
||||
|
||||
const defaultSessionPath = join(
|
||||
getSystemCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-default.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(defaultSessionPath), { recursive: true })
|
||||
writeFileSync(defaultSessionPath, '{"id":"default"}\n', 'utf-8')
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome(customSourceHome)
|
||||
|
||||
const bridgedCustomPath = join(
|
||||
getRuntimeCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-custom.jsonl'
|
||||
)
|
||||
expect(readFileSync(bridgedCustomPath, 'utf-8')).toBe('{"id":"custom"}\n')
|
||||
expect(
|
||||
existsSync(
|
||||
join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-default.jsonl')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to symlinks when hardlinks are unavailable', () => {
|
||||
fsMockState.failLink = true
|
||||
const systemSessionPath = join(
|
||||
getSystemCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-symlink-fallback.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(systemSessionPath), { recursive: true })
|
||||
writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8')
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome()
|
||||
|
||||
const runtimeSessionPath = join(
|
||||
getRuntimeCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-symlink-fallback.jsonl'
|
||||
)
|
||||
expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(true)
|
||||
expect(normalizeLinkTarget(readlinkSync(runtimeSessionPath))).toBe(
|
||||
normalizeLinkTarget(systemSessionPath)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not overwrite runtime-owned session files', () => {
|
||||
const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-conflict.jsonl')
|
||||
const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath)
|
||||
@@ -300,64 +166,6 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => {
|
||||
expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"runtime"}\n')
|
||||
})
|
||||
|
||||
it('replaces existing symlink bridges with hardlinks', () => {
|
||||
const relativeSessionPath = join('sessions', '2026', '05', '26', 'rollout-symlink.jsonl')
|
||||
const systemSessionPath = join(getSystemCodexHomePath(), relativeSessionPath)
|
||||
const runtimeSessionPath = join(getRuntimeCodexHomePath(), relativeSessionPath)
|
||||
mkdirSync(dirname(systemSessionPath), { recursive: true })
|
||||
mkdirSync(dirname(runtimeSessionPath), { recursive: true })
|
||||
writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8')
|
||||
symlinkSync(
|
||||
systemSessionPath,
|
||||
runtimeSessionPath,
|
||||
process.platform === 'win32' ? 'file' : undefined
|
||||
)
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome()
|
||||
|
||||
expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false)
|
||||
expectResourceLinked(runtimeSessionPath, systemSessionPath)
|
||||
})
|
||||
|
||||
it('does not create independent session copies when file links are unavailable', () => {
|
||||
fsMockState.failLink = true
|
||||
fsMockState.failSymlink = true
|
||||
const systemSessionPath = join(
|
||||
getSystemCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'05',
|
||||
'26',
|
||||
'rollout-unlinked.jsonl'
|
||||
)
|
||||
mkdirSync(dirname(systemSessionPath), { recursive: true })
|
||||
writeFileSync(systemSessionPath, '{"id":"system"}\n', 'utf-8')
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome()
|
||||
|
||||
expect(
|
||||
existsSync(
|
||||
join(getRuntimeCodexHomePath(), 'sessions', '2026', '05', '26', 'rollout-unlinked.jsonl')
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces unchanged legacy copied sessions with links', () => {
|
||||
const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy.jsonl')
|
||||
const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath)
|
||||
const runtimeSessionPath = join(getRuntimeCodexHomePath(), 'sessions', relativeSessionPath)
|
||||
mkdirSync(dirname(systemSessionPath), { recursive: true })
|
||||
mkdirSync(dirname(runtimeSessionPath), { recursive: true })
|
||||
writeFileSync(systemSessionPath, '{"id":"legacy"}\n', 'utf-8')
|
||||
writeFileSync(runtimeSessionPath, '{"id":"legacy"}\n', 'utf-8')
|
||||
writeLegacyCopyMarker(relativeSessionPath, systemSessionPath, runtimeSessionPath)
|
||||
|
||||
syncSystemCodexSessionsIntoManagedHome()
|
||||
|
||||
expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n')
|
||||
expectResourceLinked(runtimeSessionPath, systemSessionPath)
|
||||
})
|
||||
|
||||
it('preserves unchanged legacy copied sessions when relinking fails', () => {
|
||||
const relativeSessionPath = join('2026', '05', '26', 'rollout-legacy-unlinked.jsonl')
|
||||
const systemSessionPath = join(getSystemCodexHomePath(), 'sessions', relativeSessionPath)
|
||||
@@ -375,35 +183,4 @@ describe('syncSystemCodexSessionsIntoManagedHome', () => {
|
||||
expect(lstatSync(runtimeSessionPath).isSymbolicLink()).toBe(false)
|
||||
expect(readFileSync(runtimeSessionPath, 'utf-8')).toBe('{"id":"legacy"}\n')
|
||||
})
|
||||
|
||||
it('incrementally bridges session files without requiring the synchronous launch path', async () => {
|
||||
const systemSessionRoot = join(getSystemCodexHomePath(), 'sessions', '2026', '06', '18')
|
||||
mkdirSync(systemSessionRoot, { recursive: true })
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
writeFileSync(
|
||||
join(systemSessionRoot, `rollout-incremental-${index}.jsonl`),
|
||||
`{"id":"incremental-${index}"}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
const summary = await syncSystemCodexSessionsIntoManagedHomeIncrementally({
|
||||
batchSize: 2,
|
||||
yieldMs: 0
|
||||
})
|
||||
|
||||
expect(summary).toEqual({ scannedFiles: 5, linkedFiles: 5 })
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const systemSessionPath = join(systemSessionRoot, `rollout-incremental-${index}.jsonl`)
|
||||
const runtimeSessionPath = join(
|
||||
getRuntimeCodexHomePath(),
|
||||
'sessions',
|
||||
'2026',
|
||||
'06',
|
||||
'18',
|
||||
`rollout-incremental-${index}.jsonl`
|
||||
)
|
||||
expectResourceLinked(runtimeSessionPath, systemSessionPath)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WslResult, WslSpec } from '../../../wsl/execution/wsl-runner'
|
||||
import type {
|
||||
createCodexAccountStateDb,
|
||||
healPendingCodexAccountThreads
|
||||
} from '../index/codex-account-session-index-heal'
|
||||
import type { CodexAppServerInvocation } from '../../app-server/codex-app-server-session'
|
||||
|
||||
const { runWslProcessMock, runCodexAppServerSessionMock, stopping } = vi.hoisted(() => ({
|
||||
runWslProcessMock: vi.fn<(spec: WslSpec) => Promise<WslResult>>(),
|
||||
runCodexAppServerSessionMock: vi.fn(async () => {}),
|
||||
stopping: { value: false }
|
||||
}))
|
||||
vi.mock('../../app-server/codex-app-server-session', () => ({
|
||||
runCodexAppServerSession: runCodexAppServerSessionMock
|
||||
}))
|
||||
|
||||
// The E9 running check has its own cases; these cover the chain once the distro runs.
|
||||
vi.mock('../../../wsl/filesystem/wsl-unc-access-gate', () => ({
|
||||
assertWslUncPathRunning: async () => {}
|
||||
}))
|
||||
vi.mock('../../../wsl/execution/wsl-runner', () => ({
|
||||
runWslProcess: runWslProcessMock
|
||||
}))
|
||||
|
||||
vi.mock('./codex-account-session-bridge', () => ({
|
||||
isCodexAccountSessionBridgeStopping: () => stopping.value
|
||||
}))
|
||||
|
||||
import { _internals, startWslCodexSessionBridgeInBackground } from './wsl-codex-session-bridge'
|
||||
import { noteWslDistroMayHaveStopped } from '../../../wsl/discovery/wsl-background-distro-gate'
|
||||
|
||||
const MANAGED_HOME =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-accounts\\a1\\home'
|
||||
const LINUX_HOME = '/home/alice/.local/share/orca/codex-accounts/a1/home'
|
||||
const TARGET = {
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath: MANAGED_HOME
|
||||
}
|
||||
const OLDER = 'rollout-2026-09-01T10-00-00-11111111-1111-4111-8111-111111111111.jsonl'
|
||||
const NEWER = 'rollout-2026-09-02T10-00-00-22222222-2222-4222-8222-222222222222.jsonl'
|
||||
|
||||
function bridgeResult(pending: string[], code = 0): WslResult {
|
||||
const summary = { scannedFiles: 2, linkedFiles: 2, pendingFiles: pending.length }
|
||||
return {
|
||||
environmentResolved: true,
|
||||
code,
|
||||
stdout: `${[...pending, JSON.stringify(summary)].join('\n')}\n`,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
}
|
||||
|
||||
const markerClearResult: WslResult = {
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runWslProcessMock.mockReset()
|
||||
stopping.value = false
|
||||
_internals.reset()
|
||||
})
|
||||
|
||||
describe('startWslCodexSessionBridgeInBackground', () => {
|
||||
it('completes the home index inside the distro before linking any history', async () => {
|
||||
const order: string[] = []
|
||||
const openInvocations: CodexAppServerInvocation[] = []
|
||||
const openStateDb = vi.fn<typeof createCodexAccountStateDb>(async (_home, options = {}) => {
|
||||
order.push('open')
|
||||
const invocation = options.buildInvocation?.(MANAGED_HOME, options.timeoutMs ?? 0)
|
||||
if (invocation) {
|
||||
openInvocations.push(invocation)
|
||||
}
|
||||
return true
|
||||
})
|
||||
runWslProcessMock.mockImplementation(async () => {
|
||||
order.push('link')
|
||||
return bridgeResult([])
|
||||
})
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, { openStateDb, healPending: vi.fn() })
|
||||
|
||||
expect(order).toEqual(['open', 'link'])
|
||||
expect(openInvocations).toHaveLength(1)
|
||||
expect(openInvocations[0]?.cliPath).toBeNull()
|
||||
const args = openInvocations[0]?.args ?? []
|
||||
expect(args.slice(0, 4)).toEqual(['-d', 'Ubuntu', '--exec', 'sh'])
|
||||
const guestCommand = args.at(-1) ?? ''
|
||||
expect(guestCommand).toContain(`CODEX_HOME=`)
|
||||
expect(guestCommand).toContain(LINUX_HOME)
|
||||
expect(guestCommand).toContain('features.plugins=false')
|
||||
expect(guestCommand).toContain('read-only')
|
||||
})
|
||||
|
||||
it('links nothing when Codex cannot bring the home index to complete', async () => {
|
||||
const healPending = vi.fn()
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => false),
|
||||
healPending
|
||||
})
|
||||
|
||||
expect(runWslProcessMock).not.toHaveBeenCalled()
|
||||
expect(healPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes a home once per run, since a complete index stays complete', async () => {
|
||||
const openStateDb = vi.fn(async () => true)
|
||||
runWslProcessMock.mockResolvedValue(bridgeResult([]))
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, { openStateDb, healPending: vi.fn() })
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, { openStateDb, healPending: vi.fn() })
|
||||
|
||||
expect(openStateDb).toHaveBeenCalledTimes(1)
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('indexes every pending marker through the guest codex and clears settled markers', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER, OLDER]))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(
|
||||
async (_home, _pending, options = {}) => {
|
||||
await options.afterBatch?.([
|
||||
{ threadId: '22222222-2222-4222-8222-222222222222', outcome: 'healed' },
|
||||
{ threadId: '11111111-1111-4111-8111-111111111111', outcome: 'failed' }
|
||||
])
|
||||
return {
|
||||
outcome: 'completed' as const,
|
||||
healedThreads: 1,
|
||||
missingThreads: 0,
|
||||
failedThreads: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending
|
||||
})
|
||||
|
||||
expect(healPending).toHaveBeenCalledTimes(1)
|
||||
const [home, pending, options] = healPending.mock.calls[0] ?? []
|
||||
expect(home).toBe(MANAGED_HOME)
|
||||
expect([...(pending ?? [])]).toEqual([
|
||||
['22222222-2222-4222-8222-222222222222', '2026-09-02T10-00-00'],
|
||||
['11111111-1111-4111-8111-111111111111', '2026-09-01T10-00-00']
|
||||
])
|
||||
const heal = options?.buildInvocation?.(MANAGED_HOME, 5_000)
|
||||
expect(heal?.args.at(-1)).toContain(LINUX_HOME)
|
||||
expect(heal?.timeoutMs).toBe(5_000)
|
||||
// A failed read keeps its marker, so it is retried after Orca restarts.
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([
|
||||
`${LINUX_HOME}/.orca-index-pending`,
|
||||
NEWER
|
||||
])
|
||||
})
|
||||
|
||||
it('still indexes reported markers when one rollout failed to link, then reports the failure', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
runWslProcessMock.mockResolvedValue(bridgeResult([NEWER], 1))
|
||||
const healPending = vi.fn(async () => ({
|
||||
outcome: 'completed' as const,
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}))
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending
|
||||
})
|
||||
|
||||
expect(healPending).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[codex-session-bridge] Background WSL session bridge failed:',
|
||||
expect.any(Error)
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('skips indexing when the marker list does not match its count', async () => {
|
||||
runWslProcessMock.mockResolvedValue({
|
||||
...bridgeResult([NEWER]),
|
||||
stdout: `${NEWER}\n${JSON.stringify({ scannedFiles: 2, linkedFiles: 2, pendingFiles: 2 })}\n`
|
||||
})
|
||||
const healPending = vi.fn()
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending
|
||||
})
|
||||
|
||||
expect(healPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('indexes the other markers and clears one whose name is not a Codex thread', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER, 'rollout-notes.jsonl']))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(async () => ({
|
||||
outcome: 'completed' as const,
|
||||
healedThreads: 1,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}))
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending
|
||||
})
|
||||
|
||||
expect([...(healPending.mock.calls[0]?.[1] ?? [])]).toEqual([
|
||||
['22222222-2222-4222-8222-222222222222', '2026-09-02T10-00-00']
|
||||
])
|
||||
// thread/read can never settle it, so it must not be reported on every launch.
|
||||
expect(runWslProcessMock.mock.calls[1]?.[0].args).toEqual([
|
||||
`${LINUX_HOME}/.orca-index-pending`,
|
||||
'rollout-notes.jsonl'
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing after quit starts', async () => {
|
||||
stopping.value = true
|
||||
const openStateDb = vi.fn(async () => true)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, { openStateDb, healPending: vi.fn() })
|
||||
|
||||
expect(openStateDb).not.toHaveBeenCalled()
|
||||
expect(runWslProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('links nothing in a distro that stopped after the launch that started the chain (E9)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const openStateDb = vi.fn(async () => true)
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb,
|
||||
healPending: vi.fn(),
|
||||
requireRunning: async () => {
|
||||
throw new Error("WSL distro 'Ubuntu' is not running.")
|
||||
}
|
||||
})
|
||||
|
||||
expect(openStateDb).not.toHaveBeenCalled()
|
||||
expect(runWslProcessMock).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('ends the index heal before its next batch once the distro stops (E9)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER]))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
let running = true
|
||||
let stoppedBeforeNextBatch: boolean | undefined
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(
|
||||
async (_home, _pending, options = {}) => {
|
||||
running = false
|
||||
await options
|
||||
.afterBatch?.([{ threadId: '22222222-2222-4222-8222-222222222222', outcome: 'healed' }])
|
||||
.catch(() => {})
|
||||
stoppedBeforeNextBatch = options.shouldStop?.()
|
||||
return {
|
||||
outcome: 'stopped' as const,
|
||||
healedThreads: 1,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending,
|
||||
requireRunning: async () => {
|
||||
if (!running) {
|
||||
throw new Error("WSL distro 'Ubuntu' is not running.")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(stoppedBeforeNextBatch).toBe(true)
|
||||
// Only the link step ran; the marker clear after the stop never spawned.
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('ends the index heal when a running-list probe sees the distro stop (E9)', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER]))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
let emitStop: (stopped: string[]) => void = () => {}
|
||||
let stopped: boolean | undefined
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(
|
||||
async (_home, _pending, options = {}) => {
|
||||
emitStop(['ubuntu'])
|
||||
stopped = options.shouldStop?.()
|
||||
return {
|
||||
outcome: 'stopped' as const,
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending,
|
||||
requireRunning: async () => {},
|
||||
subscribeStops: (listener) => {
|
||||
emitStop = (distros) => listener(distros, [])
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
|
||||
expect(stopped).toBe(true)
|
||||
})
|
||||
|
||||
it('re-checks the distro right before each heal batch spawns its guest codex (E9)', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER]))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
runCodexAppServerSessionMock.mockClear()
|
||||
let running = true
|
||||
let secondBatch: Promise<void> | undefined
|
||||
let stopped: boolean | undefined
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(
|
||||
async (_home, _pending, options = {}) => {
|
||||
const invocation = options.buildInvocation?.(MANAGED_HOME, 5_000)
|
||||
if (invocation) {
|
||||
await options.runSession?.(invocation, async () => {})
|
||||
// The user stops the distro during the inter-batch delay.
|
||||
running = false
|
||||
secondBatch = options.runSession?.(invocation, async () => {})
|
||||
await secondBatch?.catch(() => {})
|
||||
}
|
||||
stopped = options.shouldStop?.()
|
||||
return {
|
||||
outcome: 'stopped' as const,
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending,
|
||||
requireRunning: async () => {
|
||||
if (!running) {
|
||||
throw new Error("WSL distro 'Ubuntu' is not running.")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(runCodexAppServerSessionMock).toHaveBeenCalledTimes(1)
|
||||
await expect(secondBatch).rejects.toThrow('is not running')
|
||||
expect(stopped).toBe(true)
|
||||
})
|
||||
|
||||
it('ends the index heal on a may-have-stopped note no running list has seen yet (E9)', async () => {
|
||||
runWslProcessMock
|
||||
.mockResolvedValueOnce(bridgeResult([NEWER]))
|
||||
.mockResolvedValue(markerClearResult)
|
||||
let stopped: boolean | undefined
|
||||
const healPending = vi.fn<typeof healPendingCodexAccountThreads>(
|
||||
async (_home, _pending, options = {}) => {
|
||||
// e.g. the hook relay or a watcher into the distro exited.
|
||||
noteWslDistroMayHaveStopped('ubuntu')
|
||||
stopped = options.shouldStop?.()
|
||||
return {
|
||||
outcome: 'stopped' as const,
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await startWslCodexSessionBridgeInBackground(TARGET, {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending,
|
||||
requireRunning: async () => {}
|
||||
})
|
||||
|
||||
expect(stopped).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -15,207 +15,15 @@ vi.mock('../../../wsl/filesystem/wsl-unc-access-gate', () => ({
|
||||
vi.mock('../../../wsl/execution/wsl-runner', () => ({
|
||||
runWslProcess: runWslProcessMock
|
||||
}))
|
||||
|
||||
import {
|
||||
_internals,
|
||||
resolveWslCodexSessionBridgeLinuxPaths,
|
||||
startWslCodexSessionBridgeInBackground,
|
||||
syncWslCodexSessionsIntoManagedHome
|
||||
} from './wsl-codex-session-bridge'
|
||||
import { _internals } from './wsl-codex-session-bridge'
|
||||
import { buildWslCodexSessionBridgeShellCommand } from './wsl-codex-session-bridge-script'
|
||||
|
||||
function mockRunWslProcessSuccess(stdout = '{"scannedFiles":2,"linkedFiles":1}\n'): void {
|
||||
runWslProcessMock.mockResolvedValue({
|
||||
environmentResolved: true,
|
||||
code: 0,
|
||||
stdout,
|
||||
stderr: '',
|
||||
timedOut: false
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runWslProcessMock.mockReset()
|
||||
_internals.reset()
|
||||
})
|
||||
|
||||
describe('syncWslCodexSessionsIntoManagedHome', () => {
|
||||
it('runs a WSL hardlink bridge from the WSL system sessions into the managed home', async () => {
|
||||
mockRunWslProcessSuccess()
|
||||
|
||||
const summary = await syncWslCodexSessionsIntoManagedHome({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 2,
|
||||
linkedFiles: 1,
|
||||
pendingRollouts: [],
|
||||
bridgeFailed: false
|
||||
})
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
const spec = runWslProcessMock.mock.calls[0]?.[0] as {
|
||||
distro: string
|
||||
loginPath: string
|
||||
shell?: string
|
||||
script: string
|
||||
timeoutMs: number
|
||||
}
|
||||
expect(spec.distro).toBe('Ubuntu')
|
||||
expect(spec.loginPath).toBe('none')
|
||||
expect(spec.shell).toBe('bash')
|
||||
expect(spec.timeoutMs).toBe(30_000)
|
||||
expect(runWslProcessMock.mock.calls[0]?.[0]).toHaveProperty('maxOutputBytes', 64 * 1024 * 1024)
|
||||
expect(spec.script).toContain(
|
||||
"index_pending_root='/home/alice/.local/share/orca/codex-runtime-home/home/.orca-index-pending'"
|
||||
)
|
||||
|
||||
const shellCommand = spec.script
|
||||
expect(shellCommand).toContain("source_sessions_root='/home/alice/.codex/sessions'")
|
||||
expect(shellCommand).toContain(
|
||||
"managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'"
|
||||
)
|
||||
expect(shellCommand).toContain(`find "$source_sessions_root" -type f -name '*.jsonl' -print0`)
|
||||
expect(shellCommand).toContain('ln -- "$link_source" "$target_file"')
|
||||
expect(shellCommand).toContain('if [ -e "$target_file" ] || [ -L "$target_file" ]; then')
|
||||
expect(shellCommand).not.toContain('ln -s')
|
||||
expect(shellCommand).toContain('cp --')
|
||||
expect(shellCommand).not.toContain('sqlite')
|
||||
})
|
||||
|
||||
it('does not invoke WSL when paths are not resolvable inside the distro', async () => {
|
||||
const summary = await syncWslCodexSessionsIntoManagedHome({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: 'C:\\Users\\alice\\.codex',
|
||||
managedCodexHomePath: 'C:\\Users\\alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home'
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 0,
|
||||
linkedFiles: 0,
|
||||
pendingRollouts: [],
|
||||
bridgeFailed: false
|
||||
})
|
||||
expect(runWslProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('parses the summary after leading stdout noise', async () => {
|
||||
mockRunWslProcessSuccess(
|
||||
'Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n'
|
||||
)
|
||||
|
||||
const summary = await syncWslCodexSessionsIntoManagedHome({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
scannedFiles: 4,
|
||||
linkedFiles: 3,
|
||||
pendingRollouts: [],
|
||||
bridgeFailed: false
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on a non-zero exit so the background wrapper logs and swallows it', async () => {
|
||||
runWslProcessMock.mockResolvedValue({
|
||||
environmentResolved: true,
|
||||
code: 1,
|
||||
stdout: '',
|
||||
stderr: 'boom',
|
||||
timedOut: false
|
||||
})
|
||||
|
||||
await expect(
|
||||
syncWslCodexSessionsIntoManagedHome({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
})
|
||||
).rejects.toThrow('WSL codex session bridge failed')
|
||||
})
|
||||
|
||||
it('coalesces duplicate background bridges for the same WSL target', async () => {
|
||||
mockRunWslProcessSuccess()
|
||||
const target = {
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
}
|
||||
|
||||
const dependencies = {
|
||||
openStateDb: vi.fn(async () => true),
|
||||
healPending: vi.fn()
|
||||
}
|
||||
const firstTask = startWslCodexSessionBridgeInBackground(target, dependencies)
|
||||
const secondTask = startWslCodexSessionBridgeInBackground(target, dependencies)
|
||||
|
||||
expect(firstTask).toBe(secondTask)
|
||||
await firstTask
|
||||
expect(runWslProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWslCodexSessionBridgeLinuxPaths', () => {
|
||||
it('requires both homes to belong to the requested distro', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionBridgeLinuxPaths({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
managedCodexHomePath:
|
||||
'\\\\wsl.localhost\\Debian\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts Linux paths for direct script construction tests', () => {
|
||||
expect(
|
||||
resolveWslCodexSessionBridgeLinuxPaths({
|
||||
distro: 'Ubuntu',
|
||||
systemCodexHomePath: '/home/alice/.codex',
|
||||
managedCodexHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home'
|
||||
})
|
||||
).toEqual({
|
||||
systemSessionsRoot: '/home/alice/.codex/sessions',
|
||||
managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions',
|
||||
managedHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home',
|
||||
indexPendingRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/.orca-index-pending'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWslCodexSessionBridgeShellCommand', () => {
|
||||
it('only targets JSONL session files under sessions', () => {
|
||||
const shellCommand = buildWslCodexSessionBridgeShellCommand({
|
||||
systemSessionsRoot: "/home/alice/.codex/sessions with 'quote'",
|
||||
managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions'
|
||||
})
|
||||
|
||||
expect(shellCommand).toContain(
|
||||
`source_sessions_root='/home/alice/.codex/sessions with '\\''quote'\\'''`
|
||||
)
|
||||
expect(shellCommand).toContain(`-name '*.jsonl'`)
|
||||
expect(shellCommand).not.toContain('.sqlite')
|
||||
})
|
||||
|
||||
it('keeps Linux-side shell variable expansion intact for the guest shell', () => {
|
||||
const shellCommand = buildWslCodexSessionBridgeShellCommand({
|
||||
systemSessionsRoot: '/home/alice/.codex/sessions',
|
||||
managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions'
|
||||
})
|
||||
|
||||
expect(shellCommand).toContain('$source_sessions_root')
|
||||
expect(shellCommand).toContain('$source_file')
|
||||
expect(shellCommand).toContain('$((scanned_files + 1))')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'publishes a verified guest-side copy across filesystems',
|
||||
() => {
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
_internals,
|
||||
createCodexAccountStateDb,
|
||||
healCodexAccountSessionIndex,
|
||||
healPendingCodexAccountThreads
|
||||
} from './codex-account-session-index-heal'
|
||||
import {
|
||||
CodexAppServerUnsupportedError,
|
||||
type CodexAppServerInvocation,
|
||||
type CodexAppServerRpc
|
||||
} from '../../app-server/codex-app-server-session'
|
||||
|
||||
const HOME = '/codex-accounts/account-1/home'
|
||||
|
||||
// Why: fails closed if a regression bypasses the fake session — never the real codex or ~/.codex.
|
||||
function buildInvocation(): CodexAppServerInvocation {
|
||||
return {
|
||||
command: '/nonexistent/orca-test-codex',
|
||||
args: ['app-server'],
|
||||
cliPath: null,
|
||||
env: { CODEX_HOME: join(tmpdir(), 'orca-test-nonexistent-codex-home') },
|
||||
timeoutMs: 1_000
|
||||
}
|
||||
}
|
||||
|
||||
function fakeAppServer(onRead: (threadId: string) => void = () => {}) {
|
||||
const readThreadIds: string[] = []
|
||||
const runSession = vi.fn(
|
||||
async (
|
||||
_invocation: CodexAppServerInvocation,
|
||||
body: (rpc: CodexAppServerRpc) => Promise<void>
|
||||
): Promise<void> => {
|
||||
await body({
|
||||
request: async (method, params) => {
|
||||
expect(method).toBe('thread/read')
|
||||
const threadId = String(params?.threadId)
|
||||
readThreadIds.push(threadId)
|
||||
onRead(threadId)
|
||||
return {}
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
}
|
||||
)
|
||||
return { runSession, readThreadIds }
|
||||
}
|
||||
|
||||
function bridged(...threadIds: string[]): Map<string, string> {
|
||||
return new Map(threadIds.map((threadId) => [threadId, '2026-07-20T10-00-00']))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_internals.resetFailedThreads()
|
||||
})
|
||||
|
||||
describe('healCodexAccountSessionIndex', () => {
|
||||
it('reads only bridged threads missing from the Codex index', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer()
|
||||
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a', 'b', 'c'), {
|
||||
readIndexedThreadIds: () => new Set(['b']),
|
||||
buildInvocation,
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(readThreadIds.sort()).toEqual(['a', 'c'])
|
||||
expect(summary).toEqual({
|
||||
outcome: 'completed',
|
||||
healedThreads: 2,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the most recent bridged rollouts first', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer()
|
||||
|
||||
await healCodexAccountSessionIndex(
|
||||
HOME,
|
||||
new Map([
|
||||
['middle', '2026-08-01T09-00-00'],
|
||||
['oldest', '2025-12-31T23-59-59'],
|
||||
['newest', '2026-09-28T01-46-16']
|
||||
]),
|
||||
{ readIndexedThreadIds: () => new Set(), buildInvocation, runSession, readConcurrency: 1 }
|
||||
)
|
||||
|
||||
expect(readThreadIds).toEqual(['newest', 'middle', 'oldest'])
|
||||
})
|
||||
|
||||
it('does not start Codex when every bridged thread is already indexed', async () => {
|
||||
const { runSession } = fakeAppServer()
|
||||
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds: () => new Set(['a']),
|
||||
buildInvocation,
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(summary.outcome).toBe('up-to-date')
|
||||
expect(runSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start Codex when its index cannot be read', async () => {
|
||||
const { runSession } = fakeAppServer()
|
||||
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds: () => null,
|
||||
buildInvocation,
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(summary.outcome).toBe('no-index')
|
||||
expect(runSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not start Codex once the app is quitting', async () => {
|
||||
const { runSession } = fakeAppServer()
|
||||
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds: () => new Set(),
|
||||
buildInvocation,
|
||||
runSession,
|
||||
shouldStop: () => true
|
||||
})
|
||||
|
||||
expect(summary.outcome).toBe('stopped')
|
||||
expect(runSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops retrying a thread Codex refuses to index until Orca restarts', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer((threadId) => {
|
||||
if (threadId === 'broken') {
|
||||
throw new Error('codex app-server thread/read failed: invalid rollout')
|
||||
}
|
||||
})
|
||||
const dependencies = {
|
||||
readIndexedThreadIds: () => new Set<string>(),
|
||||
buildInvocation,
|
||||
runSession
|
||||
}
|
||||
|
||||
const first = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies)
|
||||
const second = await healCodexAccountSessionIndex(HOME, bridged('broken'), dependencies)
|
||||
|
||||
expect(first).toEqual({
|
||||
outcome: 'completed',
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 1
|
||||
})
|
||||
expect(second.outcome).toBe('up-to-date')
|
||||
expect(readThreadIds).toEqual(['broken'])
|
||||
})
|
||||
|
||||
it('remembers a refused thread only for the home that refused it', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer((threadId) => {
|
||||
if (readThreadIds.length === 1) {
|
||||
throw new Error(`codex app-server thread/read failed: invalid rollout ${threadId}`)
|
||||
}
|
||||
})
|
||||
const dependencies = {
|
||||
readIndexedThreadIds: () => new Set<string>(),
|
||||
buildInvocation,
|
||||
runSession
|
||||
}
|
||||
|
||||
await healCodexAccountSessionIndex(HOME, bridged('shared'), dependencies)
|
||||
const other = await healCodexAccountSessionIndex(
|
||||
'/codex-accounts/account-2/home',
|
||||
bridged('shared'),
|
||||
dependencies
|
||||
)
|
||||
|
||||
expect(other.healedThreads).toBe(1)
|
||||
expect(readThreadIds).toEqual(['shared', 'shared'])
|
||||
})
|
||||
|
||||
it('counts a rollout Codex cannot find as missing and does not reread it', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer(() => {
|
||||
throw new Error('codex app-server thread/read failed: no rollout found for thread id gone')
|
||||
})
|
||||
const options = { readIndexedThreadIds: () => new Set<string>(), buildInvocation, runSession }
|
||||
|
||||
const first = await healCodexAccountSessionIndex(HOME, bridged('gone'), options)
|
||||
const second = await healCodexAccountSessionIndex(HOME, bridged('gone'), options)
|
||||
|
||||
expect(first).toEqual({
|
||||
outcome: 'completed',
|
||||
healedThreads: 0,
|
||||
missingThreads: 1,
|
||||
failedThreads: 0
|
||||
})
|
||||
expect(second.outcome).toBe('up-to-date')
|
||||
expect(readThreadIds).toEqual(['gone'])
|
||||
})
|
||||
|
||||
it('retries a thread on the next pass when the app-server session fails', async () => {
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error('codex app-server exited before responding')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const readIndexedThreadIds = (): Set<string> => new Set()
|
||||
|
||||
const first = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds,
|
||||
buildInvocation,
|
||||
runSession: failing
|
||||
})
|
||||
const { runSession, readThreadIds } = fakeAppServer()
|
||||
const second = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds,
|
||||
buildInvocation,
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(first.outcome).toBe('aborted')
|
||||
expect(second.outcome).toBe('completed')
|
||||
expect(readThreadIds).toEqual(['a'])
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('reports a session that fails during quit as stopped, not unsupported', async () => {
|
||||
let stopping = false
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds: () => new Set(),
|
||||
buildInvocation,
|
||||
runSession: async () => {
|
||||
stopping = true
|
||||
throw new CodexAppServerUnsupportedError('app-server killed during quit')
|
||||
},
|
||||
shouldStop: () => stopping
|
||||
})
|
||||
|
||||
expect(summary.outcome).toBe('stopped')
|
||||
})
|
||||
|
||||
it('aborts rather than writing off a thread while a live Codex holds the database', async () => {
|
||||
const { runSession } = fakeAppServer(() => {
|
||||
throw new Error('codex app-server thread/read failed: database is locked')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const summary = await healCodexAccountSessionIndex(HOME, bridged('a'), {
|
||||
readIndexedThreadIds: () => new Set(),
|
||||
buildInvocation,
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(summary).toEqual({
|
||||
outcome: 'aborted',
|
||||
healedThreads: 0,
|
||||
missingThreads: 0,
|
||||
failedThreads: 0
|
||||
})
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCodexAccountStateDb', () => {
|
||||
it('starts Codex on the home without sending any request', async () => {
|
||||
const { runSession, readThreadIds } = fakeAppServer()
|
||||
const invocations: string[] = []
|
||||
|
||||
const created = await createCodexAccountStateDb(HOME, {
|
||||
buildInvocation: (home, timeoutMs) => {
|
||||
invocations.push(home)
|
||||
return { ...buildInvocation(), timeoutMs }
|
||||
},
|
||||
runSession
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(invocations).toEqual([HOME])
|
||||
expect(readThreadIds).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a Codex without app-server as having no state DB to stall', async () => {
|
||||
const created = await createCodexAccountStateDb(HOME, {
|
||||
buildInvocation,
|
||||
runSession: async () => {
|
||||
throw new CodexAppServerUnsupportedError('unknown subcommand app-server')
|
||||
}
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
})
|
||||
|
||||
it('reports failure when Codex could not start', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const created = await createCodexAccountStateDb(HOME, {
|
||||
buildInvocation,
|
||||
runSession: async () => {
|
||||
throw new Error('spawn codex ENOENT')
|
||||
}
|
||||
})
|
||||
|
||||
expect(created).toBe(false)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('healPendingCodexAccountThreads', () => {
|
||||
it('reports each batch it settled, newest rollout first', async () => {
|
||||
const { runSession } = fakeAppServer((threadId) => {
|
||||
if (threadId === 'old') {
|
||||
throw new Error('codex app-server thread/read failed: no rollout found for old')
|
||||
}
|
||||
})
|
||||
const batches: string[][] = []
|
||||
|
||||
const summary = await healPendingCodexAccountThreads(
|
||||
HOME,
|
||||
new Map([
|
||||
['old', '2026-07-01T10-00-00'],
|
||||
['new', '2026-07-20T10-00-00']
|
||||
]),
|
||||
{
|
||||
buildInvocation,
|
||||
runSession,
|
||||
readsPerServerSession: 1,
|
||||
interBatchDelayMs: 0,
|
||||
afterBatch: async (settled) => {
|
||||
batches.push(settled.map(({ threadId, outcome }) => `${threadId}:${outcome}`))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(batches).toEqual([['new:healed'], ['old:missing']])
|
||||
expect(summary).toMatchObject({ healedThreads: 1, missingThreads: 1 })
|
||||
})
|
||||
|
||||
it('keeps indexing when post-batch bookkeeping fails', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { runSession, readThreadIds } = fakeAppServer()
|
||||
|
||||
const summary = await healPendingCodexAccountThreads(HOME, bridged('a', 'b'), {
|
||||
buildInvocation,
|
||||
runSession,
|
||||
readsPerServerSession: 1,
|
||||
interBatchDelayMs: 0,
|
||||
afterBatch: async () => {
|
||||
throw new Error('guest unreachable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(readThreadIds.sort()).toEqual(['a', 'b'])
|
||||
expect(summary.outcome).toBe('completed')
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCodexAccountStateDb timeout', () => {
|
||||
it('passes a caller timeout to the invocation', async () => {
|
||||
const timeouts: number[] = []
|
||||
|
||||
await createCodexAccountStateDb(HOME, {
|
||||
buildInvocation: (_home, timeoutMs) => {
|
||||
timeouts.push(timeoutMs)
|
||||
return buildInvocation()
|
||||
},
|
||||
runSession: async () => {},
|
||||
timeoutMs: 900_000
|
||||
})
|
||||
|
||||
expect(timeouts).toEqual([900_000])
|
||||
})
|
||||
})
|
||||
@@ -227,28 +227,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(marker.healedThreads).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps second and later passes cheap when the audit ledger is unchanged', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
|
||||
})
|
||||
const first = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(first.outcome).toBe('completed')
|
||||
const second = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
const third = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(second.outcome).toBe('up-to-date')
|
||||
expect(third.outcome).toBe('up-to-date')
|
||||
expect(rig.readLog().serverStarts).toBe(1)
|
||||
})
|
||||
|
||||
it('re-reads a healed thread after a later publication event', async () => {
|
||||
const id = threadId('1')
|
||||
const stamp = '2026-07-01T10-00-00'
|
||||
@@ -271,31 +249,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(rig.readLog().threadIds).toEqual([id, id])
|
||||
})
|
||||
|
||||
it('resumes only unprocessed sessions when the audit ledger grows', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
|
||||
})
|
||||
await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
appendFileSync(
|
||||
rig.paths.auditLogPath,
|
||||
`${JSON.stringify({
|
||||
action: 'hardlink',
|
||||
target: rolloutTarget(rig.paths.systemSessionsRoot, '2026-07-04T10-00-00', threadId('4'))
|
||||
})}\n`
|
||||
)
|
||||
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 1, healedThreads: 1 })
|
||||
expect(rig.readLog().threadIds).toEqual([threadId('1'), threadId('4')])
|
||||
})
|
||||
|
||||
it('backs off failed sessions and retries them later while missing stays terminal', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [
|
||||
@@ -419,46 +372,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(rig.readLog().serverStarts).toBe(1)
|
||||
})
|
||||
|
||||
it('splits work into batches with one server session each and bounded concurrency', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: Array.from({ length: 5 }, (_, index) => ({
|
||||
stamp: `2026-07-0${index + 1}T10-00-00`,
|
||||
id: threadId(String(index + 1))
|
||||
}))
|
||||
})
|
||||
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
readsPerServerSession: 2,
|
||||
readConcurrency: 2,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 5 })
|
||||
const log = rig.readLog()
|
||||
expect(log.serverStarts).toBe(3)
|
||||
expect(log.maxInFlight).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('caps overrides at the production batch and concurrency limits', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: Array.from({ length: 51 }, (_, index) => ({
|
||||
stamp: `2026-07-${String(index + 1).padStart(2, '0')}T10-00-00`,
|
||||
id: threadId(String(index + 1))
|
||||
}))
|
||||
})
|
||||
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
readsPerServerSession: 1_000,
|
||||
readConcurrency: 1_000,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ outcome: 'completed', healedThreads: 51 })
|
||||
expect(rig.readLog()).toMatchObject({ serverStarts: 2, maxInFlight: 2 })
|
||||
})
|
||||
|
||||
it('stops promptly when shouldStop flips and resumes on the next pass', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: Array.from({ length: 4 }, (_, index) => ({
|
||||
@@ -503,27 +416,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(existsSync(rig.paths.healMarkerPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not spawn another server when stop flips during the inter-batch delay', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [
|
||||
{ stamp: '2026-07-02T10-00-00', id: threadId('2') },
|
||||
{ stamp: '2026-07-01T10-00-00', id: threadId('1') }
|
||||
]
|
||||
})
|
||||
let stopChecks = 0
|
||||
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
readsPerServerSession: 1,
|
||||
interBatchDelayMs: 1,
|
||||
// Opt out after the second batch's pre-delay check.
|
||||
shouldStop: () => stopChecks++ >= 3
|
||||
})
|
||||
|
||||
expect(summary).toMatchObject({ outcome: 'stopped', healedThreads: 1 })
|
||||
expect(rig.readLog()).toMatchObject({ serverStarts: 1, threadIds: [threadId('2')] })
|
||||
})
|
||||
|
||||
it('marks the pass unsupported without ledger writes when thread/read is unavailable', async () => {
|
||||
const rig = createHealRig({
|
||||
scenario: 'unknown-method',
|
||||
@@ -570,18 +462,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 })
|
||||
})
|
||||
|
||||
it('marks the pass unsupported when the CLI lacks the app-server subcommand', async () => {
|
||||
const rig = createHealRig({
|
||||
scenario: 'no-subcommand',
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }]
|
||||
})
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(summary.outcome).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('aborts without recording when the server dies mid-batch, then retries next pass', async () => {
|
||||
const rig = createHealRig({
|
||||
scenario: 'die-mid-batch',
|
||||
@@ -621,72 +501,6 @@ describe('runCodexSessionIndexHeal', () => {
|
||||
expect(retried.healedThreads).toBe(2)
|
||||
})
|
||||
|
||||
it('retries transient sqlite contention instead of marking the thread failed', async () => {
|
||||
const rig = createHealRig({
|
||||
auditedThreads: [{ stamp: '2026-07-01T10-00-00', id: threadId('1') }],
|
||||
busyThreadIds: [threadId('1')]
|
||||
})
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(summary.outcome).toBe('aborted')
|
||||
expect(readLedgerOutcomes(rig.paths)).toEqual({})
|
||||
|
||||
const retried = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: (home, timeoutMs) => {
|
||||
const invocation = rig.buildInvocation(home, timeoutMs)
|
||||
return {
|
||||
...invocation,
|
||||
env: {
|
||||
STUB_CONFIG: JSON.stringify({
|
||||
scenario: 'ok',
|
||||
readLogFile: rig.readLogFile,
|
||||
missingThreadIds: [],
|
||||
failingThreadIds: [],
|
||||
busyThreadIds: []
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(retried).toMatchObject({ outcome: 'completed', healedThreads: 1 })
|
||||
})
|
||||
|
||||
it('completes immediately with no server spawn when there is nothing to heal', async () => {
|
||||
const rig = createHealRig({ auditedThreads: [] })
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 0 })
|
||||
expect(rig.readLog().serverStarts).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores audit records outside the backfill link/copy actions', async () => {
|
||||
const rig = createHealRig({ auditedThreads: [] })
|
||||
appendFileSync(
|
||||
rig.paths.auditLogPath,
|
||||
`${[
|
||||
JSON.stringify({ action: 'run-summary', scannedFiles: 3 }),
|
||||
JSON.stringify({ action: 'scan-failed', source: '/managed/sessions/2026' }),
|
||||
JSON.stringify({
|
||||
action: 'failed',
|
||||
target: rolloutTarget(rig.paths.systemSessionsRoot, '2026-07-01T10-00-00', threadId('9'))
|
||||
}),
|
||||
'not-json',
|
||||
''
|
||||
].join('\n')}\n`
|
||||
)
|
||||
const summary = await runCodexSessionIndexHeal(rig.paths, {
|
||||
buildInvocation: rig.buildInvocation,
|
||||
interBatchDelayMs: 0
|
||||
})
|
||||
expect(summary).toMatchObject({ outcome: 'completed', pendingThreads: 0 })
|
||||
expect(rig.readLog().serverStarts).toBe(0)
|
||||
})
|
||||
|
||||
it('scopes audit and processed ledger records to the current Codex home', async () => {
|
||||
const currentId = threadId('1')
|
||||
const foreignId = threadId('2')
|
||||
|
||||
@@ -4,10 +4,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as ownerIdentity from '../../../agent-hooks/managed-hook-owner-identity'
|
||||
import { CODEX_READ_ONLY_APP_SERVER_ARGS } from '../../../codex-cli/codex-read-only-app-server-args'
|
||||
import {
|
||||
_internals,
|
||||
hasCodexStateDbBackfillRecoveryGivenUp,
|
||||
resolveCodexBackfillSupervisorLockRoot,
|
||||
runCodexStateDbBackfillRecovery,
|
||||
startCodexStateDbBackfillRecoveryInBackground,
|
||||
@@ -49,32 +47,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('Codex state DB backfill recovery', () => {
|
||||
it('does not restart an exhausted supervisor when later triggers arrive', async () => {
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const run = vi.fn(async () => ({ outcome: 'gave-up' as const, spawnCount: 5 }))
|
||||
const withLock = vi.fn(
|
||||
async (_home: string, _signal: AbortSignal | undefined, claim: () => Promise<unknown>) =>
|
||||
await claim()
|
||||
)
|
||||
const dependencies = {
|
||||
isPending: vi.fn(() => true),
|
||||
run,
|
||||
withLock: withLock as never
|
||||
}
|
||||
|
||||
const first = startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies)
|
||||
await expect(first).resolves.toEqual({ outcome: 'gave-up', spawnCount: 5 })
|
||||
const repeated = startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies)
|
||||
|
||||
expect(repeated).toBe(first)
|
||||
await expect(repeated).resolves.toEqual({ outcome: 'gave-up', spawnCount: 5 })
|
||||
expect(dependencies.isPending).toHaveBeenCalledTimes(1)
|
||||
expect(withLock).toHaveBeenCalledTimes(1)
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
expect(hasCodexStateDbBackfillRecoveryGivenUp('/managed-home')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not start a supervisor for an unreadable index', async () => {
|
||||
const home = await createTemporaryRoot()
|
||||
await writeFile(join(home, 'state_5.sqlite'), 'not a sqlite database')
|
||||
@@ -91,82 +63,6 @@ describe('Codex state DB backfill recovery', () => {
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases a completed supervisor entry', async () => {
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
const run = vi.fn(async () => ({ outcome: 'completed' as const, spawnCount: 1 }))
|
||||
const dependencies = {
|
||||
isPending: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
|
||||
run,
|
||||
withLock: vi.fn(
|
||||
async (_home: string, _signal: AbortSignal | undefined, claim: () => Promise<unknown>) =>
|
||||
await claim()
|
||||
) as never
|
||||
}
|
||||
|
||||
await expect(
|
||||
startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies)
|
||||
).resolves.toEqual({ outcome: 'completed', spawnCount: 1 })
|
||||
await expect(
|
||||
startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies)
|
||||
).resolves.toBeNull()
|
||||
|
||||
expect(dependencies.isPending).toHaveBeenCalledTimes(2)
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases a failed owner-lock attempt for later arbitration', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||
const dependencies = {
|
||||
isPending: vi.fn(() => true),
|
||||
run: vi.fn(async () => ({ outcome: 'completed' as const, spawnCount: 1 })),
|
||||
withLock: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('owner still live'))
|
||||
.mockImplementation(
|
||||
async (_home: string, _signal: AbortSignal | undefined, claim: () => Promise<unknown>) =>
|
||||
await claim()
|
||||
)
|
||||
}
|
||||
|
||||
await expect(
|
||||
startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies as never)
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
startCodexStateDbBackfillRecoveryInBackground('/managed-home', dependencies as never)
|
||||
).resolves.toEqual({ outcome: 'completed', spawnCount: 1 })
|
||||
|
||||
expect(dependencies.isPending).toHaveBeenCalledTimes(2)
|
||||
expect(dependencies.withLock).toHaveBeenCalledTimes(2)
|
||||
expect(dependencies.run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds permanent coordinator failures across later triggers', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const dependencies = {
|
||||
isPending: vi.fn(() => true),
|
||||
run: vi.fn(),
|
||||
withLock: vi.fn(async () => {
|
||||
throw new Error('permanent lock-root failure')
|
||||
})
|
||||
}
|
||||
const tasks: Promise<unknown>[] = []
|
||||
|
||||
for (let attempt = 0; attempt < 6; attempt += 1) {
|
||||
const task = startCodexStateDbBackfillRecoveryInBackground(
|
||||
'/managed-home',
|
||||
dependencies as never
|
||||
)
|
||||
tasks.push(task)
|
||||
await expect(task).resolves.toBeNull()
|
||||
}
|
||||
|
||||
expect(tasks[5]).toBe(tasks[4])
|
||||
expect(dependencies.isPending).toHaveBeenCalledTimes(5)
|
||||
expect(dependencies.withLock).toHaveBeenCalledTimes(5)
|
||||
expect(dependencies.run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds permanent claimant exits just beyond the fast-failure window', async () => {
|
||||
const controller = new AbortController()
|
||||
const children = new Map<ReturnType<typeof createFakeChild>, number>()
|
||||
@@ -247,255 +143,6 @@ describe('Codex state DB backfill recovery', () => {
|
||||
safetyFuseTripped: false
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers after claimant exits just beyond the old fast-failure window', async () => {
|
||||
const children = new Map<ReturnType<typeof createFakeChild>, number>()
|
||||
const timerDurations: number[] = []
|
||||
let now = 0
|
||||
let exitCount = 0
|
||||
let pendingTimers = 0
|
||||
let maxLiveChildren = 0
|
||||
const spawnProcess = vi.fn(() => {
|
||||
const child = createFakeChild()
|
||||
children.set(child, now + 10_001)
|
||||
maxLiveChildren = Math.max(maxLiveChildren, children.size)
|
||||
return child
|
||||
})
|
||||
const terminate = vi.fn(async (child: ReturnType<typeof createFakeChild>) => {
|
||||
children.delete(child)
|
||||
})
|
||||
const sleep = vi.fn(async (ms: number) => {
|
||||
timerDurations.push(ms)
|
||||
pendingTimers += 1
|
||||
const wakeAt = now + ms
|
||||
for (const [child, exitAt] of children) {
|
||||
if (exitAt <= wakeAt) {
|
||||
now = exitAt
|
||||
children.delete(child)
|
||||
exitCount += 1
|
||||
child.emit('exit', 1, null)
|
||||
}
|
||||
}
|
||||
now = wakeAt
|
||||
pendingTimers -= 1
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const summary = await runCodexStateDbBackfillRecovery(
|
||||
'/managed-home',
|
||||
new AbortController().signal,
|
||||
{
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnProcess.mock.calls.length >= 3
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: {
|
||||
kind: 'incomplete',
|
||||
stateDbPath: '/state.sqlite',
|
||||
status: 'running'
|
||||
}
|
||||
),
|
||||
terminate: terminate as never,
|
||||
sleep,
|
||||
now: () => now
|
||||
}
|
||||
)
|
||||
|
||||
expect({
|
||||
summary,
|
||||
spawnCount: spawnProcess.mock.calls.length,
|
||||
exitCount,
|
||||
timerCount: timerDurations.length,
|
||||
pollCount: timerDurations.filter((ms) => ms === 5_000).length,
|
||||
backoffCount: timerDurations.filter((ms) => ms === 2_000).length,
|
||||
liveChildren: children.size,
|
||||
pendingTimers,
|
||||
maxLiveChildren,
|
||||
terminateCount: terminate.mock.calls.length
|
||||
}).toEqual({
|
||||
summary: { outcome: 'completed', spawnCount: 3 },
|
||||
spawnCount: 3,
|
||||
exitCount: 2,
|
||||
timerCount: 9,
|
||||
pollCount: 7,
|
||||
backoffCount: 2,
|
||||
liveChildren: 0,
|
||||
pendingTimers: 0,
|
||||
maxLiveChildren: 1,
|
||||
terminateCount: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers after a transient process spawn error', async () => {
|
||||
const children = [createFakeChild(), createFakeChild()]
|
||||
let spawnCount = 0
|
||||
let now = 0
|
||||
const timerDurations: number[] = []
|
||||
const spawnProcess = vi.fn(() => {
|
||||
const child = children[spawnCount++]
|
||||
if (spawnCount === 1) {
|
||||
queueMicrotask(() => child.emit('error', new Error('temporary launch failure')))
|
||||
}
|
||||
return child
|
||||
})
|
||||
const terminate = vi.fn(async () => {})
|
||||
|
||||
await expect(
|
||||
runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, {
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus: vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
),
|
||||
terminate,
|
||||
sleep: vi.fn(async (ms: number) => {
|
||||
timerDurations.push(ms)
|
||||
now += ms
|
||||
await Promise.resolve()
|
||||
}),
|
||||
now: () => now
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'completed', spawnCount: 2 })
|
||||
expect(spawnProcess).toHaveBeenCalledTimes(2)
|
||||
expect(timerDurations).toEqual([5_000, 2_000, 5_000])
|
||||
expect(terminate).toHaveBeenCalledTimes(1)
|
||||
expect(terminate).toHaveBeenCalledWith(children[1], process.platform !== 'win32')
|
||||
})
|
||||
|
||||
it('keeps the successful app-server claimant alive until Codex marks its DB complete', async () => {
|
||||
const child = createFakeChild()
|
||||
const terminate = vi.fn(async () => {})
|
||||
const readStatus = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' })
|
||||
.mockReturnValue({ kind: 'complete', stateDbPath: '/state.sqlite' })
|
||||
|
||||
await expect(
|
||||
runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, {
|
||||
spawnProcess: vi.fn(() => child) as never,
|
||||
readStatus,
|
||||
terminate,
|
||||
sleep: vi.fn(async () => {}),
|
||||
now: vi.fn(() => 1_000)
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'completed', spawnCount: 1 })
|
||||
expect(terminate).toHaveBeenCalledWith(child, process.platform !== 'win32')
|
||||
})
|
||||
|
||||
it('hands the Codex shim itself to the spawn chokepoint for native Windows recovery', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const child = createFakeChild()
|
||||
const spawnProcess = vi.fn(() => child)
|
||||
const codexCommand = 'C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd'
|
||||
const readStatus = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ kind: 'incomplete', stateDbPath: 'state.sqlite', status: 'running' })
|
||||
.mockReturnValue({ kind: 'complete', stateDbPath: 'state.sqlite' })
|
||||
const terminate = vi.fn(async () => {})
|
||||
|
||||
await runCodexStateDbBackfillRecovery(
|
||||
'C:\\Users\\alice\\.codex',
|
||||
new AbortController().signal,
|
||||
{
|
||||
spawnProcess: spawnProcess as never,
|
||||
resolveCommand: () => codexCommand,
|
||||
readStatus,
|
||||
terminate,
|
||||
sleep: vi.fn(async () => {}),
|
||||
now: vi.fn(() => 1_000)
|
||||
}
|
||||
)
|
||||
|
||||
const [spawnFile, spawnArgs, spawnOptions] = spawnProcess.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string[],
|
||||
{ cwd?: string; env?: NodeJS.ProcessEnv }
|
||||
]
|
||||
// Pre-wrapping hides the npm shim from spawnProcess resolution.
|
||||
expect(spawnFile).toBe(codexCommand)
|
||||
expect(spawnArgs).toEqual([
|
||||
'-c',
|
||||
'approval_policy=never',
|
||||
'-s',
|
||||
'read-only',
|
||||
'-a',
|
||||
'never',
|
||||
'app-server'
|
||||
])
|
||||
expect(spawnOptions.cwd).toBe('C:\\Users\\alice\\.codex')
|
||||
expect(spawnOptions.env?.CODEX_HOME).toBe('C:\\Users\\alice\\.codex')
|
||||
expect(spawnOptions).not.toHaveProperty('detached')
|
||||
expect(terminate).toHaveBeenCalledWith(child, false)
|
||||
})
|
||||
|
||||
it('retries a live foreign lease until one durable claimant can recover it', async () => {
|
||||
const first = createFakeChild()
|
||||
const second = createFakeChild()
|
||||
const children = [first, second]
|
||||
let now = 0
|
||||
let spawnCount = 0
|
||||
const spawnProcess = vi.fn(() => {
|
||||
const child = children[spawnCount++]
|
||||
if (child === first) {
|
||||
queueMicrotask(() => child.emit('exit', 1, null))
|
||||
}
|
||||
return child
|
||||
})
|
||||
const readStatus = vi.fn((): CodexStateDbBackfillStatus =>
|
||||
spawnCount >= 2
|
||||
? { kind: 'complete', stateDbPath: '/state.sqlite' }
|
||||
: { kind: 'incomplete', stateDbPath: '/state.sqlite', status: 'running' }
|
||||
)
|
||||
|
||||
await expect(
|
||||
runCodexStateDbBackfillRecovery('/managed-home', new AbortController().signal, {
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus,
|
||||
terminate: vi.fn(async () => {}),
|
||||
sleep: vi.fn(async (ms: number) => {
|
||||
now += ms
|
||||
await Promise.resolve()
|
||||
}),
|
||||
now: () => now
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'completed', spawnCount: 2 })
|
||||
})
|
||||
|
||||
it('routes a WSL managed home through its distro and Linux CODEX_HOME', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const child = createFakeChild()
|
||||
const spawnProcess = vi.fn(() => child)
|
||||
const readStatus = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ kind: 'incomplete', stateDbPath: 'state.sqlite', status: 'running' })
|
||||
.mockReturnValue({ kind: 'complete', stateDbPath: 'state.sqlite' })
|
||||
|
||||
await runCodexStateDbBackfillRecovery(
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex',
|
||||
new AbortController().signal,
|
||||
{
|
||||
spawnProcess: spawnProcess as never,
|
||||
readStatus,
|
||||
terminate: vi.fn(async () => {}),
|
||||
sleep: vi.fn(async () => {}),
|
||||
now: vi.fn(() => 1_000)
|
||||
}
|
||||
)
|
||||
|
||||
expect(spawnProcess).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
expect.arrayContaining(['-d', 'Ubuntu']),
|
||||
expect.objectContaining({ windowsHide: true })
|
||||
)
|
||||
const spawnCall = spawnProcess.mock.calls[0] as unknown as [string, string[]]
|
||||
const command = spawnCall[1].join(' ')
|
||||
expect(command).toContain('export CODEX_HOME=')
|
||||
expect(command).toContain('/home/alice/.codex')
|
||||
for (const arg of CODEX_READ_ONLY_APP_SERVER_ARGS) {
|
||||
expect(command).toContain(arg)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('Codex backfill supervisor owner lock', () => {
|
||||
|
||||
@@ -89,16 +89,6 @@ describe('prepareLegacySharedCodexSessionResume', () => {
|
||||
expect(statSync(targetPath).ino).toBe(statSync(rolloutPath).ino)
|
||||
})
|
||||
|
||||
it('materializes a compressed legacy rollout without changing its representation', async () => {
|
||||
const compressedPath = `${rolloutPath}.zst`
|
||||
rmSync(rolloutPath)
|
||||
rolloutPath = compressedPath
|
||||
writeFileSync(rolloutPath, 'compressed-rollout', 'utf-8')
|
||||
|
||||
await expect(prepare()).resolves.toEqual({ useRealCodexHome: true })
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('compressed-rollout')
|
||||
})
|
||||
|
||||
it('coalesces concurrent resumes of the same legacy rollout', async () => {
|
||||
const [first, second] = await Promise.all([prepare(), prepare()])
|
||||
|
||||
@@ -122,49 +112,6 @@ describe('prepareLegacySharedCodexSessionResume', () => {
|
||||
expect(readFileSync(targetRolloutPath(), 'utf-8')).toBe('{"different":true}\n')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['per-account home', join(rootPlaceholder(), 'codex-accounts', 'account-1', 'home'), 'local'],
|
||||
['custom home', join(rootPlaceholder(), 'custom-codex-home'), 'local'],
|
||||
['WSL home', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex', 'local'],
|
||||
['SSH session', rootPlaceholder(), 'ssh:server-1']
|
||||
])(
|
||||
'preserves %s without materializing it',
|
||||
async (_label, codexHomeTemplate, executionHostId) => {
|
||||
const codexHome = codexHomeTemplate.replace(rootPlaceholder(), root)
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: rolloutPath,
|
||||
codexHome,
|
||||
executionHostId: executionHostId as 'local'
|
||||
},
|
||||
options()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves the legacy home while a non-default Codex home lane is selected', async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), {
|
||||
...options(),
|
||||
isHostSystemDefaultRealHomeSelected: () => false
|
||||
})
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
expect(existsSync(targetRolloutPath())).toBe(false)
|
||||
})
|
||||
|
||||
it('still materializes a legacy resume when an account selection exists', async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(legacyArgs(), {
|
||||
...options(),
|
||||
getSelectedHostAccountCodexHomePath: () => join(root, 'codex-accounts', 'account-1', 'home')
|
||||
})
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: true })
|
||||
})
|
||||
|
||||
function prepare() {
|
||||
return prepareLegacySharedCodexSessionResume(legacyArgs(), options())
|
||||
}
|
||||
@@ -250,34 +197,6 @@ describe('per-account resume repin', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the home of a row that already names the selected account', async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: recordedRolloutPath,
|
||||
codexHome: selectedHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
repinOptions()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('declines while the system default is selected', async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: bridgedRolloutPath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{ ...repinOptions(), getSelectedHostAccountCodexHomePath: () => null }
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('declines while the bridge has not yet linked the rollout into the selected home', async () => {
|
||||
const unbridgedSelectedHome = join(
|
||||
root,
|
||||
@@ -316,58 +235,6 @@ describe('per-account resume repin', () => {
|
||||
).rejects.toBeInstanceOf(ManagedCodexHomeTemporarilyUnavailableError)
|
||||
})
|
||||
|
||||
it('declines a session owned by another host', async () => {
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: bridgedRolloutPath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'ssh:server-1' as 'local'
|
||||
},
|
||||
repinOptions()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('does not consult the host selection while resuming a WSL account session', async () => {
|
||||
const wslHome =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\me\\.local\\share\\orca\\codex-accounts\\account-1\\home'
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: `${wslHome}\\sessions\\2026\\07\\20\\rollout-session.jsonl`,
|
||||
codexHome: wslHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{
|
||||
...repinOptions(),
|
||||
getSelectedHostAccountCodexHomePath: () => {
|
||||
throw new Error('host lane must not be consulted')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
it('declines a transcript outside the dated rollout layout', async () => {
|
||||
const straySessionPath = join(peerHome, 'sessions', 'stray.jsonl')
|
||||
writeFileSync(straySessionPath, '{"type":"session_meta"}\n', 'utf-8')
|
||||
|
||||
const result = await prepareLegacySharedCodexSessionResume(
|
||||
{
|
||||
agent: 'codex',
|
||||
filePath: straySessionPath,
|
||||
codexHome: peerHome,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
repinOptions()
|
||||
)
|
||||
|
||||
expect(result).toEqual({ useRealCodexHome: false })
|
||||
})
|
||||
|
||||
function repinOptions() {
|
||||
return {
|
||||
isHostSystemDefaultRealHomeSelected: () => false,
|
||||
@@ -392,7 +259,3 @@ const rolloutCandidateAccessors = {
|
||||
getHardlinkIdentity: (candidate: { file: { dev: number; ino: number; nlink: number } }) =>
|
||||
codexRolloutHardlinkIdentity(candidate.file)
|
||||
}
|
||||
|
||||
function rootPlaceholder(): string {
|
||||
return '__ROOT__'
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
claimsCodexRolloutLayout,
|
||||
codexDatedRolloutRelativePath,
|
||||
findTrustedCodexSessionResume,
|
||||
resolveCodexSessionResumeProvenance,
|
||||
resolveTrustedCodexSessionResumeHome
|
||||
@@ -25,42 +23,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
it('returns the trusted home containing a persisted rollout', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl',
|
||||
trustedCodexHomes: ['/managed/account/home', '/Users/example/.codex'],
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
).toBe('/Users/example/.codex')
|
||||
})
|
||||
|
||||
it('accepts Windows paths case-insensitively', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
|
||||
trustedCodexHomes: ['c:\\users\\example\\.codex'],
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
).toBe('c:\\users\\example\\.codex')
|
||||
})
|
||||
|
||||
it('accepts an extended-length Windows rollout and preserves its original path', async () => {
|
||||
const homePath = 'C:\\Users\\Example\\.codex'
|
||||
const transcriptPath =
|
||||
'\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl'
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath,
|
||||
trustedCodexHomes: [homePath],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: (filePath) => filePath === transcriptPath
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath })
|
||||
})
|
||||
|
||||
it('rejects unsafe or unrelated Windows namespace paths before probing files', () => {
|
||||
const fileIsRegular = vi.fn((): boolean => true)
|
||||
const trustedCodexHomes = ['C:\\Users\\Example\\.codex']
|
||||
@@ -85,77 +47,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
expect(fileIsRegular).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a missing or non-regular extended-length rollout', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath:
|
||||
'\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
|
||||
trustedCodexHomes: ['C:\\Users\\Example\\.codex'],
|
||||
fileIsRegular: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a normal-form rollout under an extended-length trusted home', () => {
|
||||
const homePath = '\\\\?\\C:\\Users\\Example\\.codex'
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: 'C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-a.jsonl',
|
||||
trustedCodexHomes: [homePath],
|
||||
fileIsRegular: () => true
|
||||
})
|
||||
).toBe(homePath)
|
||||
})
|
||||
|
||||
// Comparison normalization must not change paths used for file access.
|
||||
it('follows a compressed extended-length rollout without losing the extended spelling', async () => {
|
||||
const homePath = 'C:\\Users\\Example\\.codex'
|
||||
const plainPath =
|
||||
'\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl'
|
||||
const compressedPath = `${plainPath}.zst`
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath: plainPath,
|
||||
trustedCodexHomes: [homePath],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: (filePath) => filePath === compressedPath
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
|
||||
})
|
||||
|
||||
it('accepts extended-length scan entries but not device-namespace entries', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const homePath = 'C:\\Users\\Example\\.codex'
|
||||
const extendedEntry = `\\\\?\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl`
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [homePath],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => true,
|
||||
async *listSessionFiles(): AsyncIterable<string> {
|
||||
yield extendedEntry
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({ homePath, transcriptPath: extendedEntry })
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [homePath],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => true,
|
||||
async *listSessionFiles(): AsyncIterable<string> {
|
||||
yield `\\\\.\\C:\\Users\\Example\\.codex\\sessions\\2026\\07\\20\\rollout-${sessionId}.jsonl`
|
||||
}
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('rejects paths outside trusted homes or outside the rollout layout', () => {
|
||||
const fileIsRegular = vi.fn((): boolean => true)
|
||||
@@ -184,16 +76,6 @@ describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
expect(fileIsRegular).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a trusted-looking path when the rollout no longer exists', () => {
|
||||
expect(
|
||||
resolveTrustedCodexSessionResumeHome({
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-a.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
fileIsRegular: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('requires the transcript provenance to name a regular rollout file', () => {
|
||||
const homePath = mkdtempSync(join(tmpdir(), 'orca-codex-resume-home-'))
|
||||
tempRoots.push(homePath)
|
||||
@@ -277,47 +159,6 @@ describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
).resolves.toEqual({ homePath, transcriptPath: compressedPath })
|
||||
})
|
||||
|
||||
it('finds older saved sessions by id when transcript provenance is absent', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const rolloutPath = `/managed/account/home/sessions/2026/07/20/rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
|
||||
const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable<string> {
|
||||
if (sessionsRoot === '/managed/account/home/sessions') {
|
||||
yield `/managed/account/home/sessions/misplaced-${sessionId}.jsonl`
|
||||
yield rolloutPath
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'],
|
||||
...withoutHomeRanking,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath })
|
||||
})
|
||||
|
||||
it('does not scan session trees when exact transcript provenance is valid', async () => {
|
||||
const transcriptPath =
|
||||
'/managed/account/home/sessions/2026/07/20/rollout-2026-07-20-session.jsonl'
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
})
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: 'session-a',
|
||||
transcriptPath,
|
||||
trustedCodexHomes: ['/managed/account/home'],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => true,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath })
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replace rejected transcript provenance with a same-id rollout from another home', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
@@ -336,286 +177,6 @@ describe('resolveTrustedCodexSessionResumeHome', () => {
|
||||
).resolves.toBeNull()
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan another home after rejecting extended-length provenance', async () => {
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
})
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath:
|
||||
'\\\\?\\D:\\Other\\.codex\\sessions\\2026\\07\\20\\rollout-019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl',
|
||||
trustedCodexHomes: ['C:\\Users\\Example\\.codex'],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => true,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not scan homes for an untrusted legacy session id shape', async () => {
|
||||
const listSessionFiles = (): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
}
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId: '../session',
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
...withoutHomeRanking,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const systemHome = join('/Users', 'example', '.codex')
|
||||
const sharedMirror = join('/userData', 'codex-runtime-home', 'home')
|
||||
const accountAHome = join('/userData', 'codex-accounts', 'account-a', 'home')
|
||||
const accountBHome = join('/userData', 'codex-accounts', 'account-b', 'home')
|
||||
|
||||
const rolloutIn = (homePath: string): string =>
|
||||
join(homePath, 'sessions', '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`)
|
||||
|
||||
// Bridged rollouts can share an ID across account homes.
|
||||
const listRolloutInEveryHome = async function* (sessionsRoot: string): AsyncIterable<string> {
|
||||
yield join(sessionsRoot, '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`)
|
||||
}
|
||||
|
||||
const listRolloutIn = (...homePaths: string[]) =>
|
||||
async function* (sessionsRoot: string): AsyncIterable<string> {
|
||||
if (homePaths.some((homePath) => sessionsRoot === join(homePath, 'sessions'))) {
|
||||
yield* listRolloutInEveryHome(sessionsRoot)
|
||||
}
|
||||
}
|
||||
|
||||
it('resumes into the selected account home whatever order the homes arrive in', async () => {
|
||||
for (const trustedCodexHomes of [
|
||||
[systemHome, sharedMirror, accountAHome, accountBHome],
|
||||
[systemHome, sharedMirror, accountBHome, accountAHome],
|
||||
[accountBHome, accountAHome, sharedMirror, systemHome]
|
||||
]) {
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes,
|
||||
getSelectedAccountCodexHome: () => accountBHome,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutInEveryHome
|
||||
})
|
||||
).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the real system home when no account home is selected', async () => {
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [sharedMirror, accountAHome, systemHome],
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutInEveryHome
|
||||
})
|
||||
).resolves.toEqual({ homePath: systemHome, transcriptPath: rolloutIn(systemHome) })
|
||||
})
|
||||
|
||||
it('ranks the real system home above the others even when its path sorts last', async () => {
|
||||
const lateSortingSystemHome = join('/var', 'lib', 'orca', '.codex')
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [sharedMirror, accountAHome, lateSortingSystemHome],
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: lateSortingSystemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutInEveryHome
|
||||
})
|
||||
).resolves.toEqual({
|
||||
homePath: lateSortingSystemHome,
|
||||
transcriptPath: rolloutIn(lateSortingSystemHome)
|
||||
})
|
||||
})
|
||||
|
||||
it('orders the remaining homes by path so insertion order never decides', async () => {
|
||||
for (const trustedCodexHomes of [
|
||||
[accountBHome, accountAHome],
|
||||
[accountAHome, accountBHome]
|
||||
]) {
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes,
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutInEveryHome
|
||||
})
|
||||
).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) })
|
||||
}
|
||||
})
|
||||
|
||||
// The mirror permits migration to the system home for system-default resumes.
|
||||
it('prefers the shared mirror over a per-account home when the system home lacks the id', async () => {
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [systemHome, accountAHome, sharedMirror],
|
||||
getSelectedAccountCodexHome: () => null,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutIn(sharedMirror, accountAHome)
|
||||
})
|
||||
).resolves.toEqual({ homePath: sharedMirror, transcriptPath: rolloutIn(sharedMirror) })
|
||||
})
|
||||
|
||||
it('ranks Windows homes case-insensitively and keeps the caller path spelling', async () => {
|
||||
const windowsRoot = 'C:\\Users\\Example'
|
||||
const windowsSystemHome = `${windowsRoot}\\.codex`
|
||||
const windowsAccountAHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home`
|
||||
const windowsAccountBHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\b\\home`
|
||||
const windowsRolloutIn = (homePath: string): string =>
|
||||
`${join(homePath, 'sessions')}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
|
||||
const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable<string> {
|
||||
yield `${sessionsRoot}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl`
|
||||
}
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [windowsSystemHome, windowsAccountAHome, windowsAccountBHome],
|
||||
getSelectedAccountCodexHome: () => windowsAccountBHome.toLowerCase(),
|
||||
systemCodexHomePath: windowsSystemHome,
|
||||
sharedRuntimeCodexHomePath: null,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toEqual({
|
||||
homePath: windowsAccountBHome,
|
||||
transcriptPath: windowsRolloutIn(windowsAccountBHome)
|
||||
})
|
||||
})
|
||||
|
||||
it('still resumes the only home holding the id, selected or not', async () => {
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: undefined,
|
||||
trustedCodexHomes: [systemHome, sharedMirror, accountAHome, accountBHome],
|
||||
getSelectedAccountCodexHome: () => accountAHome,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
listSessionFiles: listRolloutIn(accountBHome)
|
||||
})
|
||||
).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) })
|
||||
})
|
||||
|
||||
it('does not rescan into the selected account home when transcript provenance was rejected', async () => {
|
||||
const listSessionFiles = vi.fn((): AsyncIterable<string> => {
|
||||
throw new Error('must not scan')
|
||||
})
|
||||
|
||||
await expect(
|
||||
findTrustedCodexSessionResume({
|
||||
sessionId,
|
||||
transcriptPath: rolloutIn(accountBHome),
|
||||
trustedCodexHomes: [systemHome, accountAHome, accountBHome],
|
||||
getSelectedAccountCodexHome: () => accountAHome,
|
||||
systemCodexHomePath: systemHome,
|
||||
sharedRuntimeCodexHomePath: sharedMirror,
|
||||
fileIsRegular: () => false,
|
||||
listSessionFiles
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
expect(listSessionFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('codexDatedRolloutRelativePath', () => {
|
||||
const sessionsRoot = String.raw`C:\Users\Example\AppData\Roaming\orca\codex-runtime-home\home\sessions`
|
||||
|
||||
it('folds the extended-length prefix Codex adds to Windows transcripts (#27200)', () => {
|
||||
expect(
|
||||
codexDatedRolloutRelativePath(
|
||||
sessionsRoot,
|
||||
String.raw`\\?\C:\Users\Example\AppData\Roaming\orca\codex-runtime-home\home\sessions\2026\10\01\rollout-example.jsonl`
|
||||
)
|
||||
).toBe('2026/10/01/rollout-example.jsonl')
|
||||
})
|
||||
|
||||
it('rejects device namespaces, extended UNC paths, and non-rollout layouts', () => {
|
||||
for (const filePath of [
|
||||
String.raw`\\.\C:\Users\Example\AppData\Roaming\orca\codex-runtime-home\home\sessions\2026\10\01\rollout-example.jsonl`,
|
||||
String.raw`\\?\UNC\server\share\sessions\2026\10\01\rollout-example.jsonl`,
|
||||
String.raw`C:\Users\Example\AppData\Roaming\orca\codex-runtime-home\home\sessions\2026\10\01\..\..\..\2026\10\01\rollout-example.jsonl`,
|
||||
String.raw`C:\Users\Example\.codex\sessions\2026\10\01\rollout-example.jsonl`
|
||||
]) {
|
||||
expect(codexDatedRolloutRelativePath(sessionsRoot, filePath)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('claimsCodexRolloutLayout', () => {
|
||||
it('is true for a rollout path even if the file is missing', () => {
|
||||
expect(
|
||||
claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for compressed rollouts and Windows-separated paths', () => {
|
||||
expect(
|
||||
claimsCodexRolloutLayout(
|
||||
'/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl.zst'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
claimsCodexRolloutLayout(
|
||||
'C:\\Users\\example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a rollout under a home Orca no longer trusts, so resume cannot silently fall through to the selected account', () => {
|
||||
expect(
|
||||
claimsCodexRolloutLayout('/removed/account/home/sessions/2026/07/20/rollout-a.jsonl')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for an ADS-shaped rollout that provenance trust rejects', () => {
|
||||
expect(
|
||||
claimsCodexRolloutLayout(
|
||||
'C:\\Users\\example\\.codex\\sessions\\2026\\07\\20\\rollout-a:stream.jsonl'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for Claude (or other non-Codex) transcript paths', () => {
|
||||
expect(
|
||||
claimsCodexRolloutLayout(
|
||||
'/Users/example/.claude/projects/-Users-example-repo/019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is false for empty provenance and JSONL misplaced inside a sessions root', () => {
|
||||
expect(claimsCodexRolloutLayout(undefined)).toBe(false)
|
||||
expect(claimsCodexRolloutLayout(' ')).toBe(false)
|
||||
expect(claimsCodexRolloutLayout('/Users/example/.codex/sessions/rollout-a.jsonl')).toBe(false)
|
||||
expect(
|
||||
claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/nested/rollout-a.jsonl')
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCodexSessionResumeProvenance', () => {
|
||||
@@ -650,45 +211,4 @@ describe('resolveCodexSessionResumeProvenance', () => {
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: true })
|
||||
})
|
||||
|
||||
it('resumes from the originating home when that home is still trusted', async () => {
|
||||
const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11'
|
||||
const origin = writeRollout(sessionId)
|
||||
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId,
|
||||
transcriptPath: origin.rolloutPath,
|
||||
trustedCodexHomes: [origin.homePath],
|
||||
...withoutHomeRanking
|
||||
})
|
||||
).resolves.toEqual({
|
||||
outcome: 'resume',
|
||||
homePath: origin.homePath,
|
||||
transcriptPath: origin.rolloutPath
|
||||
})
|
||||
})
|
||||
|
||||
it('starts fresh silently for cross-agent provenance on a pane relabeled codex', async () => {
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath: '/Users/example/.claude/projects/repo/019f81b9.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
...withoutHomeRanking
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: false })
|
||||
})
|
||||
|
||||
it('reports a missing rollout under a trusted home as rejected Codex provenance', async () => {
|
||||
await expect(
|
||||
resolveCodexSessionResumeProvenance({
|
||||
sessionId: '019f81b9-19a9-7651-a8d1-352d9420bd11',
|
||||
transcriptPath: '/Users/example/.codex/sessions/2026/07/20/rollout-gone.jsonl',
|
||||
trustedCodexHomes: ['/Users/example/.codex'],
|
||||
...withoutHomeRanking,
|
||||
fileIsRegular: () => false
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'fresh', claimedCodexProvenance: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import {
|
||||
createManagedCommandMatcher,
|
||||
type HookDefinition
|
||||
} from '../../../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildWindowsHookPowerShellCommand,
|
||||
wrapWindowsHookCommand
|
||||
} from '../../../agent-hooks/windows-hook-command'
|
||||
import {
|
||||
buildCodexHookCommand,
|
||||
CODEX_HOOK_COMMAND_FORM,
|
||||
readCodexHookCommandForm
|
||||
} from './codex-hook-command-form'
|
||||
import { getCodexManagedHookInstallMaterial } from './codex-hook-definition'
|
||||
import { planRealHomeCodexHookEntries } from '../reconciliation/codex-real-home-hook-entry-plan'
|
||||
|
||||
// Goldens prevent shared HOME builds rewriting each other's hooks without a form bump.
|
||||
const POSIX_GOLDEN =
|
||||
': orca-agent-hook-form=1; if [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -f "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" ]; then /bin/sh "${ORCA_AGENT_HOOK_ROOT-}/agent-hooks/codex-hook.sh" || :; elif [ -z "${ORCA_AGENT_HOOK_ROOT-}" ] && [ -n "${ORCA_PANE_KEY-}" ] && [ -n "${ORCA_AGENT_HOOK_PORT-}" ] && [ -f "${HOME-}/.orca/agent-hooks/codex-hook.sh" ]; then /bin/sh "${HOME-}/.orca/agent-hooks/codex-hook.sh" || :; else { command -p cat 2>/dev/null || cat; } >/dev/null 2>&1 || :; fi'
|
||||
const WINDOWS_BARE_GOLDEN = 'C:/Users/alice/.orca/agent-hooks/codex-hook.cmd'
|
||||
const WINDOWS_CMD_GOLDEN =
|
||||
'C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
|
||||
// Older local builds used bare cmd; installation and app-start reconciliation migrate it.
|
||||
const WINDOWS_BARE_CMD_SPELLING =
|
||||
'cmd --% /d /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
|
||||
const WINDOWS_ENV = { SystemRoot: 'C:\\Windows', ComSpec: 'C:\\Windows\\system32\\cmd.exe' }
|
||||
// Dev builds used PowerShell text for spaced profile paths; app start migrates it.
|
||||
const WINDOWS_POWERSHELL_TEXT =
|
||||
"<# orca-agent-hook-form=1 #> if ($env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_ROOT -and (Test-Path -LiteralPath (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') -PathType Leaf)) { & (Join-Path $env:ORCA_AGENT_HOOK_ROOT 'agent-hooks\\codex-hook.cmd') } elseif (-not $env:ORCA_AGENT_HOOK_ROOT -and $env:ORCA_PANE_KEY -and $env:ORCA_AGENT_HOOK_PORT -and (Test-Path -LiteralPath 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' -PathType Leaf)) { & 'C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd' } else { if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }; [Console]::In.ReadToEnd() | Out-Null }; exit 0"
|
||||
|
||||
function windowsCommandFor(
|
||||
profileDirName: string,
|
||||
env: Record<string, string | undefined> = WINDOWS_ENV
|
||||
): string {
|
||||
return buildCodexHookCommand(
|
||||
`C:\\Users\\${profileDirName}\\.orca\\agent-hooks\\codex-hook.cmd`,
|
||||
'win32',
|
||||
env
|
||||
)
|
||||
}
|
||||
|
||||
describe('frozen Codex hook command', () => {
|
||||
it('matches the form 1 goldens', () => {
|
||||
expect(CODEX_HOOK_COMMAND_FORM).toBe(1)
|
||||
expect(buildCodexHookCommand('/home/a/.orca/agent-hooks/codex-hook.sh', 'linux')).toBe(
|
||||
POSIX_GOLDEN
|
||||
)
|
||||
expect(windowsCommandFor('alice')).toBe(WINDOWS_BARE_GOLDEN)
|
||||
expect(windowsCommandFor('First Last')).toBe(WINDOWS_CMD_GOLDEN)
|
||||
})
|
||||
|
||||
it.each([' ', '&', '^', '$', '`', "'", '!', '(', ')', 'é', '测'])(
|
||||
'takes the cmd spelling for a profile path holding %j',
|
||||
(character) => {
|
||||
const name = `a${character}b`
|
||||
expect(windowsCommandFor(name)).toBe(
|
||||
`C:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd"`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['alice', 'Alice.Smith-2', 'a_b', 'ALICE~1'])(
|
||||
'keeps the bare path for the safe profile path %s',
|
||||
(name) => {
|
||||
expect(windowsCommandFor(name)).toBe(`C:/Users/${name}/.orca/agent-hooks/codex-hook.cmd`)
|
||||
}
|
||||
)
|
||||
|
||||
it('writes the same Windows bytes for a path in either slash direction', () => {
|
||||
for (const name of ['alice', 'First Last']) {
|
||||
const backslashed = `C:\\Users\\${name}\\.orca\\agent-hooks\\codex-hook.cmd`
|
||||
expect(buildCodexHookCommand(backslashed, 'win32', WINDOWS_ENV)).toBe(
|
||||
buildCodexHookCommand(backslashed.replaceAll('\\', '/'), 'win32', WINDOWS_ENV)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('names the system cmd.exe from %SystemRoot%, on any drive, with backslashes', () => {
|
||||
expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe(
|
||||
'D:\\Windows\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
|
||||
)
|
||||
expect(windowsCommandFor('First Last', { SystemRoot: 'C:\\WINDOWS\\' })).toBe(
|
||||
WINDOWS_CMD_GOLDEN.replace('C:\\Windows\\', 'C:\\WINDOWS\\')
|
||||
)
|
||||
expect(windowsCommandFor('alice', { SystemRoot: 'D:\\Windows' })).toBe(WINDOWS_BARE_GOLDEN)
|
||||
})
|
||||
|
||||
it('falls back to the Windows directory holding %ComSpec% when %SystemRoot% is unset', () => {
|
||||
expect(windowsCommandFor('First Last', { ComSpec: 'E:\\WinNT\\system32\\cmd.exe' })).toBe(
|
||||
'E:\\WinNT\\System32\\cmd.exe --% /d /v:off /c @"C:/Users/First Last/.orca/agent-hooks/codex-hook.cmd"'
|
||||
)
|
||||
// Missing one environment variable must not change this machine's command bytes.
|
||||
expect(windowsCommandFor('First Last', { ComSpec: 'C:\\Windows\\system32\\cmd.exe' })).toBe(
|
||||
WINDOWS_CMD_GOLDEN
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unset', {}],
|
||||
['relative', { SystemRoot: 'Windows' }],
|
||||
['spaced', { SystemRoot: 'C:\\My Windows' }],
|
||||
['holding &', { SystemRoot: 'C:\\Win&dows' }],
|
||||
['a UNC share', { SystemRoot: '\\\\server\\Windows' }],
|
||||
['another shell in %ComSpec%', { ComSpec: 'C:\\Tools\\tcc.exe' }],
|
||||
['a spaced %ComSpec%', { ComSpec: 'C:\\My Windows\\System32\\cmd.exe' }]
|
||||
])(
|
||||
'names C:\\Windows\\System32\\cmd.exe, never a bare or quoted cmd, when the Windows directory is %s',
|
||||
(_case, env) => {
|
||||
expect(windowsCommandFor('First Last', env)).toBe(WINDOWS_CMD_GOLDEN)
|
||||
}
|
||||
)
|
||||
|
||||
it('writes identical bytes for the same profile path and Windows directory', () => {
|
||||
const first = windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })
|
||||
expect(windowsCommandFor('First Last', { SystemRoot: 'D:\\Windows' })).toBe(first)
|
||||
expect(windowsCommandFor('First Last', { SystemRoot: 'D:/Windows/' })).toBe(first)
|
||||
})
|
||||
|
||||
it('writes identical POSIX bytes whatever the home or build', () => {
|
||||
expect(buildCodexHookCommand('/Users/a/.orca/agent-hooks/codex-hook.sh', 'darwin')).toBe(
|
||||
buildCodexHookCommand('/home/b/.orca/agent-hooks/codex-hook.sh', 'linux')
|
||||
)
|
||||
})
|
||||
|
||||
it.each([POSIX_GOLDEN, WINDOWS_BARE_GOLDEN, WINDOWS_CMD_GOLDEN])(
|
||||
'keeps the script name in plain text so every older build still recognizes it',
|
||||
(command) => {
|
||||
const isOrca = createManagedCommandMatcher(
|
||||
command === POSIX_GOLDEN ? 'codex-hook.sh' : 'codex-hook.cmd'
|
||||
)
|
||||
expect(isOrca(command)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('reads the form: current, higher, and unmarked older forms', () => {
|
||||
expect(readCodexHookCommandForm(POSIX_GOLDEN, POSIX_GOLDEN)).toBe(1)
|
||||
expect(readCodexHookCommandForm(WINDOWS_BARE_GOLDEN, WINDOWS_BARE_GOLDEN)).toBe(1)
|
||||
expect(readCodexHookCommandForm(POSIX_GOLDEN.replace('form=1', 'form=2'), POSIX_GOLDEN)).toBe(2)
|
||||
expect(
|
||||
readCodexHookCommandForm(
|
||||
"if [ -f '/h/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/h/.orca/agent-hooks/codex-hook.sh'; fi",
|
||||
POSIX_GOLDEN
|
||||
)
|
||||
).toBe(0)
|
||||
expect(
|
||||
readCodexHookCommandForm(
|
||||
'C:\\Users\\alice\\.orca\\agent-hooks\\codex-hook.cmd',
|
||||
WINDOWS_BARE_GOLDEN
|
||||
)
|
||||
).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the Windows spelling change', () => {
|
||||
const hooksWith = (command: string): Record<string, HookDefinition[]> => ({
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command, timeout: 10 }] },
|
||||
{ hooks: [{ type: 'command', command: 'user-hook.cmd' }] }
|
||||
]
|
||||
})
|
||||
const plan = (command: string, convertOlderForms: boolean) =>
|
||||
planRealHomeCodexHookEntries({
|
||||
hooks: hooksWith(command),
|
||||
sourcePath: 'C:/Users/First Last/.codex/hooks.json',
|
||||
material: {
|
||||
...getCodexManagedHookInstallMaterial(),
|
||||
events: ['Stop'],
|
||||
command: WINDOWS_CMD_GOLDEN
|
||||
},
|
||||
isOrcaCommand: createManagedCommandMatcher('codex-hook.cmd'),
|
||||
convertOlderForms
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the PowerShell-text form', WINDOWS_POWERSHELL_TEXT],
|
||||
['the bare-cmd spelling', WINDOWS_BARE_CMD_SPELLING],
|
||||
[
|
||||
"main's PowerShell text",
|
||||
buildWindowsHookPowerShellCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
],
|
||||
[
|
||||
'the encoded launcher',
|
||||
wrapWindowsHookCommand('C:\\Users\\First Last\\.orca\\agent-hooks\\codex-hook.cmd')
|
||||
]
|
||||
])('converts %s to the cmd.exe spelling once, in its slot, at app start', (_case, older) => {
|
||||
const converted = plan(older, true)
|
||||
expect(converted).toMatchObject({ kind: 'settle', changedLabels: new Set(['stop']) })
|
||||
expect(converted.hooks.Stop).toEqual([
|
||||
{ hooks: [{ type: 'command', command: WINDOWS_CMD_GOLDEN, timeout: 10 }] },
|
||||
{ hooks: [{ type: 'command', command: 'user-hook.cmd' }] }
|
||||
])
|
||||
expect(plan(WINDOWS_CMD_GOLDEN, true)).toMatchObject({ changedLabels: new Set() })
|
||||
})
|
||||
|
||||
it.each([WINDOWS_POWERSHELL_TEXT, WINDOWS_BARE_CMD_SPELLING])(
|
||||
'leaves the older Windows form %# alone on a pane launch',
|
||||
(older) => {
|
||||
expect(plan(older, false)).toMatchObject({ changedLabels: new Set() })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('frozen Codex hook command under /bin/sh', () => {
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function makeScripts(): { home: string; root: string; marker: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-codex-hook-form-'))
|
||||
roots.push(dir)
|
||||
const home = join(dir, 'home')
|
||||
const root = join(dir, 'root')
|
||||
const marker = join(dir, 'ran')
|
||||
for (const [base, label] of [
|
||||
[join(home, '.orca'), 'shared'],
|
||||
[root, 'root']
|
||||
]) {
|
||||
mkdirSync(join(base, 'agent-hooks'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(base, 'agent-hooks', 'codex-hook.sh'),
|
||||
`cat >/dev/null; printf ${label} >> '${marker}'; exit 2\n`
|
||||
)
|
||||
}
|
||||
return { home, root, marker }
|
||||
}
|
||||
|
||||
async function run(env: Record<string, string>): Promise<{ code: number | null; ran: string }> {
|
||||
const result = await runProcess({
|
||||
program: '/bin/sh',
|
||||
args: ['-c', POSIX_GOLDEN],
|
||||
input: '{"hook_event_name":"Stop"}',
|
||||
env: { PATH: process.env.PATH ?? '', ...env },
|
||||
timeoutMs: 10_000
|
||||
})
|
||||
const ran = existsSync(env.MARKER) ? readFileSync(env.MARKER, 'utf-8') : ''
|
||||
return { code: result.code, ran }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['outside Orca', {}, ''],
|
||||
['a pane with hooks off', { ORCA_PANE_KEY: 'tab:leaf' }, ''],
|
||||
['a pane with hooks on', { ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1' }, 'shared'],
|
||||
[
|
||||
'a structured child with a root',
|
||||
{ ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' },
|
||||
''
|
||||
],
|
||||
[
|
||||
'a pane with a hook root',
|
||||
{ ORCA_PANE_KEY: 'tab:leaf', ORCA_AGENT_HOOK_PORT: '1', ORCA_AGENT_HOOK_ROOT: 'R' },
|
||||
'root'
|
||||
]
|
||||
])('runs the right script for %s and always exits 0', async (_case, env, expected) => {
|
||||
const scripts = makeScripts()
|
||||
const resolved: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(env).map(([key, value]) => [key, value === 'R' ? scripts.root : value])
|
||||
)
|
||||
expect(await run({ ...resolved, HOME: scripts.home, MARKER: scripts.marker })).toEqual({
|
||||
code: 0,
|
||||
ran: expected
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
upsertHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from '../../trust/toml/config-toml-trust'
|
||||
import {
|
||||
computeCodexTrustKey as computeTrustKey,
|
||||
getExplicitHomeCodexHookSourcePath as getCodexExplicitHomeHookSourcePath,
|
||||
normalizeCodexTrustSourcePath as normalizeCodexHookSourcePath
|
||||
} from '../../trust/identity/codex-trust-identity'
|
||||
import { computeCodexTrustKey as computeTrustKey } from '../../trust/identity/codex-trust-identity'
|
||||
import type { CodexHookHashes } from '../../trust/hooks/codex-hook-trust-derivation'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() }))
|
||||
@@ -34,8 +30,6 @@ import {
|
||||
reconcileRealHomeCodexHookEntries,
|
||||
removeRealHomeCodexHookForOptOut
|
||||
} from '../reconciliation/codex-real-home-hook-install'
|
||||
import { getRealHomeHookKeySourcePaths } from '../reconciliation/codex-real-home-hooks-json'
|
||||
import { cleanupLegacyManagedHookRepresentations } from '../reconciliation/codex-hook-legacy-cleanup'
|
||||
import '../installation/hook-service'
|
||||
import { getCodexManagedHookInstallMaterial } from './codex-hook-definition'
|
||||
|
||||
@@ -99,20 +93,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('both spellings of a symlinked ~/.codex', () => {
|
||||
it('resolves the key through a symlinked HOME before ~/.codex exists', () => {
|
||||
const linkedHome = join(root, 'linked-home')
|
||||
symlinkSync(home, linkedHome, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
homedirMock.mockReturnValue(linkedHome)
|
||||
|
||||
expect(getRealHomeHookKeySourcePaths()).toEqual([
|
||||
normalizeCodexHookSourcePath(join(linkedHome, '.codex', 'hooks.json')),
|
||||
normalizeCodexHookSourcePath(join(home, '.codex', 'hooks.json'))
|
||||
])
|
||||
expect(getCodexExplicitHomeHookSourcePath(join(linkedHome, '.codex', 'hooks.json'))).toBe(
|
||||
normalizeCodexHookSourcePath(join(home, '.codex', 'hooks.json'))
|
||||
)
|
||||
})
|
||||
|
||||
it("approves under both keys, and the opt-out removes both by Codex's hash", async () => {
|
||||
const resolvedHooks = linkCodexHomeToDotfiles()
|
||||
|
||||
@@ -201,29 +181,6 @@ describe('both spellings of a symlinked ~/.codex', () => {
|
||||
expect(trust.get(computeTrustKey(afterAt(resolvedHooks, 1)))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("sweeps a retired hook's approval under both keys", async () => {
|
||||
const resolvedHooks = linkCodexHomeToDotfiles()
|
||||
const retired = `/bin/sh "${join(home, 'old-user-data', 'agent-hooks', 'codex-hook.sh')}"`
|
||||
writeFileSync(
|
||||
hooksPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: retired }] }] } })}\n`
|
||||
)
|
||||
const retiredAt = (sourcePath: string): CodexTrustEntry => ({
|
||||
sourcePath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: retired
|
||||
})
|
||||
upsertHookTrustEntries(tomlPath(), [retiredAt(hooksPath()), retiredAt(resolvedHooks)])
|
||||
|
||||
await cleanupLegacyManagedHookRepresentations()
|
||||
|
||||
const trust = readHookTrustEntries(tomlPath())
|
||||
expect(trust.get(computeTrustKey(retiredAt(hooksPath())))).toBeUndefined()
|
||||
expect(trust.get(computeTrustKey(retiredAt(resolvedHooks)))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps both files when an approval would break config.toml', async () => {
|
||||
linkCodexHomeToDotfiles()
|
||||
writeFileSync(hooksPath(), `${JSON.stringify({ hooks: {} }, null, 2)}\n`)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult } from '@orca/process-host/process-spec'
|
||||
|
||||
const { runProcessMock, blockedForExecMock } = vi.hoisted(() => ({
|
||||
runProcessMock: vi.fn(),
|
||||
blockedForExecMock: vi.fn(async (): Promise<{ distro: string; state: string } | null> => null)
|
||||
}))
|
||||
|
||||
vi.mock('@orca/process-host', () => ({ runProcess: runProcessMock }))
|
||||
vi.mock('../../../wsl/discovery/wsl-background-exec-gate', () => ({
|
||||
blockedWslDistroForExec: blockedForExecMock
|
||||
}))
|
||||
|
||||
import { MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES, _internals } from './codex-wsl-hook-install-plan'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
type PendingRun = { spec: { program: string; args: string[] }; resolve: (r: ProcessResult) => void }
|
||||
const pending: PendingRun[] = []
|
||||
|
||||
function exited(stdout: string, code = 0): ProcessResult {
|
||||
return { code, signal: null, stdout, stderr: '', timedOut: false }
|
||||
}
|
||||
|
||||
async function spawned(count: number): Promise<void> {
|
||||
await vi.waitFor(() => expect(pending).toHaveLength(count))
|
||||
}
|
||||
|
||||
/** Answers the `index`th wsl.exe run and lets its settlement land. */
|
||||
async function answer(index: number, result: ProcessResult): Promise<void> {
|
||||
pending[index].resolve(result)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
pending.length = 0
|
||||
runProcessMock.mockReset()
|
||||
runProcessMock.mockImplementation(
|
||||
(spec: PendingRun['spec']) =>
|
||||
new Promise<ProcessResult>((resolve) => pending.push({ spec, resolve }))
|
||||
)
|
||||
blockedForExecMock.mockReset()
|
||||
blockedForExecMock.mockResolvedValue(null)
|
||||
_internals.resetWslCanonicalPathCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform)
|
||||
})
|
||||
|
||||
describe('canonicalizeWslLinuxPath', () => {
|
||||
it('bounds successful canonical path entries', async () => {
|
||||
setPlatform('win32')
|
||||
runProcessMock.mockImplementation(async () => exited('/home/canonical\n'))
|
||||
|
||||
for (let index = 0; index < MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES + 4; index += 1) {
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', `/home/path-${index}`)
|
||||
}
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(_internals.getWslCanonicalPathCacheSizeForTests()).toBe(
|
||||
MAX_WSL_CANONICAL_PATH_CACHE_ENTRIES
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the path unchanged off Windows without spawning wsl.exe', async () => {
|
||||
setPlatform('linux')
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alice')).toBe('/home/alice')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never blocks: returns null and schedules an async resolution through process-host', async () => {
|
||||
setPlatform('win32')
|
||||
const result = _internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
|
||||
expect(result).toBeNull()
|
||||
await spawned(1)
|
||||
expect(pending[0].spec).toMatchObject({
|
||||
program: 'wsl.exe',
|
||||
timeoutMs: 5000,
|
||||
args: [
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
`if [ ! -d "$1" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$1"`,
|
||||
'sh',
|
||||
'/home/alias'
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('does not start a stopped distro to probe its Codex home, and keeps the known identity (E9)', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('/home/alice\n'))
|
||||
|
||||
blockedForExecMock.mockResolvedValue({ distro: 'Ubuntu', state: 'stopped' })
|
||||
const settled = vi.fn()
|
||||
expect(
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
|
||||
).toBe('/home/alice')
|
||||
await vi.waitFor(() => expect(settled).toHaveBeenCalledWith({ status: 'unavailable' }))
|
||||
expect(blockedForExecMock).toHaveBeenCalledWith('Ubuntu')
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
|
||||
})
|
||||
|
||||
it('resolves a custom automount root and notifies the first launch', async () => {
|
||||
setPlatform('win32')
|
||||
const settled = vi.fn()
|
||||
const windowsPath = 'D:\\orca\\codex-runtime-home\\home'
|
||||
|
||||
expect(
|
||||
_internals.canonicalizeWslLinuxPath(
|
||||
'Ubuntu',
|
||||
'/mnt/d/orca/codex-runtime-home/home',
|
||||
windowsPath,
|
||||
settled
|
||||
)
|
||||
).toBeNull()
|
||||
|
||||
await spawned(1)
|
||||
expect(pending[0].spec).toMatchObject({
|
||||
program: 'wsl.exe',
|
||||
timeoutMs: 5000,
|
||||
args: [
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'sh',
|
||||
'-c',
|
||||
`resolved=$(wslpath -a -u "$1") || exit; if [ ! -d "$resolved" ]; then printf '%s\\n' '__ORCA_WSL_PATH_MISSING__'; exit 0; fi; readlink -f -- "$resolved"`,
|
||||
'sh',
|
||||
windowsPath
|
||||
]
|
||||
})
|
||||
|
||||
await answer(0, exited('/windows/d/orca/codex-runtime-home/home\n'))
|
||||
expect(settled).toHaveBeenCalledWith({
|
||||
status: 'resolved',
|
||||
canonicalPath: '/windows/d/orca/codex-runtime-home/home'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not spawn a second subprocess while one is in flight', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
|
||||
await spawned(1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries after a failed resolution rather than caching the failure', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('', 1))
|
||||
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
await spawned(2)
|
||||
})
|
||||
|
||||
it('keeps the last known-good cache when revalidation later fails', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('/home/alice\n'))
|
||||
|
||||
const settled = vi.fn()
|
||||
expect(
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
|
||||
).toBe('/home/alice')
|
||||
await spawned(2)
|
||||
await answer(1, exited('', 1))
|
||||
|
||||
// Transport failure cannot invalidate a known-good custom-automount identity.
|
||||
expect(settled).toHaveBeenCalledWith({ status: 'unavailable' })
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice')
|
||||
})
|
||||
|
||||
it('drops the cached identity when WSL confirms the path is missing', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('/home/alice\n'))
|
||||
|
||||
const settled = vi.fn()
|
||||
expect(
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
|
||||
).toBe('/home/alice')
|
||||
await spawned(2)
|
||||
await answer(1, exited('__ORCA_WSL_PATH_MISSING__\n'))
|
||||
|
||||
expect(settled).toHaveBeenCalledWith({ status: 'missing' })
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces a cached path when its canonical identity changes', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('/home/alice-old\n'))
|
||||
|
||||
const settled = vi.fn()
|
||||
expect(
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias', '/home/alias', settled)
|
||||
).toBe('/home/alice-old')
|
||||
await spawned(2)
|
||||
await answer(1, exited('/home/alice-new\n'))
|
||||
|
||||
expect(settled).toHaveBeenCalledWith({
|
||||
status: 'resolved',
|
||||
canonicalPath: '/home/alice-new'
|
||||
})
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBe('/home/alice-new')
|
||||
})
|
||||
|
||||
it('ignores non-absolute readlink output', async () => {
|
||||
setPlatform('win32')
|
||||
_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')
|
||||
await spawned(1)
|
||||
await answer(0, exited('readlink: missing operand\n'))
|
||||
|
||||
expect(_internals.canonicalizeWslLinuxPath('Ubuntu', '/home/alias')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,7 @@ import '../../../agent-hooks/installer-utils'
|
||||
import { wrapPosixHookCommand } from '../../../agent-hooks/posix-hook-command'
|
||||
import '../../trust/toml/config-toml-trust'
|
||||
import { upsertHookTrustContent as upsertHookTrustEntriesInContent } from '../../trust/toml/config-toml-hook-trust-edit'
|
||||
import {
|
||||
hookTrustHeader,
|
||||
isCodexManagedCommand,
|
||||
setupCodexHookHomes
|
||||
} from './hook-service-test-harness'
|
||||
import { setupCodexHookHomes } from './hook-service-test-harness'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
@@ -112,69 +108,6 @@ describe('CodexHookService', () => {
|
||||
expect(readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')).toContain(':stop:1:0')
|
||||
})
|
||||
|
||||
it('removes legacy Orca-managed hooks from system ~/.codex during install', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const legacyCommand = legacyManagedHookCommand()
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: 'user-hook' }] },
|
||||
{ hooks: [{ type: 'command', command: legacyCommand }] }
|
||||
],
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
|
||||
},
|
||||
_managed: {
|
||||
'external-manager': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 1,
|
||||
handlerIndex: 0,
|
||||
command: legacyCommand
|
||||
},
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: legacyCommand
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
|
||||
expect(systemHooks.hooks.SessionStart).toBeUndefined()
|
||||
expect(systemHooks._managed).toEqual({ 'external-manager': { Stop: [0] } })
|
||||
const systemToml = readFileSync(join(systemCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(systemToml).toContain('model = "system-model"')
|
||||
expect(systemToml).not.toContain(':stop:1:0')
|
||||
expect(systemToml).not.toContain(':session_start:0:0')
|
||||
})
|
||||
|
||||
it('removes very large legacy Orca-managed hook lists from system ~/.codex', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
@@ -209,17 +142,6 @@ describe('CodexHookService', () => {
|
||||
expect(systemHooks.hooks.Stop).toBeUndefined()
|
||||
}, 30_000)
|
||||
|
||||
it('removes the legacy Orca Codex profile file when it only contains managed hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(profilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8')
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('removes only the legacy Orca block from a user-edited Codex profile file', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const profilePath = join(systemCodexHome, 'orca-agent-status.config.toml')
|
||||
@@ -278,131 +200,4 @@ describe('CodexHookService', () => {
|
||||
expect(systemHooks.hooks.SessionStart).toBeUndefined()
|
||||
expect(existsSync(profilePath)).toBe(false)
|
||||
})
|
||||
|
||||
it('sanitizes runtime hooks.json metadata during remove even without managed hooks', async () => {
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
// remove() rebuilds mirrored user hooks from the canonical home.
|
||||
mkdirSync(join(homes.tmpHome, '.codex'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(homes.tmpHome, '.codex', 'hooks.json'),
|
||||
JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }] } })
|
||||
)
|
||||
writeFileSync(
|
||||
managedHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'user-hook' }] }]
|
||||
},
|
||||
_managed: {
|
||||
'compound-engineering': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = await new CodexHookService().remove()
|
||||
|
||||
expect(status.state).toBe('not_installed')
|
||||
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(hooksConfig._managed).toBeUndefined()
|
||||
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
|
||||
expect(hooksConfig.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-hook' }] }])
|
||||
})
|
||||
|
||||
it('cleans duplicate Codex hook representations while keeping status hooks in runtime CODEX_HOME', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
const systemTomlPath = join(systemCodexHome, 'config.toml')
|
||||
const legacyProfilePath = join(systemCodexHome, 'orca-agent-status.config.toml')
|
||||
const legacyCommand = legacyManagedHookCommand()
|
||||
const userCommand = 'user-stop-hook'
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: userCommand }] },
|
||||
{ hooks: [{ type: 'command', command: legacyCommand }] }
|
||||
],
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemTomlPath,
|
||||
upsertHookTrustEntriesInContent(
|
||||
['model = "system-model"', '', '[features]', 'codex_hooks = true', ''].join('\n'),
|
||||
[
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: userCommand
|
||||
},
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'session_start',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: legacyCommand
|
||||
}
|
||||
]
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(legacyProfilePath, LEGACY_ORCA_PROFILE_LINES.join('\n'), 'utf-8')
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
const stopCommands =
|
||||
runtimeHooks.hooks.Stop?.flatMap(
|
||||
(definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []
|
||||
) ?? []
|
||||
expect(stopCommands).toContain(userCommand)
|
||||
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
|
||||
expect(
|
||||
isCodexManagedCommand(runtimeHooks.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
|
||||
).toBe(true)
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain('[features]\nhooks = true')
|
||||
expect(runtimeToml).not.toContain('codex_hooks')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:permission_request:0:0`))
|
||||
|
||||
const systemHooks = JSON.parse(readFileSync(systemHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
expect(systemHooks.hooks.Stop).toEqual([{ hooks: [{ type: 'command', command: userCommand }] }])
|
||||
expect(systemHooks.hooks.SessionStart).toBeUndefined()
|
||||
const systemToml = readFileSync(systemTomlPath, 'utf-8')
|
||||
expect(systemToml).toContain('codex_hooks = true')
|
||||
expect(systemToml).not.toContain(':session_start:0:0')
|
||||
expect(existsSync(legacyProfilePath)).toBe(false)
|
||||
expect(service.getStatus(managedCodexHome).state).toBe('installed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path'
|
||||
import { parse as parseToml } from 'smol-toml'
|
||||
import type * as InstallerUtils from '../../../agent-hooks/installer-utils'
|
||||
import type * as TrustDerivation from '../../trust/hooks/codex-hook-trust-derivation'
|
||||
import { isCodexManagedCommand, setupCodexHookHomes } from './hook-service-test-harness'
|
||||
import { setupCodexHookHomes } from './hook-service-test-harness'
|
||||
|
||||
const { getPathMock, homedirMock, hooks, codex } = vi.hoisted(() => {
|
||||
const hooks: { beforeHooksJsonWrite: (() => void) | null } = { beforeHooksJsonWrite: null }
|
||||
@@ -45,7 +45,6 @@ import './hook-service'
|
||||
import { CodexHookService } from './codex-hook-service-implementation'
|
||||
import {
|
||||
_internals as lookupInternals,
|
||||
resolveCodexHookAnswer,
|
||||
startCodexHookHashLookup
|
||||
} from '../../trust/hooks/codex-hook-hash-lookup'
|
||||
import { memoizeCodexHookAnswer } from '../../trust/hooks/codex-hook-trust-memo'
|
||||
@@ -67,7 +66,6 @@ import {
|
||||
getManagedScriptPath
|
||||
} from '../definitions/codex-hook-definition'
|
||||
import { buildCodexHookCommand as getManagedCommand } from '../definitions/codex-hook-command-form'
|
||||
import { CODEX_DAEMON_OVERRIDE_MARKER } from '../../configuration/shared-server/codex-daemon-socket-path-guard'
|
||||
import { writeCodexTrustGrantLedgerHome } from '../../trust/grants/codex-trust-grant-ledger'
|
||||
import { getCodexHookTrustSignature } from '../definitions/codex-hook-identity'
|
||||
|
||||
@@ -145,44 +143,6 @@ const timedOut: CodexHookAnswer = { kind: 'pending', failure: 'timed out' }
|
||||
const command = (): string => getManagedCommand(getManagedScriptPath())
|
||||
|
||||
describe('managed-home Codex hook approval', () => {
|
||||
it("approves Orca's entry with Codex's hash, enabled, only in the events Codex lists", async () => {
|
||||
seedSystemUserStopHook()
|
||||
useCodexHashes()
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const runtimeHooks = JSON.parse(readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')).hooks
|
||||
expect(Object.keys(runtimeHooks).sort()).toEqual(
|
||||
[
|
||||
'PermissionRequest',
|
||||
'PostToolUse',
|
||||
'PreToolUse',
|
||||
'SessionStart',
|
||||
'Stop',
|
||||
'UserPromptSubmit'
|
||||
].sort()
|
||||
)
|
||||
expect(isCodexManagedCommand(runtimeHooks.Stop[0].hooks[0].command)).toBe(true)
|
||||
expect(runtimeHooks.Stop[1].hooks[0].command).toBe('user-stop.sh')
|
||||
const trust = readHookTrustEntries(join(managedHome(), 'config.toml'))
|
||||
expect(trust.get(managedKey('stop', 0))).toEqual({
|
||||
trustedHash: 'sha256:codex-stop',
|
||||
enabled: true
|
||||
})
|
||||
expect(trust.get(managedKey('subagent_start', 0))).toBeUndefined()
|
||||
expect(
|
||||
trust.get(
|
||||
computeTrustKey({
|
||||
sourcePath: getCodexExplicitHomeHookSourcePath(join(managedHome(), 'hooks.json')),
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 1,
|
||||
handlerIndex: 0,
|
||||
command: 'user-stop.sh'
|
||||
})
|
||||
)?.trustedHash
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('writes the approval before the entry, and takes it back if the entry write fails', async () => {
|
||||
useCodexHashes()
|
||||
const approvedAtWrite: (string | undefined)[] = []
|
||||
@@ -205,106 +165,6 @@ describe('managed-home Codex hook approval', () => {
|
||||
).toBe(undefined)
|
||||
})
|
||||
|
||||
it("keeps only the user's hooks, and says to update Codex, when Codex has no hooks/list", async () => {
|
||||
seedSystemUserStopHook()
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
useAnswer({
|
||||
kind: 'refused',
|
||||
codexVersion: 'codex-cli 0.127.0',
|
||||
failure: 'Codex 0.127.0 is too old for Orca status; update Codex'
|
||||
})
|
||||
|
||||
const status = await service.install()
|
||||
|
||||
const runtimeHooks = JSON.parse(readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')).hooks
|
||||
expect(runtimeHooks.Stop).toEqual([{ hooks: [{ type: 'command', command: 'user-stop.sh' }] }])
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
?.trustedHash
|
||||
).not.toBe('sha256:codex-stop')
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('session_start', 0))
|
||||
).toBeUndefined()
|
||||
expect(status).toMatchObject({
|
||||
state: 'not_installed',
|
||||
detail: expect.stringContaining('update Codex')
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the entry with no approval in each listed event when Codex 0.128 has no approvals', async () => {
|
||||
seedSystemUserStopHook()
|
||||
useAnswer({
|
||||
kind: 'hashes',
|
||||
codexVersion: 'codex-cli 0.128.0',
|
||||
hashes: { stop: null, session_start: null }
|
||||
})
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const runtimeHooks = JSON.parse(readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')).hooks
|
||||
expect(Object.keys(runtimeHooks).sort()).toEqual(['SessionStart', 'Stop'])
|
||||
expect(isCodexManagedCommand(runtimeHooks.Stop[0].hooks[0].command)).toBe(true)
|
||||
expect(runtimeHooks.Stop[1].hooks[0].command).toBe('user-stop.sh')
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it("leaves a user hook's approval in place in an event Codex does not list Orca's entry for", async () => {
|
||||
const systemHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(systemHome, 'hooks.json'),
|
||||
JSON.stringify({
|
||||
hooks: { Interrupt: [{ hooks: [{ type: 'command', command: 'user-interrupt.sh' }] }] }
|
||||
})
|
||||
)
|
||||
const userInterrupt = {
|
||||
eventLabel: 'interrupt' as const,
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'user-interrupt.sh'
|
||||
}
|
||||
upsertHookTrustEntries(join(systemHome, 'config.toml'), [
|
||||
{ ...userInterrupt, sourcePath: join(systemHome, 'hooks.json') }
|
||||
])
|
||||
useAnswer({ kind: 'hashes', codexVersion: 'codex-cli 0.149.0', hashes: CODEX_HASHES })
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const runtimeHooks = JSON.parse(readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')).hooks
|
||||
expect(runtimeHooks.Interrupt).toEqual([
|
||||
{ hooks: [{ type: 'command', command: 'user-interrupt.sh' }] }
|
||||
])
|
||||
const sourcePath = getCodexExplicitHomeHookSourcePath(join(managedHome(), 'hooks.json'))
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(
|
||||
computeTrustKey({ ...userInterrupt, sourcePath })
|
||||
)?.trustedHash
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it("keeps a managed home's approved entry while Codex is not found", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
codex.fingerprintCodex.mockReturnValue(null)
|
||||
|
||||
const status = await service.install()
|
||||
|
||||
expect(listedEventApprovals()).toEqual(CODEX_HASHES)
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
?.trustedHash
|
||||
).toBe('sha256:codex-stop')
|
||||
expect(status).toMatchObject({
|
||||
state: 'installed',
|
||||
detail: expect.stringContaining('Approved by Orca; not yet confirmed by Codex')
|
||||
})
|
||||
})
|
||||
|
||||
it("leaves user trust byte-untouched while approving Orca's entries", async () => {
|
||||
mkdirSync(managedHome(), { recursive: true })
|
||||
const userBlock = [
|
||||
@@ -336,45 +196,6 @@ describe('managed-home Codex hook approval', () => {
|
||||
expect(trust.get(managedKey('session_start', 0))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports why there is no status when Orca has not asked Codex yet', () => {
|
||||
lookupInternals.resetForTesting()
|
||||
|
||||
expect(new CodexHookService().getStatus(managedHome())).toMatchObject({
|
||||
state: 'not_installed',
|
||||
detail: 'Orca has not asked Codex yet'
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a managed home's approved entry when Codex's answer comes after the launch's wait", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
useAnswer(
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({ kind: 'hashes', codexVersion: 'codex-cli 0.160.0', hashes: CODEX_HASHES }),
|
||||
200
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
await service.install(undefined, false)
|
||||
|
||||
expect(listedEventApprovals()).toEqual(CODEX_HASHES)
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
).toEqual({ trustedHash: 'sha256:codex-stop', enabled: true })
|
||||
} finally {
|
||||
await resolveCodexHookAnswer()
|
||||
}
|
||||
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
).toEqual({ trustedHash: 'sha256:codex-stop', enabled: true })
|
||||
})
|
||||
|
||||
it("keeps the managed config.toml loadable when hooks are off and the user's approvals are inline", async () => {
|
||||
const systemHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemHome, { recursive: true })
|
||||
@@ -407,98 +228,6 @@ describe('managed-home Codex hook approval', () => {
|
||||
expect(() => parseToml(readFileSync(join(managedHome(), 'config.toml'), 'utf-8'))).not.toThrow()
|
||||
})
|
||||
|
||||
it("keeps a managed home's approved entry when Codex's answer timed out", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
useAnswer({ kind: 'pending', failure: 'Codex app-server timed out' })
|
||||
|
||||
await service.install()
|
||||
|
||||
expect(listedEventApprovals()).toEqual(CODEX_HASHES)
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
?.trustedHash
|
||||
).toBe('sha256:codex-stop')
|
||||
})
|
||||
|
||||
it("keeps a managed home's approved entry when this process may not ask Codex, as in the CLI", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
lookupInternals.resetForTesting()
|
||||
codex.fingerprintCodex.mockReturnValue('codex-unknown-to-the-cli')
|
||||
|
||||
await service.install()
|
||||
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
?.trustedHash
|
||||
).toBe('sha256:codex-stop')
|
||||
})
|
||||
|
||||
it("mirrors the user's Codex settings into a managed home on its first install", async () => {
|
||||
const systemHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemHome, { recursive: true })
|
||||
writeFileSync(join(systemHome, 'config.toml'), 'model = "user-model"\n')
|
||||
useCodexHashes()
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(readFileSync(join(managedHome(), 'config.toml'), 'utf-8')).toContain(
|
||||
'model = "user-model"'
|
||||
)
|
||||
})
|
||||
|
||||
it('applies the daemon socket guard to a long managed home on its first install', async () => {
|
||||
const longHome = join(homes.userDataDir, 'codex-accounts', 'a'.repeat(100), 'home')
|
||||
useCodexHashes()
|
||||
|
||||
expect((await new CodexHookService().install(longHome)).state).toBe('installed')
|
||||
|
||||
expect(readFileSync(join(longHome, 'config.toml'), 'utf-8')).toContain(
|
||||
CODEX_DAEMON_OVERRIDE_MARKER
|
||||
)
|
||||
})
|
||||
|
||||
it("reports an approval that no longer holds Codex's hash, or that is switched off", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
const stop = {
|
||||
sourcePath: getCodexExplicitHomeHookSourcePath(join(managedHome(), 'hooks.json')),
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: command()
|
||||
}
|
||||
upsertHookTrustEntries(join(managedHome(), 'config.toml'), [
|
||||
{ ...stop, eventLabel: 'stop', trustedHash: 'sha256:stale' },
|
||||
{
|
||||
...stop,
|
||||
eventLabel: 'session_start',
|
||||
trustedHash: CODEX_HASHES.session_start,
|
||||
enabled: false
|
||||
}
|
||||
])
|
||||
|
||||
expect(service.getStatus(managedHome())).toMatchObject({
|
||||
state: 'partial',
|
||||
detail: 'Approval missing, stale or disabled for events: SessionStart, Stop'
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps Codex's answer when hooks are turned off, so turning them on asks nothing", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
await service.remove()
|
||||
expect(service.getStatus(managedHome())).toMatchObject({ state: 'not_installed', detail: null })
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
expect(codex.deriveCodexHookHashes).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("lets hooks turned off during a Codex launch's wait win", async () => {
|
||||
useCodexHashes()
|
||||
const service = new CodexHookService()
|
||||
@@ -549,28 +278,6 @@ describe('managed-home Codex hook approval', () => {
|
||||
?.trustedHash
|
||||
}
|
||||
|
||||
it("approves a fresh home with Orca's hash when Codex answers after the launch's wait", async () => {
|
||||
useAnswer(new Promise(() => {}))
|
||||
|
||||
const status = await new CodexHookService().install(undefined, false)
|
||||
|
||||
const runtimeHooks = JSON.parse(
|
||||
readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')
|
||||
).hooks
|
||||
expect(isCodexManagedCommand(runtimeHooks.Stop[0].hooks[0].command)).toBe(true)
|
||||
expect(
|
||||
readHookTrustEntries(join(managedHome(), 'config.toml')).get(managedKey('stop', 0))
|
||||
).toEqual({
|
||||
trustedHash: orcaStop(),
|
||||
enabled: true
|
||||
})
|
||||
expect(status).toMatchObject({
|
||||
state: 'installed',
|
||||
detail: 'Approved by Orca; not yet confirmed by Codex (waiting for Codex to answer)'
|
||||
})
|
||||
expect(new CodexHookService().getStatus(managedHome()).detail).toBe(status.detail)
|
||||
})
|
||||
|
||||
it("keeps the approval main's grant recorded for Orca's entry until Codex answers", async () => {
|
||||
useAnswer(timedOut)
|
||||
const service = new CodexHookService()
|
||||
@@ -602,70 +309,6 @@ describe('managed-home Codex hook approval', () => {
|
||||
expect(service.getStatus(managedHome()).state).toBe('installed')
|
||||
})
|
||||
|
||||
it("does not report a user hook's approval left at Orca's key as Orca's", async () => {
|
||||
useAnswer(timedOut)
|
||||
const service = new CodexHookService()
|
||||
await service.install()
|
||||
upsertHookTrustEntries(join(managedHome(), 'config.toml'), [
|
||||
{
|
||||
sourcePath: getCodexExplicitHomeHookSourcePath(join(managedHome(), 'hooks.json')),
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: command(),
|
||||
trustedHash: 'sha256:user'
|
||||
}
|
||||
])
|
||||
|
||||
expect(service.getStatus(managedHome())).toMatchObject({
|
||||
state: 'partial',
|
||||
detail: "Orca's hook entry is not approved yet (timed out)"
|
||||
})
|
||||
})
|
||||
|
||||
it("approves a fresh home with Orca's hash while Codex is not found", async () => {
|
||||
codex.fingerprintCodex.mockReturnValue(null)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(stopApproval()).toBe(orcaStop())
|
||||
})
|
||||
|
||||
it('reports an entry left without any approval while Codex has not answered', async () => {
|
||||
useAnswer(timedOut)
|
||||
const service = new CodexHookService()
|
||||
await service.install()
|
||||
writeFileSync(join(managedHome(), 'config.toml'), '')
|
||||
|
||||
expect(service.getStatus(managedHome())).toMatchObject({
|
||||
state: 'partial',
|
||||
detail: "Orca's hook entry is not approved yet (timed out)"
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps each event's approval and gives Orca's hash to the rest until Codex answers", async () => {
|
||||
useAnswer({
|
||||
kind: 'hashes',
|
||||
codexVersion: 'codex-cli 0.131.0',
|
||||
hashes: { stop: CODEX_HASHES.stop }
|
||||
})
|
||||
const service = new CodexHookService()
|
||||
await service.install()
|
||||
useAnswer(timedOut)
|
||||
|
||||
await service.install()
|
||||
|
||||
const runtimeHooks = JSON.parse(
|
||||
readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')
|
||||
).hooks
|
||||
expect(isCodexManagedCommand(runtimeHooks.SessionStart[0].hooks[0].command)).toBe(true)
|
||||
const trust = readHookTrustEntries(join(managedHome(), 'config.toml'))
|
||||
expect(trust.get(managedKey('stop', 0))?.trustedHash).toBe('sha256:codex-stop')
|
||||
expect(trust.get(managedKey('session_start', 0))?.trustedHash).toBe(
|
||||
computeOrcaCodexHookHashes().session_start
|
||||
)
|
||||
})
|
||||
|
||||
it("replaces a user hook's approval left at Orca's key with Orca's hash", async () => {
|
||||
useAnswer(timedOut)
|
||||
const service = new CodexHookService()
|
||||
@@ -686,42 +329,5 @@ describe('managed-home Codex hook approval', () => {
|
||||
|
||||
expect(stopApproval()).toBe(orcaStop())
|
||||
})
|
||||
|
||||
it("replaces Orca's hash with Codex's once Codex answers", async () => {
|
||||
useAnswer(timedOut)
|
||||
const service = new CodexHookService()
|
||||
await service.install()
|
||||
expect(stopApproval()).toBe(orcaStop())
|
||||
|
||||
useCodexHashes()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
expect(stopApproval()).toBe('sha256:codex-stop')
|
||||
})
|
||||
|
||||
it('uses no fallback when Codex answered that it has no hooks/list', async () => {
|
||||
useAnswer({
|
||||
kind: 'refused',
|
||||
codexVersion: 'codex-cli 0.127.0',
|
||||
failure: 'Codex 0.127.0 is too old for Orca status; update Codex'
|
||||
})
|
||||
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(stopApproval()).toBeUndefined()
|
||||
expect(readFileSync(join(managedHome(), 'hooks.json'), 'utf-8')).not.toContain('codex-hook')
|
||||
expect(status).toMatchObject({
|
||||
state: 'not_installed',
|
||||
detail: expect.stringContaining('update Codex')
|
||||
})
|
||||
})
|
||||
|
||||
it("approves with Orca's hash in a process that may not ask Codex, as the offline CLI is", async () => {
|
||||
lookupInternals.resetForTesting()
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
expect(stopApproval()).toBe(orcaStop())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,7 +31,6 @@ vi.mock('os', async (importOriginal) => {
|
||||
import './hook-service'
|
||||
import { CodexHookService } from './codex-hook-service-implementation'
|
||||
import '../definitions/codex-hook-definition'
|
||||
import { buildCodexHookCommand as getManagedCommand } from '../definitions/codex-hook-command-form'
|
||||
import { runExclusivelyForCodexTrustConfig } from '../../trust/toml/codex-trust-config-mutation-queue'
|
||||
|
||||
const homes = setupCodexHookHomes(homedirMock, getPathMock)
|
||||
@@ -99,35 +98,6 @@ describe('CodexHookService', () => {
|
||||
expect(existsSync(managedHooksJsonPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('installs PermissionRequest with trust so Codex approval prompts reach Orca', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
'model = "gpt-5.2-codex"\napproval_policy = "on-request"\n',
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
|
||||
expect(Object.keys(hooksConfig.hooks).sort()).toEqual(localManagedCodexEvents())
|
||||
expect(
|
||||
isCodexManagedCommand(hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command)
|
||||
).toBe(true)
|
||||
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('model = "gpt-5.2-codex"')
|
||||
expect(trustConfig).toContain('approval_policy = "on-request"')
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('reports, instead of writing, approvals a mirrored inline hooks.state cannot take', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
@@ -169,117 +139,6 @@ describe('CodexHookService', () => {
|
||||
expect(existsSync(join(systemCodexHome, 'hooks.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('drops plugin manager metadata from runtime hooks.json during install', async () => {
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(managedCodexHome, 'hooks.json'),
|
||||
`${JSON.stringify({
|
||||
hooks: {},
|
||||
_managed: {
|
||||
'compound-engineering': {
|
||||
Stop: [0]
|
||||
}
|
||||
}
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const hooksConfig = JSON.parse(readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')) as {
|
||||
hooks: Record<string, unknown>
|
||||
_managed?: unknown
|
||||
}
|
||||
expect(hooksConfig._managed).toBeUndefined()
|
||||
expect(Object.keys(hooksConfig)).toEqual(['hooks'])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'wraps the managed hook command when the profile path contains a space (#6078)',
|
||||
async () => {
|
||||
const spaceHome = join(tmpdir(), 'orca home with spaces')
|
||||
mkdirSync(spaceHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(spaceHome)
|
||||
try {
|
||||
const systemCodexHome = join(spaceHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const hooksConfig = JSON.parse(
|
||||
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
|
||||
|
||||
for (const eventName of localManagedCodexEvents()) {
|
||||
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toBe(
|
||||
getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd'))
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
removeTreeSync(spaceHome)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'quotes the script path when the profile contains cmd metacharacters',
|
||||
async () => {
|
||||
const metacharHome = join(tmpdir(), 'orca %ORCA_TEST% ^ home')
|
||||
mkdirSync(metacharHome, { recursive: true })
|
||||
homedirMock.mockReturnValue(metacharHome)
|
||||
try {
|
||||
const systemCodexHome = join(metacharHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const hooksConfig = JSON.parse(
|
||||
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
|
||||
|
||||
for (const eventName of localManagedCodexEvents()) {
|
||||
const command = hooksConfig.hooks[eventName]?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toBe(
|
||||
getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd'))
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
removeTreeSync(metacharHome)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'launches the managed .cmd directly when the profile path is cmd-safe',
|
||||
async () => {
|
||||
const status = await new CodexHookService().install()
|
||||
expect(status.state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const hooksConfig = JSON.parse(
|
||||
readFileSync(join(managedCodexHome, 'hooks.json'), 'utf-8')
|
||||
) as { hooks: Record<string, { hooks?: { command?: string }[] }[]> }
|
||||
|
||||
// Runner temp paths can require the quoting fallback too.
|
||||
const command = hooksConfig.hooks.Stop?.[0]?.hooks?.[0]?.command ?? ''
|
||||
const cmdSafe = /^[A-Za-z0-9_.:\\~-]+$/.test(join(homes.tmpHome, '.orca', 'agent-hooks'))
|
||||
if (cmdSafe) {
|
||||
expect(command).not.toMatch(/powershell/i)
|
||||
expect(command).toMatch(/\/agent-hooks\/codex-hook\.cmd$/)
|
||||
} else {
|
||||
expect(command).toBe(
|
||||
getManagedCommand(join(homedir(), '.orca', 'agent-hooks', 'codex-hook.cmd'))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'posts hook payloads via the curl-based managed script preserving UTF-8 and spaced metadata',
|
||||
async () => {
|
||||
|
||||
@@ -3,10 +3,7 @@ import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import '../../trust/toml/config-toml-trust'
|
||||
import {
|
||||
computeCodexTrustedHash as computeTrustedHash,
|
||||
getExplicitHomeCodexHookSourcePath as getCodexExplicitHomeHookSourcePath
|
||||
} from '../../trust/identity/codex-trust-identity'
|
||||
import { getExplicitHomeCodexHookSourcePath as getCodexExplicitHomeHookSourcePath } from '../../trust/identity/codex-trust-identity'
|
||||
import {
|
||||
escapeTomlBasicString,
|
||||
hookTrustHeader,
|
||||
@@ -63,74 +60,6 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).not.toContain(':stop:0:0')
|
||||
})
|
||||
|
||||
it('removes legacy managed trust entries hashed before hook timeouts existed', async () => {
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeTomlPath = join(managedCodexHome, 'config.toml')
|
||||
const hooksConfig = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
const command = hooksConfig.hooks.PermissionRequest?.[0]?.hooks?.[0]?.command
|
||||
expect(command).toBeDefined()
|
||||
const legacyHash = computeTrustedHash({
|
||||
sourcePath: managedHooksPath,
|
||||
eventLabel: 'permission_request',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: command!
|
||||
})
|
||||
writeFileSync(
|
||||
runtimeTomlPath,
|
||||
[
|
||||
hookTrustHeader(`${managedHooksPath}:permission_request:0:0`),
|
||||
'enabled = true',
|
||||
`trusted_hash = "${legacyHash}"`,
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await service.remove()).state).toBe('not_installed')
|
||||
|
||||
const runtimeToml = readFileSync(runtimeTomlPath, 'utf-8')
|
||||
expect(runtimeToml).not.toContain(':permission_request:0:0')
|
||||
})
|
||||
|
||||
it('mirrors system Codex config while preserving runtime hook trust on hook install', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(join(systemCodexHome, 'config.toml'), 'model = "system-model"\n', 'utf-8')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
mkdirSync(managedCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
join(managedCodexHome, 'config.toml'),
|
||||
[
|
||||
'model = "runtime-model"',
|
||||
'',
|
||||
'[hooks.state."runtime-hook"]',
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:runtime"',
|
||||
''
|
||||
].join('\n'),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const status = await new CodexHookService().install()
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
const trustConfig = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(trustConfig).toContain('model = "system-model"')
|
||||
expect(trustConfig).toContain('[hooks.state."runtime-hook"]')
|
||||
expect(trustConfig).toContain('enabled = false')
|
||||
expect(trustConfig).toContain('trusted_hash = "sha256:runtime"')
|
||||
expect(trustConfig).toContain(':permission_request:0:0')
|
||||
expect(trustConfig).not.toContain('model = "runtime-model"')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'reports legacy forward-slash runtime trust keys as not approved, and canonicalizes them on reinstall',
|
||||
async () => {
|
||||
|
||||
@@ -4,11 +4,7 @@ import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import '../../trust/toml/config-toml-trust'
|
||||
import { upsertHookTrustContent as upsertHookTrustEntriesInContent } from '../../trust/toml/config-toml-hook-trust-edit'
|
||||
import {
|
||||
hookTrustHeader,
|
||||
isCodexManagedCommand,
|
||||
setupCodexHookHomes
|
||||
} from './hook-service-test-harness'
|
||||
import { hookTrustHeader, setupCodexHookHomes } from './hook-service-test-harness'
|
||||
|
||||
const { getPathMock, homedirMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn<(name: string) => string>(),
|
||||
@@ -117,259 +113,6 @@ describe('CodexHookService', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('mirrors trusted system user hook approvals into the runtime CODEX_HOME', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
matcher: '*',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'user-hook',
|
||||
timeout: 12,
|
||||
async: true,
|
||||
statusMessage: 'Running user hook'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'user-hook',
|
||||
timeoutSec: 12,
|
||||
async: true,
|
||||
matcher: '*',
|
||||
statusMessage: 'Running user hook'
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<
|
||||
string,
|
||||
{ matcher?: string; hooks?: { command?: string; statusMessage?: string }[] }[]
|
||||
>
|
||||
}
|
||||
expect(runtimeHooks.hooks.Stop?.[1]?.matcher).toBe('*')
|
||||
expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.command).toBe('user-hook')
|
||||
expect(runtimeHooks.hooks.Stop?.[1]?.hooks?.[0]?.statusMessage).toBe('Running user hook')
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`, true))
|
||||
})
|
||||
|
||||
it('runs managed PostToolUse status before mirrored user hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [{ hooks: [{ type: 'command', command: 'slow-user-post-tool-hook' }] }]
|
||||
}
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'post_tool_use',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'slow-user-post-tool-hook'
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
|
||||
expect(isCodexManagedCommand(runtimeHooks.hooks.PostToolUse?.[0]?.hooks?.[0]?.command)).toBe(
|
||||
true
|
||||
)
|
||||
expect(runtimeHooks.hooks.PostToolUse?.[1]?.hooks?.[0]?.command).toBe(
|
||||
'slow-user-post-tool-hook'
|
||||
)
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:post_tool_use:0:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:post_tool_use:1:0`))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:post_tool_use:0:0`, true))
|
||||
})
|
||||
|
||||
it('mirrors system user hook approvals when the system trust indices are stale', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{ hooks: [{ type: 'command', command: 'first-stop-hook' }] },
|
||||
{ hooks: [{ type: 'command', command: 'second-stop-hook' }] }
|
||||
]
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 0,
|
||||
handlerIndex: 0,
|
||||
command: 'second-stop-hook'
|
||||
},
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 1,
|
||||
handlerIndex: 0,
|
||||
command: 'first-stop-hook'
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:2:0`))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:0:0`, true))
|
||||
expect(runtimeToml).not.toContain(hookTrustHeader(`${systemHooksPath}:stop:1:0`, true))
|
||||
})
|
||||
|
||||
it('skips plugin-placeholder system hooks when mirroring into runtime CODEX_HOME', async () => {
|
||||
const pluginCommands = [
|
||||
'node "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.mjs"',
|
||||
'node "${CLAUDE_PLUGIN_DATA}/scripts/on-stop.mjs"',
|
||||
'node "${PLUGIN_ROOT}/scripts/on-stop.mjs"',
|
||||
'node "${PLUGIN_DATA}/scripts/on-stop.mjs"'
|
||||
]
|
||||
const userCommand = 'user-stop-hook'
|
||||
const stopEventLabel = 'stop' as const
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
hooks: {
|
||||
Stop: [
|
||||
{
|
||||
hooks: [
|
||||
...pluginCommands.map((command) => ({ type: 'command', command })),
|
||||
{ type: 'command', command: userCommand }
|
||||
]
|
||||
}
|
||||
],
|
||||
PreCompact: pluginCommands.map((command) => ({
|
||||
hooks: [{ type: 'command', command }]
|
||||
}))
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
join(systemCodexHome, 'config.toml'),
|
||||
upsertHookTrustEntriesInContent('model = "system-model"\n', [
|
||||
...pluginCommands.map((command, handlerIndex) => ({
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: stopEventLabel,
|
||||
groupIndex: 0,
|
||||
handlerIndex,
|
||||
command
|
||||
})),
|
||||
{
|
||||
sourcePath: systemHooksPath,
|
||||
eventLabel: stopEventLabel,
|
||||
groupIndex: 0,
|
||||
handlerIndex: pluginCommands.length,
|
||||
command: userCommand
|
||||
}
|
||||
]),
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
expect((await new CodexHookService().install()).state).toBe('installed')
|
||||
|
||||
const managedCodexHome = join(homes.userDataDir, 'codex-runtime-home', 'home')
|
||||
const managedHooksPath = join(managedCodexHome, 'hooks.json')
|
||||
const runtimeHooksText = readFileSync(managedHooksPath, 'utf-8')
|
||||
const runtimeHooks = JSON.parse(runtimeHooksText) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
const stopCommands =
|
||||
runtimeHooks.hooks.Stop?.flatMap(
|
||||
(definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []
|
||||
) ?? []
|
||||
|
||||
expect(stopCommands).toContain(userCommand)
|
||||
expect(stopCommands.some((command) => isCodexManagedCommand(command))).toBe(true)
|
||||
expect(runtimeHooks.hooks.PreCompact).toBeUndefined()
|
||||
for (const command of pluginCommands) {
|
||||
expect(runtimeHooksText).not.toContain(command)
|
||||
}
|
||||
|
||||
const runtimeToml = readFileSync(join(managedCodexHome, 'config.toml'), 'utf-8')
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:1:0`))
|
||||
for (const command of pluginCommands) {
|
||||
expect(runtimeToml).not.toContain(command)
|
||||
}
|
||||
})
|
||||
|
||||
it('mirrors compact-event user hook approvals and disabled trust entries', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
@@ -475,42 +218,6 @@ describe('CodexHookService', () => {
|
||||
expect(runtimeToml).toContain(hookTrustHeader(`${managedHooksPath}:stop:0:0`))
|
||||
})
|
||||
|
||||
it('refreshes mirrored system user hooks when the system hooks file changes', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
mkdirSync(systemCodexHome, { recursive: true })
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-old' }] }] }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
|
||||
const service = new CodexHookService()
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
writeFileSync(
|
||||
systemHooksPath,
|
||||
`${JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'user-hook-new' }] }] }
|
||||
})}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
expect((await service.install()).state).toBe('installed')
|
||||
|
||||
const managedHooksPath = join(homes.userDataDir, 'codex-runtime-home', 'home', 'hooks.json')
|
||||
const runtimeHooks = JSON.parse(readFileSync(managedHooksPath, 'utf-8')) as {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
const stopCommands =
|
||||
runtimeHooks.hooks.Stop?.flatMap(
|
||||
(definition) => definition.hooks?.map((hook) => hook.command ?? '') ?? []
|
||||
) ?? []
|
||||
expect(stopCommands).toContain('user-hook-new')
|
||||
expect(stopCommands).not.toContain('user-hook-old')
|
||||
})
|
||||
|
||||
it('refreshes runtime user hooks without installing Orca-managed hooks', async () => {
|
||||
const systemCodexHome = join(homes.tmpHome, '.codex')
|
||||
const systemHooksPath = join(systemCodexHome, 'hooks.json')
|
||||
|
||||
@@ -6,23 +6,13 @@ import { dirname, join } from 'node:path'
|
||||
|
||||
import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../../../agent-hooks/installer-utils'
|
||||
import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from '../../../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
readHookTrustEntries,
|
||||
upsertHookTrustEntries,
|
||||
type CodexTrustEntry
|
||||
} from '../../trust/toml/config-toml-trust'
|
||||
import { readHookTrustEntries, type CodexTrustEntry } from '../../trust/toml/config-toml-trust'
|
||||
import {
|
||||
computeCodexTrustKey as computeTrustKey,
|
||||
computeCodexTrustedHash as computeTrustedHash,
|
||||
normalizeCodexHookTrustLookupKey as normalizeHookTrustKeyForLookup,
|
||||
parseCodexTrustKey as parseTrustKey
|
||||
computeCodexTrustedHash as computeTrustedHash
|
||||
} from '../../trust/identity/codex-trust-identity'
|
||||
import { _internals } from './hook-service'
|
||||
import { CodexHookService } from './codex-hook-service-implementation'
|
||||
import type { CodexWslRuntimeHookInstallPlan } from './codex-wsl-hook-install-plan'
|
||||
import type { CodexHookTrustGrantRequest } from '../../app-server/codex-app-server-client'
|
||||
import { codexAppServerCapabilityCache } from '../../app-server/codex-app-server-capability-cache'
|
||||
import { _internals as trustGrantInternals } from '../../trust/grants/codex-hook-trust-grant'
|
||||
|
||||
type HooksConfig = {
|
||||
hooks: Record<string, { hooks?: { command?: string }[] }[]>
|
||||
@@ -79,75 +69,6 @@ function expectedManagedCommand(scriptPath: string): string {
|
||||
}
|
||||
|
||||
describe('Codex WSL runtime hook install', () => {
|
||||
it('coalesces launch installs for one home without blocking independent homes', async () => {
|
||||
const service = new CodexHookService()
|
||||
const releases: (() => void)[] = []
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
if (!runtimeHomePath) {
|
||||
throw new Error('expected a runtime home')
|
||||
}
|
||||
started.push(runtimeHomePath)
|
||||
await new Promise<void>((resolve) => releases.push(resolve))
|
||||
return {
|
||||
agent: 'codex',
|
||||
state: 'installed',
|
||||
configPath: `${runtimeHomePath}\\hooks.json`,
|
||||
managedHooksPresent: true,
|
||||
detail: null
|
||||
}
|
||||
})
|
||||
const firstHome = '\\\\wsl$\\Ubuntu\\home\\Alice\\.codex'
|
||||
const alias = firstHome.replace('\\\\wsl$', '\\\\wsl.localhost')
|
||||
const independent = firstHome.replace('\\Alice\\', '\\Bob\\')
|
||||
const target = { runtime: 'wsl' as const, wslDistro: 'Ubuntu' }
|
||||
|
||||
const first = service.prepareRuntimeHomeForLaunch(firstHome, target, () => true, false)
|
||||
const second = service.prepareRuntimeHomeForLaunch(alias, target, () => true, false)
|
||||
const third = service.prepareRuntimeHomeForLaunch(independent, target, () => true, false)
|
||||
await vi.waitFor(() => expect(started).toEqual([firstHome, independent]))
|
||||
|
||||
releases.splice(0).forEach((release) => release())
|
||||
await Promise.all([first, second, third])
|
||||
expect(started).toEqual([firstHome, independent])
|
||||
})
|
||||
|
||||
it('does not coalesce one drive-backed home across WSL distros', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (_runtimeHome, target) => {
|
||||
started.push(target?.wslDistro ?? '')
|
||||
return null
|
||||
})
|
||||
const home = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Ubuntu' }),
|
||||
service.installForRuntimeHomeSerialized(home, { runtime: 'wsl', wslDistro: 'Debian' })
|
||||
])
|
||||
|
||||
expect(started).toEqual(['Ubuntu', 'Debian'])
|
||||
})
|
||||
|
||||
it('keeps case-distinct Linux runtime homes on separate queues', async () => {
|
||||
const service = new CodexHookService()
|
||||
const started: string[] = []
|
||||
vi.spyOn(service, 'installForRuntimeHome').mockImplementation(async (runtimeHomePath) => {
|
||||
started.push(runtimeHomePath!)
|
||||
return null
|
||||
})
|
||||
const upper =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\Alice\\.local\\share\\orca\\codex-runtime-home\\home'
|
||||
const lower = upper.replace('\\Alice\\', '\\alice\\')
|
||||
|
||||
await Promise.all([
|
||||
service.installForRuntimeHomeSerialized(upper),
|
||||
service.installForRuntimeHomeSerialized(lower)
|
||||
])
|
||||
|
||||
expect(started).toEqual([upper, lower])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'drains stdin when the WSL runtime script is missing',
|
||||
async () => {
|
||||
@@ -332,111 +253,3 @@ describe('Codex WSL runtime hook install', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex WSL runtime hook install app-server grant lane', () => {
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-wsl-grant-userdata-'))
|
||||
tempRoots.push(userDataDir)
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
trustGrantInternals.setGrantSessionRunner(null)
|
||||
trustGrantInternals.resetDiagnostics()
|
||||
codexAppServerCapabilityCache.clear()
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the unchanged self-computed lane when the WSL grant falls back', async () => {
|
||||
const plan = createTestPlan()
|
||||
writeFileSync(plan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(plan.tomlPath, '', 'utf-8')
|
||||
|
||||
const runner = vi.fn(() => {
|
||||
throw new Error('wsl.exe not reachable')
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(plan))?.state).toBe('installed')
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(1)
|
||||
const command = expectedManagedCommand(plan.commandScriptPath)
|
||||
const managedTrustEntry = getManagedTrustEntry(plan, command)
|
||||
expect(readHookTrustEntries(plan.tomlPath).get(computeTrustKey(managedTrustEntry))).toEqual({
|
||||
enabled: true,
|
||||
trustedHash: computeTrustedHash(managedTrustEntry)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the previous ledger to remove stale Codex hashes after a canonical path change', async () => {
|
||||
const basePlan = createTestPlan()
|
||||
writeFileSync(basePlan.configPath, '{"hooks":{}}\n', 'utf-8')
|
||||
writeFileSync(basePlan.tomlPath, '', 'utf-8')
|
||||
let staleKeyExpectedRemoved: string | null = null
|
||||
const runner = vi.fn(async (request: CodexHookTrustGrantRequest) => {
|
||||
if (staleKeyExpectedRemoved) {
|
||||
expect(readHookTrustEntries(basePlan.tomlPath).has(staleKeyExpectedRemoved)).toBe(false)
|
||||
}
|
||||
const entries = request.expectedTrustKeys.map((key) => {
|
||||
const parsed = parseTrustKey(key)!
|
||||
return {
|
||||
sourcePath: parsed.sourcePath,
|
||||
eventLabel: parsed.eventLabel,
|
||||
groupIndex: parsed.groupIndex,
|
||||
handlerIndex: parsed.handlerIndex,
|
||||
command: request.managedCommand,
|
||||
trustedHash: `sha256:codex-verbatim-${parsed.eventLabel}`
|
||||
}
|
||||
})
|
||||
upsertHookTrustEntries(basePlan.tomlPath, entries)
|
||||
return {
|
||||
outcome: 'granted' as const,
|
||||
wroteTrust: true,
|
||||
entries: request.expectedTrustKeys.map((key) => ({
|
||||
key,
|
||||
normalizedKey: normalizeHookTrustKeyForLookup(key),
|
||||
trustedHash: `sha256:codex-verbatim-${parseTrustKey(key)!.eventLabel}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
trustGrantInternals.setGrantSessionRunner(runner)
|
||||
|
||||
const oldPlan = {
|
||||
...basePlan,
|
||||
commandScriptPath: '/old/home/.orca/agent-hooks/codex-hook.sh',
|
||||
trustConfigPath: '/old/home/hooks.json',
|
||||
linuxRuntimeHome: '/old/home'
|
||||
}
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(oldPlan))?.state).toBe('installed')
|
||||
const oldKey = computeTrustKey(
|
||||
getManagedTrustEntry(oldPlan, expectedManagedCommand(oldPlan.commandScriptPath))
|
||||
)
|
||||
staleKeyExpectedRemoved = oldKey
|
||||
|
||||
const newPlan = {
|
||||
...basePlan,
|
||||
commandScriptPath: '/new/home/.orca/agent-hooks/codex-hook.sh',
|
||||
trustConfigPath: '/new/home/hooks.json',
|
||||
linuxRuntimeHome: '/new/home'
|
||||
}
|
||||
expect((await _internals.installManagedHooksIntoWslRuntime(newPlan))?.state).toBe('installed')
|
||||
const newKey = computeTrustKey(
|
||||
getManagedTrustEntry(newPlan, expectedManagedCommand(newPlan.commandScriptPath))
|
||||
)
|
||||
const trustEntries = readHookTrustEntries(basePlan.tomlPath)
|
||||
|
||||
expect(runner).toHaveBeenCalledTimes(2)
|
||||
expect(trustEntries.has(oldKey)).toBe(false)
|
||||
expect(trustEntries.get(newKey)?.trustedHash).toBe('sha256:codex-verbatim-user_prompt_submit')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -78,7 +77,6 @@ import {
|
||||
} from './codex-hook-reconcile'
|
||||
import { _internals as lookupInternals } from '../../trust/hooks/codex-hook-hash-lookup'
|
||||
import { readRealHomeHooksFileProblem } from './codex-real-home-hooks-json'
|
||||
import { readCurrentCodexHookStatus } from './codex-hook-status'
|
||||
import { writeCodexTrustGrantLedgerHome } from '../../trust/grants/codex-trust-grant-ledger'
|
||||
import { getCodexHookTrustSignature } from '../definitions/codex-hook-identity'
|
||||
import {
|
||||
@@ -224,28 +222,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('reconcileCodexHooks', () => {
|
||||
it('writes the entry last in every listed event, approved, at app start', async () => {
|
||||
writeHooks({ Stop: [{ hooks: [{ type: 'command', command: 'user-stop.sh' }] }] })
|
||||
|
||||
await start()
|
||||
|
||||
expect(readHooks().Stop!.map((group) => group.hooks[0]!.command)).toEqual([
|
||||
'user-stop.sh',
|
||||
command()
|
||||
])
|
||||
const key = computeTrustKey({
|
||||
sourcePath: hooksPath(),
|
||||
eventLabel: 'stop',
|
||||
groupIndex: 1,
|
||||
handlerIndex: 0,
|
||||
command: command()
|
||||
})
|
||||
expect(readHookTrustEntries(tomlPath()).get(key)).toEqual({
|
||||
trustedHash: CODEX_HASHES.stop,
|
||||
enabled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('asks Codex and converts an older build only once PATH is hydrated, with launches waiting on it', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
writeHooks({ Stop: [olderBuildStop()] })
|
||||
@@ -305,15 +281,6 @@ describe('reconcileCodexHooks', () => {
|
||||
expect(readHooks().Stop).toEqual([{ hooks: [buildCodexManagedHook(command(), 'Stop')] }])
|
||||
})
|
||||
|
||||
it('writes nothing for a launch while hooks are off', async () => {
|
||||
enabled = false
|
||||
await start()
|
||||
|
||||
await reconcileCodexHooksForLaunch()
|
||||
|
||||
expect(existsSync(codexHome())).toBe(false)
|
||||
})
|
||||
|
||||
it('writes nothing and spawns nothing across many concurrent launches when nothing changed', async () => {
|
||||
await start()
|
||||
const before = snapshot(codexHome())
|
||||
@@ -334,56 +301,6 @@ describe('reconcileCodexHooks', () => {
|
||||
expect(mocks.deriveCodexHookHashes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs again once for every call made while one runs', async () => {
|
||||
await start()
|
||||
mocks.realHomeRuns = 0
|
||||
let release!: () => void
|
||||
mocks.holdRealHome = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const first = reconcileCodexHooks()
|
||||
await vi.waitFor(() => expect(mocks.realHomeRuns).toBe(1))
|
||||
|
||||
const later = [2, 3, 4, 5].map(() => reconcileCodexHooks())
|
||||
mocks.holdRealHome = null
|
||||
release()
|
||||
await Promise.all([first, ...later])
|
||||
|
||||
expect(mocks.realHomeRuns).toBe(2)
|
||||
})
|
||||
|
||||
it('runs once for one spawn, however many env builders it goes through', async () => {
|
||||
await start()
|
||||
mocks.realHomeRuns = 0
|
||||
|
||||
scheduleCodexHookReconcile()
|
||||
scheduleCodexHookReconcile()
|
||||
scheduleCodexHookReconcile()
|
||||
await settleSpawn()
|
||||
|
||||
expect(mocks.realHomeRuns).toBe(1)
|
||||
})
|
||||
|
||||
it('lets a spawn ride a reconcile already running, which reads the files after it', async () => {
|
||||
await start()
|
||||
mocks.realHomeRuns = 0
|
||||
let release!: () => void
|
||||
mocks.holdRealHome = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const running = reconcileCodexHooks()
|
||||
await vi.waitFor(() => expect(mocks.realHomeRuns).toBe(1))
|
||||
|
||||
scheduleCodexHookReconcile()
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
mocks.holdRealHome = null
|
||||
release()
|
||||
await running
|
||||
await settleSpawn()
|
||||
|
||||
expect(mocks.realHomeRuns).toBe(1)
|
||||
})
|
||||
|
||||
it('rechecks a spawn that lands while a run is still finding Codex, which may miss a fresh install', async () => {
|
||||
const installed = mocks.codexPath
|
||||
mocks.codexPath = join(userData, 'missing-codex')
|
||||
@@ -438,80 +355,6 @@ describe('reconcileCodexHooks', () => {
|
||||
expect(readHooks().Stop).toEqual([{ hooks: [buildCodexManagedHook(command(), 'Stop')] }])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['hooks are off', () => (enabled = false), () => (enabled = true)],
|
||||
['a managed account is selected', () => (usesRealHome = false), () => (usesRealHome = true)]
|
||||
])(
|
||||
'keeps an app-start conversion while %s, for the first run that writes ~/.codex',
|
||||
async (_case, block, unblock) => {
|
||||
writeHooks({ Stop: [olderBuildStop()] })
|
||||
block()
|
||||
await start()
|
||||
|
||||
unblock()
|
||||
scheduleCodexHookReconcile()
|
||||
await settleSpawn()
|
||||
|
||||
expect(readHooks().Stop).toEqual([{ hooks: [buildCodexManagedHook(command(), 'Stop')] }])
|
||||
}
|
||||
)
|
||||
|
||||
it("writes nothing while the next pane's home is not known yet", async () => {
|
||||
startCodexHooks({
|
||||
isEnabled: () => true,
|
||||
resolveLaunchHome: () => {
|
||||
throw new Error('not ready')
|
||||
},
|
||||
pathReady: Promise.resolve()
|
||||
})
|
||||
await _internals.settledForTesting()
|
||||
|
||||
expect(existsSync(codexHome())).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves ~/.codex untouched while a managed account or custom CODEX_HOME is selected', async () => {
|
||||
usesRealHome = false
|
||||
|
||||
await start()
|
||||
scheduleCodexHookReconcile()
|
||||
await settleSpawn()
|
||||
|
||||
expect(existsSync(codexHome())).toBe(false)
|
||||
})
|
||||
|
||||
it('still writes for a launch that runs on ~/.codex whatever the selection', async () => {
|
||||
usesRealHome = false
|
||||
await start()
|
||||
|
||||
await reconcileCodexHooksForLaunch()
|
||||
|
||||
expect(Object.keys(readHooks())).toContain('Stop')
|
||||
})
|
||||
|
||||
it('writes nothing for a Codex without hooks/list', async () => {
|
||||
mocks.deriveCodexHookHashes.mockResolvedValue({
|
||||
kind: 'refused',
|
||||
codexVersion: 'codex-cli 0.120.0',
|
||||
failure: 'Codex 0.120.0 is too old for Orca status; update Codex'
|
||||
})
|
||||
|
||||
await start()
|
||||
|
||||
expect(existsSync(codexHome())).toBe(false)
|
||||
})
|
||||
|
||||
it('creates no ~/.codex while Codex cannot be found, and says so', async () => {
|
||||
mocks.codexPath = join(userData, 'missing-codex')
|
||||
|
||||
await start()
|
||||
await reconcileCodexHooksForLaunch()
|
||||
|
||||
expect(existsSync(codexHome())).toBe(false)
|
||||
expect(readCurrentCodexHookStatus().detail).toBe(
|
||||
`Orca could not find Codex at ${mocks.codexPath}`
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves Orca's entry and approvals in ~/.codex as they are while Codex cannot be found", async () => {
|
||||
await start()
|
||||
writeHooks({ Stop: [olderBuildStop()] })
|
||||
@@ -522,39 +365,6 @@ describe('reconcileCodexHooks', () => {
|
||||
|
||||
expect(snapshot(codexHome())).toEqual(before)
|
||||
})
|
||||
|
||||
it('never throws, and does nothing outside the app', async () => {
|
||||
writeHooks({ Stop: [] })
|
||||
await expect(reconcileCodexHooks({ convertOlderForms: true })).resolves.toBeUndefined()
|
||||
expect(readHooks()).toEqual({ Stop: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("warming Codex's answer at app start", () => {
|
||||
it('asks once the shell PATH is hydrated, while panes use a managed home too', async () => {
|
||||
usesRealHome = false
|
||||
let hydrate: () => void = () => {}
|
||||
const started = start(
|
||||
new Promise<void>((resolve) => {
|
||||
hydrate = resolve
|
||||
})
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
expect(mocks.probeCodexVersion).not.toHaveBeenCalled()
|
||||
|
||||
hydrate()
|
||||
await started
|
||||
|
||||
await vi.waitFor(() => expect(mocks.probeCodexVersion).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('does not ask while hooks are off', async () => {
|
||||
enabled = false
|
||||
await start()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
expect(mocks.probeCodexVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('while the lookup is still asking Codex', () => {
|
||||
@@ -642,31 +452,6 @@ describe('a ~/.codex/hooks.json Orca cannot add to', () => {
|
||||
expect(readRealHomeHooksFileProblem()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('is not the case with no hooks.json at all', () => {
|
||||
expect(readRealHomeHooksFileProblem()).toBeNull()
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'is not the case for an unreadable hooks.json',
|
||||
() => {
|
||||
writeHooks({})
|
||||
chmodSync(hooksPath(), 0o000)
|
||||
|
||||
expect(readRealHomeHooksFileProblem()).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('is not the case after a write Orca could not make', async () => {
|
||||
writeHooks({})
|
||||
writeFileSync(tomlPath(), 'model = "m"\nhooks = { state = {} }\n')
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await start()
|
||||
|
||||
expect(readHooks()).toEqual({})
|
||||
expect(readRealHomeHooksFileProblem()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("a ~/.codex/hooks.json with Codex's description key", () => {
|
||||
|
||||
@@ -62,7 +62,6 @@ import { reconcileRealHomeCodexHookEntries } from './codex-real-home-hook-instal
|
||||
import {
|
||||
buildCodexManagedHook,
|
||||
CODEX_EVENT_LABEL,
|
||||
computeOrcaCodexHookHashes,
|
||||
getCodexManagedHookInstallMaterial
|
||||
} from '../definitions/codex-hook-definition'
|
||||
import { createCodexHookTrustEntry } from '../definitions/codex-hook-identity'
|
||||
@@ -232,18 +231,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(existsSync(configPath())).toBe(false)
|
||||
})
|
||||
|
||||
it("leaves an older build's event alone on a launch, and adds Orca's entry to the rest", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A, orcaGroup(olderBuildCommand())] } })
|
||||
|
||||
expect(await reconcile({ convertOlderForms: false })).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, orcaGroup(olderBuildCommand())])
|
||||
expect(readHooks().hooks.SessionStart).toEqual([
|
||||
{ hooks: [buildCodexManagedHook(frozen(), 'SessionStart')] }
|
||||
])
|
||||
expect(trustAt(stopEntryAt(1, orcaGroup()))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never rewrites a newer build form or appends beside it', async () => {
|
||||
const newer = `: orca-agent-hook-form=2; /bin/sh "\${HOME-}/.orca/agent-hooks/codex-hook.sh"`
|
||||
writeHooks({ hooks: { Stop: [orcaGroup(newer)] } })
|
||||
@@ -253,15 +240,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(readHooks().hooks.Stop).toEqual([orcaGroup(newer)])
|
||||
})
|
||||
|
||||
it('keeps Orca entries in events this build does not subscribe to', async () => {
|
||||
const otherEvent = [orcaGroup()]
|
||||
writeHooks({ hooks: { PreCompact: otherEvent } })
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.PreCompact).toEqual(otherEvent)
|
||||
})
|
||||
|
||||
it("keeps the approval of Orca's entry in an event this Codex does not list", async () => {
|
||||
const interrupt: HookDefinition = { hooks: [buildCodexManagedHook(frozen(), 'Interrupt')] }
|
||||
writeHooks({ hooks: { Interrupt: [interrupt] } })
|
||||
@@ -287,14 +265,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(trustAt(approved)).toEqual({ trustedHash: CODEX_HASHES.interrupt, enabled: true })
|
||||
})
|
||||
|
||||
it('adds an entry only in events Codex lists', async () => {
|
||||
writeHooks({ hooks: {} })
|
||||
|
||||
await reconcile({ hashes: { stop: 'sha256:codex-stop' } })
|
||||
|
||||
expect(Object.keys(readHooks().hooks)).toEqual(['Stop'])
|
||||
})
|
||||
|
||||
it('keeps one copy, alone and last, moving the approvals of user hooks that shift', async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A, orcaGroup(), USER_B, orcaGroup(), USER_C, orcaGroup()] } })
|
||||
upsertHookTrustEntries(configPath(), [
|
||||
@@ -313,37 +283,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(await reconcile()).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('keeps the kept copy in its place when an earlier copy leaves its group empty', async () => {
|
||||
const matched: HookDefinition = { matcher: 'Bash', hooks: orcaGroup().hooks }
|
||||
writeHooks({
|
||||
hooks: {
|
||||
Stop: [matched, USER_A, { hooks: [...orcaGroup().hooks!, ...orcaGroup().hooks!] }, USER_B]
|
||||
}
|
||||
})
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, orcaGroup(), USER_B])
|
||||
expectOrcaApprovedAt(1)
|
||||
})
|
||||
|
||||
it("drops a hook that runs Orca's script through its args, as the opt-out does", async () => {
|
||||
const viaArgs: HookDefinition = {
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: '/bin/sh',
|
||||
args: [getCodexManagedHookInstallMaterial().scriptPath]
|
||||
}
|
||||
]
|
||||
}
|
||||
writeHooks({ hooks: { Stop: [USER_A, viaArgs, orcaGroup()] } })
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, orcaGroup()])
|
||||
})
|
||||
|
||||
it("moves Orca's handler out of a user's matcher group, with the shifted user approvals", async () => {
|
||||
const userGroup: HookDefinition = {
|
||||
matcher: 'Bash',
|
||||
@@ -408,48 +347,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(trustAt(leftCopy)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('moves an entry alone in a matcher group out, since Codex hashes the matcher', async () => {
|
||||
const matched: HookDefinition = {
|
||||
matcher: 'Bash',
|
||||
hooks: [buildCodexManagedHook(frozen(), 'PreToolUse')]
|
||||
}
|
||||
writeHooks({ hooks: { PreToolUse: [matched] } })
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.PreToolUse).toEqual([
|
||||
{ hooks: [buildCodexManagedHook(frozen(), 'PreToolUse')] }
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps the approval of this build's copy in an event left to an older build", async () => {
|
||||
writeHooks({ hooks: { Stop: [orcaGroup(olderBuildCommand()), orcaGroup()] } })
|
||||
const approved = {
|
||||
...stopEntryAt(1, orcaGroup()),
|
||||
trustedHash: CODEX_HASHES.stop,
|
||||
enabled: true
|
||||
}
|
||||
upsertHookTrustEntries(configPath(), [approved])
|
||||
|
||||
await reconcile({ convertOlderForms: false })
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([orcaGroup(olderBuildCommand()), orcaGroup()])
|
||||
expect(trustAt(approved)).toEqual({ trustedHash: CODEX_HASHES.stop, enabled: true })
|
||||
})
|
||||
|
||||
it('rewrites an edited entry in place and approves what it wrote', async () => {
|
||||
const edited: HookDefinition = {
|
||||
hooks: [{ ...buildCodexManagedHook(frozen(), 'Stop'), timeout: 99 }]
|
||||
}
|
||||
writeHooks({ hooks: { Stop: [edited, USER_A] } })
|
||||
upsertHookTrustEntries(configPath(), [{ ...stopEntryAt(0, edited), trustedHash: 'sha256:old' }])
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([orcaGroup(), USER_A])
|
||||
expectOrcaApprovedAt(0)
|
||||
})
|
||||
|
||||
it("re-approves after a user hook is inserted ahead, keeping the user's approvals", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
await reconcile()
|
||||
@@ -467,46 +364,6 @@ describe('reconcileRealHomeCodexHookEntries', () => {
|
||||
expect(await reconcile()).toBe('unchanged')
|
||||
})
|
||||
|
||||
it("rewrites only the approval when Codex's hash changes", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
await reconcile()
|
||||
const hooksBefore = identity(hooksPath())
|
||||
const updated = { ...CODEX_HASHES, stop: 'sha256:codex-next-stop' }
|
||||
|
||||
expect(await reconcile({ hashes: updated })).toBe('written')
|
||||
|
||||
expect(identity(hooksPath())).toEqual(hooksBefore)
|
||||
expectOrcaApprovedAt(1, 'sha256:codex-next-stop')
|
||||
expect(await reconcile({ hashes: updated })).toBe('unchanged')
|
||||
})
|
||||
|
||||
it('turns an approval a /hooks toggle disabled back on, once', async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
await reconcile()
|
||||
upsertHookTrustEntries(configPath(), [
|
||||
{ ...stopEntryAt(1, orcaGroup()), trustedHash: CODEX_HASHES.stop, enabled: false }
|
||||
])
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
|
||||
expectOrcaApprovedAt(1)
|
||||
expect(await reconcile()).toBe('unchanged')
|
||||
})
|
||||
|
||||
it("keeps user hooks' positions and approvals while appending Orca's entry", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A, USER_B] } })
|
||||
upsertHookTrustEntries(configPath(), [
|
||||
{ ...stopEntryAt(1, USER_B), trustedHash: 'sha256:user' }
|
||||
])
|
||||
const userToml = readFileSync(configPath(), 'utf-8')
|
||||
|
||||
await reconcile()
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, USER_B, orcaGroup()])
|
||||
expect(readFileSync(configPath(), 'utf-8').startsWith(userToml)).toBe(true)
|
||||
expect(trustAt(stopEntryAt(1, USER_B))?.trustedHash).toBe('sha256:user')
|
||||
})
|
||||
|
||||
it('reads again when the user saves hooks.json mid-write', async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
mocks.beforeHooksJsonGuard = () => {
|
||||
@@ -591,47 +448,4 @@ describe('until Codex answers', () => {
|
||||
|
||||
expect({ hooks: identity(hooksPath()), toml: identity(configPath()) }).toEqual(before)
|
||||
})
|
||||
|
||||
it("approves a new entry with Orca's own hash, which Codex's answer then replaces", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
|
||||
expect(await reconcile({ hashes: null })).toBe('written')
|
||||
expectOrcaApprovedAt(1, computeOrcaCodexHookHashes().stop)
|
||||
|
||||
expect(await reconcile()).toBe('written')
|
||||
expectOrcaApprovedAt(1)
|
||||
})
|
||||
|
||||
it("approves an entry it rewrites in place with Orca's own hash, not the edited copy's", async () => {
|
||||
const edited: HookDefinition = {
|
||||
hooks: [{ ...buildCodexManagedHook(frozen(), 'Stop'), timeout: 99 }]
|
||||
}
|
||||
writeHooks({ hooks: { Stop: [edited, USER_A] } })
|
||||
upsertHookTrustEntries(configPath(), [{ ...stopEntryAt(0, edited), trustedHash: 'sha256:old' }])
|
||||
|
||||
expect(await reconcile({ hashes: null })).toBe('written')
|
||||
|
||||
expectOrcaApprovedAt(0, computeOrcaCodexHookHashes().stop)
|
||||
})
|
||||
|
||||
it('writes the entry with no approval for a Codex that lists it without a hash', async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A] } })
|
||||
|
||||
expect(await reconcile({ hashes: { stop: null } })).toBe('written')
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, orcaGroup()])
|
||||
expect(readHookTrustEntries(configPath()).size).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps an approval at Orca's entry for a Codex that lists it without a hash", async () => {
|
||||
writeHooks({ hooks: { Stop: [USER_A, orcaGroup()] } })
|
||||
upsertHookTrustEntries(configPath(), [
|
||||
{ ...stopEntryAt(1, orcaGroup()), trustedHash: CODEX_HASHES.stop, enabled: true }
|
||||
])
|
||||
|
||||
await reconcile({ hashes: { ...CODEX_HASHES, stop: null } })
|
||||
|
||||
expect(readHooks().hooks.Stop).toEqual([USER_A, orcaGroup()])
|
||||
expectOrcaApprovedAt(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,20 +156,6 @@ describe('removeRealHomeCodexHookForOptOut', () => {
|
||||
expect(trust.get(computeTrustKey(afterAt(2)))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("removes Orca's approval holding Codex's own hash, which Orca cannot compute", async () => {
|
||||
mkdirSync(join(home, '.codex'))
|
||||
writeFileSync(
|
||||
hooksJsonPath(),
|
||||
`${JSON.stringify({ hooks: { Stop: [{ hooks: [orcaHandler()] }] } }, null, 2)}\n`
|
||||
)
|
||||
const orcaAt = orcaStopAt(hooksJsonPath())
|
||||
upsertHookTrustEntries(configTomlPath(), [{ ...orcaAt, trustedHash: 'sha256:codex-stop' }])
|
||||
|
||||
expect(await removeRealHomeCodexHookForOptOut([{ stop: 'sha256:codex-stop' }])).toBe('removed')
|
||||
|
||||
expect(readHookTrustEntries(configTomlPath()).has(computeTrustKey(orcaAt))).toBe(false)
|
||||
})
|
||||
|
||||
it("removes approvals left after the entry is gone, by Codex's hash", async () => {
|
||||
mkdirSync(join(home, '.codex'))
|
||||
writeFileSync(hooksJsonPath(), `${JSON.stringify({ hooks: {} }, null, 2)}\n`)
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
||||
import {
|
||||
readCodexBackgroundTaskFrame,
|
||||
type CodexBackgroundTaskEvent
|
||||
} from './codex-background-task-frames'
|
||||
|
||||
const PRIMARY = 'parent-thread'
|
||||
const PARENT_TURN = 'parent-turn'
|
||||
const CHILD = 'child-thread'
|
||||
const CHILD_TURN = 'child-turn'
|
||||
|
||||
function turn(
|
||||
method: 'turn/started' | 'turn/completed',
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
status = 'completed'
|
||||
): CodexBackgroundTaskEvent {
|
||||
return { method, threadId, params: { threadId, turn: { id: turnId, status } } }
|
||||
}
|
||||
|
||||
function activity(
|
||||
kind = 'started',
|
||||
parentTurn = PARENT_TURN,
|
||||
child = CHILD,
|
||||
name = 'count_a'
|
||||
): CodexBackgroundTaskEvent {
|
||||
return {
|
||||
method: 'item/started',
|
||||
threadId: PRIMARY,
|
||||
params: {
|
||||
threadId: PRIMARY,
|
||||
turnId: parentTurn,
|
||||
item: {
|
||||
type: 'subAgentActivity',
|
||||
id: `activity-${kind}`,
|
||||
kind,
|
||||
agentThreadId: child,
|
||||
agentPath: `/root/${name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runningChild(): CodexBackgroundTaskTracker {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity())
|
||||
return tracker
|
||||
}
|
||||
|
||||
function command(
|
||||
threadId = PRIMARY,
|
||||
method = 'item/started',
|
||||
commandText = 'sleep 90'
|
||||
): CodexBackgroundTaskEvent {
|
||||
return {
|
||||
method,
|
||||
threadId,
|
||||
params: {
|
||||
threadId,
|
||||
turnId: threadId === PRIMARY ? PARENT_TURN : CHILD_TURN,
|
||||
item: {
|
||||
type: 'commandExecution',
|
||||
id: 'exec-1',
|
||||
processId: '71831',
|
||||
source: 'unifiedExecStartup',
|
||||
command: commandText,
|
||||
status: method === 'item/started' ? 'inProgress' : 'completed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('readCodexBackgroundTaskFrame', () => {
|
||||
it('reads activity as child metadata without inferring execution state', () => {
|
||||
expect(readCodexBackgroundTaskFrame(activity('interacted'), PRIMARY)).toEqual({
|
||||
kind: 'subagents',
|
||||
children: [{ agentThreadId: CHILD, label: 'count_a' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('reads a child turn with its own execution identity', () => {
|
||||
expect(readCodexBackgroundTaskFrame(turn('turn/started', CHILD, CHILD_TURN), PRIMARY)).toEqual({
|
||||
kind: 'turn',
|
||||
threadId: CHILD,
|
||||
turnId: CHILD_TURN,
|
||||
state: 'working'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not register the primary thread even when its activity path is missing', () => {
|
||||
const event = activity('interacted', PARENT_TURN, PRIMARY)
|
||||
;(event.params as { item: { agentPath?: string } }).item.agentPath = undefined
|
||||
expect(readCodexBackgroundTaskFrame(event, PRIMARY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodexBackgroundTaskTracker child execution ownership', () => {
|
||||
it('does not claim work from an activity item without a child turn', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(activity())
|
||||
tracker.observe(activity('interacted'))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an executing child while the spawning turn is still open', () => {
|
||||
const tracker = runningChild()
|
||||
const running = {
|
||||
state: 'monitoring',
|
||||
supportsStopAll: false,
|
||||
tasks: [{ id: `codex-agent:${CHILD}`, kind: 'agent', description: 'count_a' }]
|
||||
}
|
||||
expect(tracker.state).toEqual(running)
|
||||
expect(tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))).toBe(false)
|
||||
expect(tracker.state).toEqual(running)
|
||||
})
|
||||
|
||||
it('never settles a child when a primary turn ends', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
for (let index = 0; index < 300; index++) {
|
||||
expect(tracker.observe(turn('turn/completed', PRIMARY, `later-${index}`))).toBe(false)
|
||||
}
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['completed', 'interrupted', 'failed'])(
|
||||
'settles on the matching child turn %s',
|
||||
(status) => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, status))).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not mistake late activity completion for the current child execution', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(activity('completed'))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([PARENT_TURN, 'followup-parent'])(
|
||||
'reports follow-up work in %s using the new child turn',
|
||||
(parentTurn) => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', PRIMARY, parentTurn))
|
||||
tracker.observe(activity('interacted', parentTurn))
|
||||
expect(tracker.state).toBeNull()
|
||||
tracker.observe(turn('turn/started', CHILD, 'followup-child-turn'))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, parentTurn))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('completed'))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
tracker.observe(turn('turn/completed', CHILD, 'followup-child-turn'))
|
||||
expect(tracker.state).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps idle send_message activity out of the strip', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(activity('interacted', 'message-parent'))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('does not invent another execution for a message to a working child', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(activity('interacted', 'message-parent'))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, 'message-parent'))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('retains completion delivered before child registration', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity())
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('publishes no extra state for duplicate owner or metadata events', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.observe(turn('turn/started', CHILD, CHILD_TURN))).toBe(false)
|
||||
expect(tracker.observe({ ...activity(), method: 'item/completed' })).toBe(false)
|
||||
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(true)
|
||||
expect(tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds retained child history while allowing repeated completed runs', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(activity())
|
||||
for (let index = 0; index < 300; index++) {
|
||||
const id = `child-turn-${index}`
|
||||
tracker.observe(turn('turn/started', CHILD, id))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
tracker.observe(turn('turn/completed', CHILD, id))
|
||||
expect(tracker.state).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the roster at session teardown', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.clear()).toBe(true)
|
||||
expect(tracker.state).toBeNull()
|
||||
expect(tracker.clear()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodexBackgroundTaskTracker command integration', () => {
|
||||
it('reveals a surviving primary shell after its own turn ends', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
const shell = [{ id: 'codex-command:primary:exec-1', kind: 'command', description: 'sleep 90' }]
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(command())
|
||||
expect(tracker.state).toBeNull()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.state?.tasks).toEqual(shell)
|
||||
tracker.observe(command(PRIMARY, 'item/completed'))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals a child shell only after the child execution finishes', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(command(CHILD))
|
||||
expect(tracker.state?.tasks).toHaveLength(1)
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN, 'interrupted'))
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
{
|
||||
id: `codex-command:thread:${CHILD}:exec-1`,
|
||||
kind: 'command',
|
||||
description: 'count_a — sleep 90'
|
||||
}
|
||||
])
|
||||
tracker.observe(command(CHILD, 'item/completed'))
|
||||
expect(tracker.state).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a primary shell unqualified', () => {
|
||||
const tracker = runningChild()
|
||||
tracker.observe(command(PRIMARY))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
expect(tracker.state?.tasks).toContainEqual({
|
||||
id: 'codex-command:primary:exec-1',
|
||||
kind: 'command',
|
||||
description: 'sleep 90'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the command visible under a label that would otherwise fill the row', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(600)))
|
||||
tracker.observe(command(CHILD))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description
|
||||
expect(description).toContain('sleep 90')
|
||||
expect(description).toBe(`${'L'.repeat(95)}… — sleep 90`)
|
||||
})
|
||||
|
||||
it('never cuts a label mid surrogate pair', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, `${'L'.repeat(94)}\u{1F600}bad`))
|
||||
tracker.observe(command(CHILD))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
expect(description).toBe(`${'L'.repeat(94)}… — sleep 90`)
|
||||
})
|
||||
|
||||
it('never cuts a qualified command mid surrogate pair', () => {
|
||||
// The second bound clips the composed row; bounding only the label leaves a lone surrogate.
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(activity('started', PARENT_TURN, CHILD, 'L'.repeat(96)))
|
||||
// Places the pair exactly where a raw slice of the composed row splits it.
|
||||
tracker.observe(command(CHILD, 'item/started', `${'C'.repeat(412)}\u{1F600}${'D'.repeat(200)}`))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.length).toBeLessThanOrEqual(512)
|
||||
expect(description.startsWith(`${'L'.repeat(96)} — `)).toBe(true)
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
})
|
||||
|
||||
it('never cuts an unqualified primary command mid surrogate pair', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
// The pair straddles the raw description bound itself.
|
||||
tracker.observe(
|
||||
command(PRIMARY, 'item/started', `${'C'.repeat(511)}\u{1F600}${'D'.repeat(50)}`)
|
||||
)
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
const description = tracker.state?.tasks?.[0]?.description ?? ''
|
||||
expect(description.length).toBeLessThanOrEqual(512)
|
||||
expect(description.isWellFormed()).toBe(true)
|
||||
})
|
||||
|
||||
it('names a child shell whose label only arrives after the command', () => {
|
||||
const tracker = new CodexBackgroundTaskTracker(PRIMARY)
|
||||
tracker.observe(turn('turn/started', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(turn('turn/started', CHILD, CHILD_TURN))
|
||||
tracker.observe(command(CHILD))
|
||||
tracker.observe(turn('turn/completed', PRIMARY, PARENT_TURN))
|
||||
tracker.observe(activity())
|
||||
tracker.observe(turn('turn/completed', CHILD, CHILD_TURN))
|
||||
expect(tracker.state?.tasks).toEqual([
|
||||
{
|
||||
id: `codex-command:thread:${CHILD}:exec-1`,
|
||||
kind: 'command',
|
||||
description: 'count_a — sleep 90'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentChildWorkEvidence } from '../../../../shared/agent-status/child-work/agent-status-child-work-evidence'
|
||||
import { codexPrimaryCommandTaskId } from '../../../../shared/structured-session-foreground-commands'
|
||||
import { CodexAppServerRequestError } from '../../app-server/codex-app-server-request-error'
|
||||
import type { CodexAppServerConnection } from '../../app-server/codex-app-server-connection-types'
|
||||
import {
|
||||
CodexAppServerTimeoutError,
|
||||
CodexAppServerUnsupportedError
|
||||
} from '../../app-server/codex-app-server-session'
|
||||
import {
|
||||
probeCodexBackgroundTerminals,
|
||||
terminateCodexBackgroundTerminals
|
||||
} from './codex-background-terminals'
|
||||
import { terminateCodexBackgroundTerminals } from './codex-background-terminals'
|
||||
import {
|
||||
acquireReadyCodexForTest,
|
||||
adapterFor,
|
||||
@@ -26,26 +18,6 @@ const TASK_ID = codexPrimaryCommandTaskId('exec-1')
|
||||
|
||||
type Call = { method: string; params?: Record<string, unknown> }
|
||||
|
||||
function unknownVariant(method: string): CodexAppServerRequestError {
|
||||
const message = `Invalid request: unknown variant \`${method}\``
|
||||
return new CodexAppServerRequestError(
|
||||
method,
|
||||
-32600,
|
||||
`codex app-server ${method} failed: ${message}`,
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
function threadNotFound(method: string): CodexAppServerRequestError {
|
||||
const message = `thread not found: ${THREAD_ID}`
|
||||
return new CodexAppServerRequestError(
|
||||
method,
|
||||
-32600,
|
||||
`codex app-server ${method} failed: ${message}`,
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
function fakeRpc(routes: Record<string, Route>): {
|
||||
rpc: Pick<CodexAppServerConnection, 'request'>
|
||||
calls: Call[]
|
||||
@@ -104,148 +76,7 @@ async function backgroundedDevServer(codex: ReturnType<typeof codexWith>): Promi
|
||||
})
|
||||
}
|
||||
|
||||
function lastCommandStoppable(evidence: AgentChildWorkEvidence[]): boolean | undefined {
|
||||
const live = evidence.filter(
|
||||
(edge): edge is Extract<AgentChildWorkEvidence, { type: 'live' }> =>
|
||||
edge.type === 'live' && edge.child.handle.id === TASK_ID
|
||||
)
|
||||
return live.at(-1)?.child.stoppable
|
||||
}
|
||||
|
||||
describe('Codex background-command Stop', () => {
|
||||
it('offers a Stop once the app-server lists background terminals, and terminates by process id', async () => {
|
||||
let running = [PROCESS_ID]
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () => ({
|
||||
data: running.map((processId) => ({ itemId: 'exec-1', processId, command: 'pnpm dev' })),
|
||||
nextCursor: null
|
||||
}),
|
||||
'thread/backgroundTerminals/terminate': (params) => {
|
||||
running = running.filter((processId) => processId !== params?.processId)
|
||||
return { terminated: true }
|
||||
}
|
||||
})
|
||||
await backgroundedDevServer(codex)
|
||||
|
||||
expect(
|
||||
codex.calls().filter((call) => call.method === 'thread/backgroundTerminals/list')
|
||||
).toEqual([
|
||||
{ method: 'thread/backgroundTerminals/list', params: { threadId: THREAD_ID, limit: 1 } }
|
||||
])
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
|
||||
supportsTaskStop: true,
|
||||
supportsStopAll: true
|
||||
})
|
||||
expect(lastCommandStoppable(codex.evidence)).toBe(true)
|
||||
|
||||
await expect(
|
||||
codex.adapter.stopBackgroundTasks({ sessionId: 'session-1', fence: 7, taskIds: [TASK_ID] })
|
||||
).resolves.toEqual({ cancelled: true })
|
||||
expect(codex.calls().slice(-2)).toEqual([
|
||||
{
|
||||
method: 'thread/backgroundTerminals/terminate',
|
||||
params: { threadId: THREAD_ID, processId: PROCESS_ID }
|
||||
},
|
||||
{ method: 'thread/backgroundTerminals/list', params: { threadId: THREAD_ID } }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an older Codex without background terminals at no Stop', async () => {
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () => {
|
||||
throw unknownVariant('thread/backgroundTerminals/list')
|
||||
}
|
||||
})
|
||||
|
||||
await backgroundedDevServer(codex)
|
||||
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
|
||||
supportsTaskStop: true,
|
||||
supportsStopAll: false
|
||||
})
|
||||
expect(lastCommandStoppable(codex.evidence)).toBe(false)
|
||||
await expect(
|
||||
codex.adapter.stopBackgroundTasks({ sessionId: 'session-1', fence: 7, taskIds: [TASK_ID] })
|
||||
).resolves.toEqual({ cancelled: false })
|
||||
expect(
|
||||
codex.calls().some((call) => call.method === 'thread/backgroundTerminals/terminate')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('restates an already backgrounded command as stoppable when the probe answers late', async () => {
|
||||
let answer: (value: unknown) => void = () => {}
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () =>
|
||||
new Promise((resolve) => {
|
||||
answer = resolve
|
||||
})
|
||||
})
|
||||
await backgroundedDevServer(codex)
|
||||
expect(lastCommandStoppable(codex.evidence)).toBe(false)
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
|
||||
supportsTaskStop: true,
|
||||
supportsStopAll: false
|
||||
})
|
||||
|
||||
answer({ data: [], nextCursor: null })
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(lastCommandStoppable(codex.evidence)).toBe(true)
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true)
|
||||
})
|
||||
|
||||
it('probes the app-server once, not per command', async () => {
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () => ({ data: [], nextCursor: null })
|
||||
})
|
||||
await backgroundedDevServer(codex)
|
||||
codex.notify('item/started', {
|
||||
threadId: THREAD_ID,
|
||||
turnId: 'turn-2',
|
||||
item: { ...commandItem('inProgress'), id: 'exec-2', processId: '71832' }
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(
|
||||
codex.calls().filter((call) => call.method === 'thread/backgroundTerminals/list')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('asks again after a probe that timed out, instead of hiding Stop for good', async () => {
|
||||
let probes = 0
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () => {
|
||||
probes += 1
|
||||
if (probes === 1) {
|
||||
throw new CodexAppServerTimeoutError('codex app-server timed out')
|
||||
}
|
||||
return { data: [], nextCursor: null }
|
||||
}
|
||||
})
|
||||
await acquireReadyCodexForTest(codex.adapter, { identity, fence: 7, spawnToken: 'spawn-1' })
|
||||
codex.notify('turn/started', {
|
||||
threadId: THREAD_ID,
|
||||
turn: { id: TURN_ID, status: 'inProgress' }
|
||||
})
|
||||
codex.notify('item/started', {
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
item: commandItem('inProgress')
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(probes).toBe(1)
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(false)
|
||||
|
||||
codex.notify('turn/completed', {
|
||||
threadId: THREAD_ID,
|
||||
turn: { id: TURN_ID, status: 'completed' }
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
expect(probes).toBe(2)
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')?.supportsStopAll).toBe(true)
|
||||
expect(lastCommandStoppable(codex.evidence)).toBe(true)
|
||||
})
|
||||
|
||||
it('stops nothing for a stale fence', async () => {
|
||||
const codex = codexWith({
|
||||
'thread/backgroundTerminals/list': () => ({ data: [], nextCursor: null })
|
||||
@@ -260,81 +91,9 @@ describe('Codex background-command Stop', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('probeCodexBackgroundTerminals', () => {
|
||||
const probe = (error: Error | null, reply: unknown = { data: [] }) =>
|
||||
probeCodexBackgroundTerminals(
|
||||
fakeRpc({
|
||||
'thread/backgroundTerminals/list': () => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
return reply
|
||||
}
|
||||
}).rpc,
|
||||
THREAD_ID,
|
||||
5_000
|
||||
)
|
||||
|
||||
it('reads a listing as support and a refused method as none', async () => {
|
||||
await expect(probe(null)).resolves.toBe('supported')
|
||||
await expect(probe(unknownVariant('thread/backgroundTerminals/list'))).resolves.toBe(
|
||||
'unsupported'
|
||||
)
|
||||
await expect(
|
||||
probe(new CodexAppServerUnsupportedError('codex app-server does not support it'))
|
||||
).resolves.toBe('unsupported')
|
||||
await expect(probe(null, {})).resolves.toBe('unsupported')
|
||||
})
|
||||
|
||||
it('leaves the answer open when the app-server did not answer', async () => {
|
||||
await expect(probe(new CodexAppServerTimeoutError('codex app-server timed out'))).resolves.toBe(
|
||||
'unknown'
|
||||
)
|
||||
await expect(probe(new Error('codex app-server connection closed'))).resolves.toBe('unknown')
|
||||
await expect(probe(threadNotFound('thread/backgroundTerminals/list'))).resolves.toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminateCodexBackgroundTerminals', () => {
|
||||
const always = { timeoutMs: 5_000, isCurrent: () => true }
|
||||
|
||||
it('counts a process Codex no longer holds as stopped once the list confirms it', async () => {
|
||||
const { rpc } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => ({ terminated: false }),
|
||||
'thread/backgroundTerminals/list': () => ({ data: [], nextCursor: null })
|
||||
})
|
||||
await expect(
|
||||
terminateCodexBackgroundTerminals(
|
||||
rpc,
|
||||
[{ threadId: THREAD_ID, processId: PROCESS_ID }],
|
||||
always
|
||||
)
|
||||
).resolves.toEqual({ stopped: 1, survived: false })
|
||||
})
|
||||
|
||||
it('answers a survivor the list shows, after trying the others', async () => {
|
||||
const { rpc, calls } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => ({ terminated: true }),
|
||||
'thread/backgroundTerminals/list': () => ({
|
||||
data: [{ processId: PROCESS_ID }],
|
||||
nextCursor: null
|
||||
})
|
||||
})
|
||||
await expect(
|
||||
terminateCodexBackgroundTerminals(
|
||||
rpc,
|
||||
[
|
||||
{ threadId: THREAD_ID, processId: PROCESS_ID },
|
||||
{ threadId: THREAD_ID, processId: '71832' }
|
||||
],
|
||||
always
|
||||
)
|
||||
).resolves.toEqual({ stopped: 1, survived: true })
|
||||
expect(
|
||||
calls.filter((call) => call.method === 'thread/backgroundTerminals/terminate')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('reads every page of the list', async () => {
|
||||
const { rpc } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => ({ terminated: true }),
|
||||
@@ -352,43 +111,6 @@ describe('terminateCodexBackgroundTerminals', () => {
|
||||
).resolves.toEqual({ stopped: 0, survived: true })
|
||||
})
|
||||
|
||||
it('answers a listed survivor even when another request timed out', async () => {
|
||||
const { rpc } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => ({ terminated: true }),
|
||||
'thread/backgroundTerminals/list': (params) => {
|
||||
if (params?.threadId === 'thread-2') {
|
||||
throw new CodexAppServerTimeoutError('codex app-server timed out')
|
||||
}
|
||||
return { data: [{ processId: PROCESS_ID }], nextCursor: null }
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
terminateCodexBackgroundTerminals(
|
||||
rpc,
|
||||
[
|
||||
{ threadId: THREAD_ID, processId: PROCESS_ID },
|
||||
{ threadId: 'thread-2', processId: '71832' }
|
||||
],
|
||||
always
|
||||
)
|
||||
).resolves.toEqual({ stopped: 0, survived: true })
|
||||
})
|
||||
|
||||
it('counts a thread Codex unloaded as stopped', async () => {
|
||||
const { rpc } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => {
|
||||
throw threadNotFound('thread/backgroundTerminals/terminate')
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
terminateCodexBackgroundTerminals(
|
||||
rpc,
|
||||
[{ threadId: THREAD_ID, processId: PROCESS_ID }],
|
||||
always
|
||||
)
|
||||
).resolves.toEqual({ stopped: 1, survived: false })
|
||||
})
|
||||
|
||||
it('never counts a dropped connection as stopped', async () => {
|
||||
const closed = new Error('codex app-server connection closed')
|
||||
const onTerminate = fakeRpc({
|
||||
@@ -417,23 +139,4 @@ describe('terminateCodexBackgroundTerminals', () => {
|
||||
)
|
||||
).rejects.toBe(closed)
|
||||
})
|
||||
|
||||
it('stops asking an app-server that timed out', async () => {
|
||||
const { rpc, calls } = fakeRpc({
|
||||
'thread/backgroundTerminals/terminate': () => {
|
||||
throw new CodexAppServerTimeoutError('codex app-server timed out')
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
terminateCodexBackgroundTerminals(
|
||||
rpc,
|
||||
[
|
||||
{ threadId: THREAD_ID, processId: PROCESS_ID },
|
||||
{ threadId: THREAD_ID, processId: '71832' }
|
||||
],
|
||||
always
|
||||
)
|
||||
).rejects.toThrow('timed out')
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
+2
-121
@@ -1,10 +1,7 @@
|
||||
// Older desktop/mobile clients pair results by position, so every finished call needs output.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { pairToolBlocks } from '../../../../shared/native-chat/tools/native-chat-tool-pairs'
|
||||
import { pairNativeChatToolResults } from '../../../../shared/native-chat/tools/native-chat-tool-pairing'
|
||||
import { projectNativeChatTranscript } from '../../../../shared/native-chat/transcript/native-chat-transcript-projection'
|
||||
@@ -19,13 +16,11 @@ import {
|
||||
HELPER_TURN,
|
||||
item,
|
||||
PARENT_TURN,
|
||||
PROMPT,
|
||||
publishedRows,
|
||||
shell,
|
||||
spawn,
|
||||
turn,
|
||||
waitStarted,
|
||||
type Frame
|
||||
waitStarted
|
||||
} from './codex-collab-call-frames-fixture'
|
||||
import { THREAD_ID } from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
|
||||
@@ -70,9 +65,6 @@ function positionalRuns(rows: AgentJournalRenderItem[]): {
|
||||
}
|
||||
}
|
||||
|
||||
const outputOf = (rows: AgentJournalRenderItem[], name: string): AgentJournalItemBody | undefined =>
|
||||
rows.map((row) => row.body).find((body) => body.kind === 'tool-call' && body.name === name)
|
||||
|
||||
describe('Codex collab call rows on a client that pairs results by position', () => {
|
||||
it('draws each output under its own call through spawn, shell, wait and close', async () => {
|
||||
const rows = await publishedRows([
|
||||
@@ -128,115 +120,4 @@ describe('Codex collab call rows on a client that pairs results by position', ()
|
||||
expect(mobile).toEqual(runs)
|
||||
expect(desktop).toEqual(runs)
|
||||
})
|
||||
|
||||
it('gives a wait that timed out an output, and keeps naming the helpers it waited on', async () => {
|
||||
const rows = await publishedRows([
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawn('item/completed'),
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
waitStarted,
|
||||
// Codex ends a timed-out wait naming no receiver and no state.
|
||||
collab('item/completed', {
|
||||
id: 'call-wait',
|
||||
tool: 'wait',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [],
|
||||
agentsStates: {}
|
||||
}),
|
||||
collab('item/started', {
|
||||
id: 'call-close',
|
||||
tool: 'closeAgent',
|
||||
status: 'inProgress',
|
||||
receiverThreadIds: [HELPER]
|
||||
}),
|
||||
collab('item/completed', {
|
||||
id: 'call-close',
|
||||
tool: 'closeAgent',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
agentsStates: { [HELPER]: { status: 'running', message: null } }
|
||||
}),
|
||||
shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'),
|
||||
shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE')
|
||||
])
|
||||
expect(outputOf(rows, 'wait_agent')).toMatchObject({
|
||||
state: 'completed',
|
||||
input: { description: PROMPT, agents: [HELPER] }
|
||||
})
|
||||
const runs = [
|
||||
[
|
||||
['spawn_agent', 'Spawned'],
|
||||
['wait_agent', 'Finished waiting'],
|
||||
// Its snapshot says `running`: the status from before the close.
|
||||
['close_agent', 'Closed'],
|
||||
['shell', 'PARENT_DONE']
|
||||
]
|
||||
]
|
||||
const { mobile, desktop } = positionalRuns(rows)
|
||||
expect(mobile).toEqual(runs)
|
||||
expect(desktop).toEqual(runs)
|
||||
})
|
||||
|
||||
it('gives every other finished call what it did or what it reports', async () => {
|
||||
const OTHER = '01a0ea72-0000-7000-8000-000000000002'
|
||||
const finished = (id: string, tool: string, fields: Record<string, unknown>): Frame[] => [
|
||||
collab('item/started', { id, tool, status: 'inProgress', receiverThreadIds: [HELPER] }),
|
||||
collab('item/completed', {
|
||||
id,
|
||||
tool,
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
...fields
|
||||
})
|
||||
]
|
||||
const rows = await publishedRows([
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawn('item/completed'),
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
collab('item/completed', {
|
||||
id: 'call-spawn-failed',
|
||||
tool: 'spawnAgent',
|
||||
status: 'failed',
|
||||
receiverThreadIds: [],
|
||||
prompt: 'second helper'
|
||||
}),
|
||||
...finished('call-send', 'sendInput', {
|
||||
prompt: 'keep going',
|
||||
agentsStates: { [HELPER]: { status: 'running', message: null } }
|
||||
}),
|
||||
...finished('call-resume', 'resumeAgent', {
|
||||
agentsStates: { [HELPER]: { status: 'completed', message: 'OLD_REPLY' } }
|
||||
}),
|
||||
collab('item/started', {
|
||||
id: 'call-wait',
|
||||
tool: 'wait',
|
||||
status: 'inProgress',
|
||||
receiverThreadIds: [HELPER, OTHER]
|
||||
}),
|
||||
// A wait's end names only the helpers that finished; an errored one's message is its error.
|
||||
collab('item/completed', {
|
||||
id: 'call-wait',
|
||||
tool: 'wait',
|
||||
status: 'failed',
|
||||
receiverThreadIds: [OTHER],
|
||||
agentsStates: { [OTHER]: { status: 'errored', message: 'boom' } }
|
||||
}),
|
||||
shell('item/started', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE'),
|
||||
shell('item/completed', THREAD_ID, PARENT_TURN, 'call-parent-shell', 'PARENT_DONE')
|
||||
])
|
||||
const runs = [
|
||||
[
|
||||
['spawn_agent', 'Spawned'],
|
||||
['spawn_agent', 'Agent spawn failed'],
|
||||
['send_input', 'Sent input'],
|
||||
['resume_agent', 'Completed - OLD_REPLY'],
|
||||
['wait_agent', `${OTHER}: Error - boom`],
|
||||
['shell', 'PARENT_DONE']
|
||||
]
|
||||
]
|
||||
const { mobile, desktop } = positionalRuns(rows)
|
||||
expect(mobile).toEqual(runs)
|
||||
expect(desktop).toEqual(runs)
|
||||
expect(outputOf(rows, 'wait_agent')).toMatchObject({ input: { agents: [HELPER, OTHER] } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,645 +0,0 @@
|
||||
import {
|
||||
acquireReadyCodexForTest,
|
||||
fakeCodex,
|
||||
identityFor,
|
||||
THREAD_ID
|
||||
} from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { createAgentChildWorkAdmission } from '../../../../shared/agent-status/child-work/agent-status-child-work-admission'
|
||||
import type { AgentChildWorkRecord } from '../../../../shared/agent-status/child-work/agent-status-child-work'
|
||||
import { reconcileAgentChildWorkEvidence } from '../../../../shared/agent-status/child-work/agent-status-child-work-reconciliation'
|
||||
import { createAgentStatusStore } from '../../../../shared/agent-status/store/agent-status-store'
|
||||
import { makeStructuredAgentStatusSubject } from '../../../../shared/agent-status/contracts/agent-status-subject'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { CodexStructuredSessionAdapter } from '../session-lifecycle/codex-structured-session-adapter'
|
||||
import { CodexBackgroundTaskTracker } from './codex-background-task-tracker'
|
||||
import { createCodexHistoryTranslator } from '../journal/codex-structured-journal-translation'
|
||||
import { CodexSubagentExecutions } from './codex-subagent-executions'
|
||||
import { readCodexSubagentAnnouncements } from './codex-subagent-activity'
|
||||
import type { CodexThreadItem } from '../items/codex-thread-item-identity'
|
||||
|
||||
const parent = makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceKind: 'folder'
|
||||
},
|
||||
'session-1'
|
||||
)
|
||||
|
||||
// The spawn and wait items below are verbatim from a live default-mode session (codex-cli
|
||||
// 0.155.0-alpha.9.2, `multi_agent` on, `multi_agent_v2` off); only the sender is the fixture's
|
||||
// thread. That session sent no `subAgentActivity` at all.
|
||||
const HELPER = '01a0d114-2b8e-73e2-a69b-8e86df067ae9'
|
||||
const PARENT_TURN = '01a0d112-6f86-7092-8c87-b8618f259efa'
|
||||
const HELPER_TURN = 'helper-turn-1'
|
||||
const PROMPT =
|
||||
'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes, reply with the single word `CHILD_REPLY` and nothing else.'
|
||||
const LABEL = 'Run the shell command exactly: `sleep 45; echo CHILD_DONE`. After it completes,…'
|
||||
const COMMAND = "/bin/zsh -lc 'sleep 45; echo CHILD_DONE'"
|
||||
|
||||
type Frame = { method: string; params: Record<string, unknown> }
|
||||
|
||||
const turn = (
|
||||
method: 'turn/started' | 'turn/completed',
|
||||
threadId: string,
|
||||
id: string,
|
||||
status = 'completed'
|
||||
): Frame => ({ method, params: { threadId, turn: { id, status } } })
|
||||
const item = (
|
||||
method: 'item/started' | 'item/completed',
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
fields: Record<string, unknown>
|
||||
): Frame => ({ method, params: { threadId, turnId, item: fields } })
|
||||
const collab = (
|
||||
method: 'item/started' | 'item/completed',
|
||||
fields: Record<string, unknown>
|
||||
): Frame =>
|
||||
item(method, THREAD_ID, PARENT_TURN, {
|
||||
type: 'collabAgentToolCall',
|
||||
senderThreadId: THREAD_ID,
|
||||
prompt: null,
|
||||
model: null,
|
||||
reasoningEffort: null,
|
||||
agentsStates: {},
|
||||
...fields
|
||||
})
|
||||
|
||||
const spawnStarted = collab('item/started', {
|
||||
id: 'call_dxoSnQY1cHVswlN8MGFJBxHt',
|
||||
tool: 'spawnAgent',
|
||||
status: 'inProgress',
|
||||
receiverThreadIds: [],
|
||||
prompt: PROMPT,
|
||||
model: '',
|
||||
reasoningEffort: 'medium'
|
||||
})
|
||||
const spawnCompleted = collab('item/completed', {
|
||||
id: 'call_dxoSnQY1cHVswlN8MGFJBxHt',
|
||||
tool: 'spawnAgent',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
prompt: PROMPT,
|
||||
model: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
agentsStates: { [HELPER]: { status: 'pendingInit', message: null } }
|
||||
})
|
||||
const waitStarted = collab('item/started', {
|
||||
id: 'call_rMn9MIAPavyhOD6ifjH38A8E',
|
||||
tool: 'wait',
|
||||
status: 'inProgress',
|
||||
receiverThreadIds: [HELPER]
|
||||
})
|
||||
const waitCompleted = collab('item/completed', {
|
||||
id: 'call_rMn9MIAPavyhOD6ifjH38A8E',
|
||||
tool: 'wait',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
agentsStates: { [HELPER]: { status: 'completed', message: 'CHILD_REPLY' } }
|
||||
})
|
||||
const closeCompleted = collab('item/completed', {
|
||||
id: 'call-close',
|
||||
tool: 'closeAgent',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
agentsStates: { [HELPER]: { status: 'shutdown', message: null } }
|
||||
})
|
||||
const helperShell = (method: 'item/started' | 'item/completed'): Frame =>
|
||||
item(method, HELPER, HELPER_TURN, {
|
||||
type: 'commandExecution',
|
||||
id: 'call_3iUPkSEtwkUYstngZm5CNJ52',
|
||||
command: COMMAND,
|
||||
cwd: '/work/repo',
|
||||
source: 'unifiedExecStartup',
|
||||
status: method === 'item/started' ? 'inProgress' : 'completed',
|
||||
...(method === 'item/completed' ? { exitCode: 0, aggregatedOutput: 'CHILD_DONE\n' } : {})
|
||||
})
|
||||
const helperReply = item('item/completed', HELPER, HELPER_TURN, {
|
||||
type: 'agentMessage',
|
||||
id: 'msg-child',
|
||||
text: 'CHILD_REPLY'
|
||||
})
|
||||
const activityStarted: Frame = item('item/started', THREAD_ID, PARENT_TURN, {
|
||||
type: 'subAgentActivity',
|
||||
id: 'activity-helper',
|
||||
kind: 'started',
|
||||
agentThreadId: HELPER,
|
||||
agentPath: '/root/sleeper'
|
||||
})
|
||||
|
||||
async function session() {
|
||||
const codex = fakeCodex()
|
||||
const store = createAgentStatusStore({ epoch: 'epoch-1', mode: 'authority' })
|
||||
expect(store.applyMutation({ parent: { subject: parent } })).not.toBeNull()
|
||||
let minted = 0
|
||||
const admission = createAgentChildWorkAdmission(store, {
|
||||
mintChildWorkId: () => `child-${++minted}`
|
||||
})
|
||||
const adapter = new CodexStructuredSessionAdapter({
|
||||
resolveLaunch: async () => ({
|
||||
command: 'codex',
|
||||
args: ['app-server'],
|
||||
cwd: '/work/repo',
|
||||
codexHome: null,
|
||||
resumeThreadId: null
|
||||
}),
|
||||
openConnection: codex.openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
now: () => 1_700_000_000_500,
|
||||
onChildWorkEvidence: (_sessionId, evidence) =>
|
||||
reconcileAgentChildWorkEvidence({ store, admission, parent, provider: 'codex', evidence })
|
||||
})
|
||||
// The latest revision of each journal row, in first-written order.
|
||||
const rows = new Map<string, AgentJournalItemBody>()
|
||||
const journal: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body) => rows.set(JSON.stringify(identity), body),
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
await acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor('session-1'),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-9',
|
||||
events: journal
|
||||
})
|
||||
const send = (...frames: Frame[]): void => {
|
||||
for (const frame of frames) {
|
||||
codex.connections[0]!.handlers.onNotification?.(frame.method, frame.params)
|
||||
}
|
||||
}
|
||||
const agents = (): AgentChildWorkRecord[] =>
|
||||
store.getChildren(parent).filter((record) => record.kind === 'agent')
|
||||
const commands = (): AgentChildWorkRecord[] =>
|
||||
store.getChildren(parent).filter((record) => record.kind === 'command')
|
||||
const strip = () => adapter.backgroundTaskState('session-1')?.tasks ?? []
|
||||
const toolRow = (name: string) =>
|
||||
[...rows.values()].find((body) => body.kind === 'tool-call' && body.name === name)
|
||||
const rawCollabRows = () =>
|
||||
[...rows.values()].filter(
|
||||
(body) => body.kind === 'status' && body.providerFrame?.kind === 'item:collabAgentToolCall'
|
||||
)
|
||||
return { send, agents, commands, strip, toolRow, rawCollabRows }
|
||||
}
|
||||
|
||||
describe('Codex default-mode helpers', () => {
|
||||
it('registers a helper announced only by its spawn call, in the strip and the records', async () => {
|
||||
const run = await session()
|
||||
run.send(turn('turn/started', THREAD_ID, PARENT_TURN), spawnStarted)
|
||||
expect(run.agents()).toEqual([])
|
||||
expect(run.strip()).toEqual([])
|
||||
|
||||
run.send(spawnCompleted, turn('turn/started', HELPER, HELPER_TURN), waitStarted)
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({
|
||||
membership: 'live',
|
||||
state: 'working',
|
||||
description: LABEL,
|
||||
invocation: { invocationId: HELPER_TURN, generation: 1 }
|
||||
})
|
||||
])
|
||||
expect(run.strip()).toEqual([
|
||||
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL }
|
||||
])
|
||||
|
||||
run.send(helperShell('item/started'))
|
||||
expect(run.strip()).toEqual([
|
||||
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL }
|
||||
])
|
||||
expect(run.commands()).toEqual([
|
||||
expect.objectContaining({
|
||||
membership: 'live',
|
||||
description: COMMAND,
|
||||
parentChildWorkId: run.agents()[0]?.childWorkId
|
||||
})
|
||||
])
|
||||
expect(run.agents()[0]?.operation).toMatchObject({ toolName: 'Bash', input: COMMAND })
|
||||
|
||||
run.send(helperShell('item/completed'))
|
||||
expect(run.commands()).toEqual([])
|
||||
|
||||
run.send(helperReply, turn('turn/completed', HELPER, HELPER_TURN))
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded',
|
||||
lastMessage: 'CHILD_REPLY'
|
||||
})
|
||||
])
|
||||
expect(run.strip()).toEqual([])
|
||||
})
|
||||
|
||||
it('labels a command the helper leaves running with the helper, once its turn is over', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
helperShell('item/started'),
|
||||
turn('turn/completed', HELPER, HELPER_TURN)
|
||||
)
|
||||
expect(run.strip()).toEqual([
|
||||
expect.objectContaining({ kind: 'command', description: `${LABEL} — ${COMMAND}` })
|
||||
])
|
||||
})
|
||||
|
||||
it('renders each collab call as a tool row naming its helper, never as a raw provider row', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnStarted,
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
waitStarted
|
||||
)
|
||||
expect(run.toolRow('wait_agent')).toMatchObject({
|
||||
state: 'running',
|
||||
input: { description: LABEL, agents: [HELPER] }
|
||||
})
|
||||
run.send(turn('turn/completed', HELPER, HELPER_TURN), waitCompleted)
|
||||
expect(run.toolRow('spawn_agent')).toMatchObject({
|
||||
state: 'completed',
|
||||
input: { description: LABEL, prompt: PROMPT, model: 'gpt-5.5', agents: [HELPER] }
|
||||
})
|
||||
expect(run.toolRow('wait_agent')).toMatchObject({
|
||||
state: 'completed',
|
||||
input: { description: LABEL },
|
||||
output: expect.objectContaining({ head: 'CHILD_REPLY' })
|
||||
})
|
||||
expect(run.rawCollabRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps naming the helper when the turn ends with its wait still open', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
waitStarted,
|
||||
turn('turn/completed', THREAD_ID, PARENT_TURN, 'interrupted')
|
||||
)
|
||||
expect(run.toolRow('wait_agent')).toMatchObject({
|
||||
state: 'failed',
|
||||
input: { description: LABEL }
|
||||
})
|
||||
})
|
||||
|
||||
it('registers the helper whichever order its first turn and its spawn arrive in', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
spawnCompleted
|
||||
)
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'live', state: 'working', description: LABEL })
|
||||
])
|
||||
expect(run.strip()).toHaveLength(1)
|
||||
expect(run.toolRow('spawn_agent')).toMatchObject({
|
||||
state: 'completed',
|
||||
input: { description: LABEL }
|
||||
})
|
||||
})
|
||||
|
||||
it('registers a helper whose spawn call failed but created its thread, as Codex reports an errored one', async () => {
|
||||
const run = await session()
|
||||
// A failed spawn can still create a thread that later runs.
|
||||
const spawnFailedWithThread = collab('item/completed', {
|
||||
id: 'call-failed-spawn',
|
||||
tool: 'spawnAgent',
|
||||
status: 'failed',
|
||||
receiverThreadIds: [HELPER],
|
||||
prompt: PROMPT,
|
||||
model: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
agentsStates: { [HELPER]: { status: 'errored', message: null } }
|
||||
})
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnFailedWithThread,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
helperShell('item/started')
|
||||
)
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'live', state: 'working', description: LABEL })
|
||||
])
|
||||
expect(run.strip()).toEqual([
|
||||
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: LABEL }
|
||||
])
|
||||
})
|
||||
|
||||
it('registers nothing for a spawn that created no thread', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
collab('item/completed', {
|
||||
id: 'call-refused-spawn',
|
||||
tool: 'spawnAgent',
|
||||
status: 'failed',
|
||||
receiverThreadIds: [],
|
||||
prompt: PROMPT
|
||||
})
|
||||
)
|
||||
expect(run.agents()).toEqual([])
|
||||
expect(run.strip()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a helper running when its caller failed to close it', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
collab('item/completed', {
|
||||
id: 'call-close-failed',
|
||||
tool: 'closeAgent',
|
||||
status: 'failed',
|
||||
receiverThreadIds: [HELPER],
|
||||
agentsStates: { [HELPER]: { status: 'notFound', message: null } }
|
||||
})
|
||||
)
|
||||
expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })])
|
||||
expect(run.strip()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("ends a closed helper only on its own interrupted turn, never on the caller's close item", async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
// A completed close call can report a helper snapshot that is still running.
|
||||
collab('item/completed', {
|
||||
id: 'call-close-errored',
|
||||
tool: 'closeAgent',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
agentsStates: { [HELPER]: { status: 'running', message: null } }
|
||||
})
|
||||
)
|
||||
expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })])
|
||||
expect(run.strip()).toHaveLength(1)
|
||||
|
||||
run.send(closeCompleted, turn('turn/completed', HELPER, HELPER_TURN, 'interrupted'))
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'settled', outcome: 'cancelled' })
|
||||
])
|
||||
expect(run.strip()).toEqual([])
|
||||
expect(run.toolRow('close_agent')).toMatchObject({
|
||||
state: 'completed',
|
||||
input: { description: LABEL }
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps a closed helper's running command until Codex reports the killed process's exit", async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
helperShell('item/started'),
|
||||
turn('turn/completed', HELPER, HELPER_TURN),
|
||||
closeCompleted
|
||||
)
|
||||
expect(run.commands()).toEqual([expect.objectContaining({ membership: 'live' })])
|
||||
run.send(helperShell('item/completed'))
|
||||
expect(run.commands()).toEqual([])
|
||||
expect(run.strip()).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a finished helper finished when its caller closes it', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
turn('turn/completed', HELPER, HELPER_TURN),
|
||||
closeCompleted
|
||||
)
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'settled', outcome: 'succeeded' })
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps one child for a session that announces the helper both ways', async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
activityStarted,
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
{ ...activityStarted, method: 'item/completed' }
|
||||
)
|
||||
// The activity landed first, so its task name labels the one child.
|
||||
expect(run.agents()).toEqual([expect.objectContaining({ description: 'sleeper' })])
|
||||
expect(run.strip()).toEqual([
|
||||
{ id: `codex-agent:${HELPER}`, kind: 'agent', description: 'sleeper' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a helper whose spawn was never seen', () => {
|
||||
const sendInput = collab('item/completed', {
|
||||
id: 'call-send-input',
|
||||
tool: 'sendInput',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER],
|
||||
prompt: 'Now run it again.',
|
||||
agentsStates: { [HELPER]: { status: 'running', message: null } }
|
||||
})
|
||||
|
||||
it("registers from any call that names it, so its shell is no longer the session's own", async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
helperShell('item/started')
|
||||
)
|
||||
expect(run.strip()).toEqual([])
|
||||
expect(run.commands()).toEqual([
|
||||
expect.objectContaining({ membership: 'live', residency: 'foreground' })
|
||||
])
|
||||
|
||||
run.send(sendInput)
|
||||
expect(run.strip()).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }])
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'live', state: 'working' })
|
||||
])
|
||||
expect(run.commands()).toEqual([
|
||||
expect.objectContaining({
|
||||
membership: 'live',
|
||||
parentChildWorkId: run.agents()[0]?.childWorkId
|
||||
})
|
||||
])
|
||||
expect(run.toolRow('send_input')).toMatchObject({ input: { description: HELPER } })
|
||||
})
|
||||
|
||||
it('registers each helper a call on several names', async () => {
|
||||
const second = 'helper-two'
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
turn('turn/started', second, 'helper-two-turn'),
|
||||
collab('item/started', {
|
||||
id: 'call-wait-both',
|
||||
tool: 'wait',
|
||||
status: 'inProgress',
|
||||
receiverThreadIds: [HELPER, second]
|
||||
})
|
||||
)
|
||||
expect(run.strip()).toEqual([
|
||||
{ id: `codex-agent:${HELPER}`, kind: 'agent' },
|
||||
{ id: `codex-agent:${second}`, kind: 'agent' }
|
||||
])
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'live' }),
|
||||
expect.objectContaining({ membership: 'live' })
|
||||
])
|
||||
})
|
||||
|
||||
it("reopens a helper's one entry when a later turn sends it work, even after that turn ended", async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
spawnCompleted,
|
||||
turn('turn/started', HELPER, HELPER_TURN),
|
||||
turn('turn/completed', HELPER, HELPER_TURN),
|
||||
turn('turn/completed', THREAD_ID, PARENT_TURN),
|
||||
turn('turn/started', THREAD_ID, 'parent-turn-2'),
|
||||
{ ...sendInput, params: { ...sendInput.params, turnId: 'parent-turn-2' } },
|
||||
turn('turn/completed', THREAD_ID, 'parent-turn-2'),
|
||||
turn('turn/started', HELPER, 'helper-turn-2')
|
||||
)
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'live', state: 'working' })
|
||||
])
|
||||
})
|
||||
|
||||
it('registers every helper a call names, and none it reports not found', () => {
|
||||
const [second, missing] = ['helper-two', 'helper-missing']
|
||||
const waited: CodexThreadItem = {
|
||||
type: 'collabAgentToolCall',
|
||||
id: 'call-wait-many',
|
||||
tool: 'wait',
|
||||
status: 'completed',
|
||||
receiverThreadIds: [HELPER, second, missing],
|
||||
agentsStates: {
|
||||
[HELPER]: { status: 'completed', message: 'done' },
|
||||
[second]: { status: 'running', message: null },
|
||||
[missing]: { status: 'notFound', message: null }
|
||||
}
|
||||
}
|
||||
expect(readCodexSubagentAnnouncements(waited, THREAD_ID)).toEqual([
|
||||
{ agentThreadId: HELPER, label: null, spawned: false },
|
||||
{ agentThreadId: second, label: null, spawned: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the record follows a helper whose turn ends', () => {
|
||||
const running = async () => {
|
||||
const run = await session()
|
||||
run.send(
|
||||
turn('turn/started', THREAD_ID, PARENT_TURN),
|
||||
activityStarted,
|
||||
turn('turn/started', HELPER, HELPER_TURN)
|
||||
)
|
||||
return run
|
||||
}
|
||||
|
||||
it('settles the strip and the record on the failed completion that follows a fatal error', async () => {
|
||||
const run = await running()
|
||||
// Codex sends the fatal error, then the helper's failed `turn/completed` 0-32 ms later.
|
||||
run.send({
|
||||
method: 'error',
|
||||
params: {
|
||||
threadId: HELPER,
|
||||
turnId: HELPER_TURN,
|
||||
willRetry: false,
|
||||
error: { message: 'Selected model is at capacity.', codexErrorInfo: 'serverOverloaded' }
|
||||
}
|
||||
})
|
||||
expect(run.strip()).toEqual([expect.objectContaining({ id: `codex-agent:${HELPER}` })])
|
||||
expect(run.agents()).toEqual([expect.objectContaining({ membership: 'live' })])
|
||||
|
||||
run.send(turn('turn/completed', HELPER, HELPER_TURN, 'failed'))
|
||||
expect(run.strip()).toEqual([])
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'settled', outcome: 'failed' })
|
||||
])
|
||||
})
|
||||
|
||||
it('settles the strip and the record when its thread closes with no turn/completed', async () => {
|
||||
const run = await running()
|
||||
run.send({ method: 'thread/closed', params: { threadId: HELPER } })
|
||||
expect(run.strip()).toEqual([])
|
||||
expect(run.agents()).toEqual([
|
||||
expect.objectContaining({ membership: 'settled', outcome: 'unknown' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('a restored thread', () => {
|
||||
function restored(replayed = [spawnCompleted, waitCompleted]) {
|
||||
const executions = new CodexSubagentExecutions()
|
||||
const rows = new Map<string, AgentJournalItemBody>()
|
||||
const translator = createCodexHistoryTranslator({
|
||||
sink: {
|
||||
appendItem: (identity, body) => rows.set(JSON.stringify(identity), body),
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
},
|
||||
primaryThreadId: () => THREAD_ID,
|
||||
subagentExecutions: executions,
|
||||
schedule: (run: () => void) => {
|
||||
run()
|
||||
return () => {}
|
||||
}
|
||||
})
|
||||
const tracker = new CodexBackgroundTaskTracker(THREAD_ID, executions)
|
||||
const admission = translator.restoreThread(THREAD_ID, {
|
||||
turns: [{ id: PARENT_TURN, items: replayed.map((frame) => frame.params.item) }]
|
||||
})
|
||||
expect(admission).toEqual({ accepted: true })
|
||||
const bodies = () => [...rows.values()]
|
||||
return { executions, tracker, bodies }
|
||||
}
|
||||
|
||||
it('names the helper on a replayed call row, as the live row did', () => {
|
||||
const { bodies } = restored()
|
||||
expect(bodies()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'tool-call',
|
||||
name: 'wait_agent',
|
||||
input: expect.objectContaining({ description: LABEL })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('claims no running helper for a name it learned from history', () => {
|
||||
const { executions, tracker, bodies } = restored()
|
||||
expect(executions.workingChildren()).toEqual([])
|
||||
tracker.observe({
|
||||
method: 'turn/started',
|
||||
threadId: THREAD_ID,
|
||||
params: { threadId: THREAD_ID, turn: { id: 'next-turn', status: 'inProgress' } }
|
||||
})
|
||||
expect(tracker.state).toBeNull()
|
||||
expect(tracker.drainChildWorkEvidence(1)).toEqual([])
|
||||
expect(
|
||||
bodies().filter(
|
||||
(body) =>
|
||||
body.kind === 'message' && body.blocks.some((block) => block.type === 'subagent-group')
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('knows a helper whose spawn history compacted away, from a call on it', () => {
|
||||
const { tracker } = restored([waitCompleted])
|
||||
tracker.observe({ ...turn('turn/started', HELPER, HELPER_TURN), threadId: HELPER })
|
||||
expect(tracker.state?.tasks).toEqual([{ id: `codex-agent:${HELPER}`, kind: 'agent' }])
|
||||
})
|
||||
})
|
||||
-335
@@ -4,8 +4,6 @@ import {
|
||||
identityFor,
|
||||
THREAD_ID
|
||||
} from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
foldAgentLeadStatus,
|
||||
@@ -40,8 +38,6 @@ const parent = makeStructuredAgentStatusSubject(
|
||||
'session-1'
|
||||
)
|
||||
const REVIEWER = 'thread-reviewer'
|
||||
const TESTER = 'thread-tester'
|
||||
const LINTER = 'thread-linter'
|
||||
|
||||
type Frame = { method: string; params: Record<string, unknown> }
|
||||
type Delivery = { kind: 'journal' | 'publish' | 'evidence'; detail: string }
|
||||
@@ -80,10 +76,6 @@ const item = (
|
||||
method,
|
||||
params: { threadId, turnId, item: fields }
|
||||
})
|
||||
const status = (threadId: string, activeFlags: string[]): Frame => ({
|
||||
method: 'thread/status/changed',
|
||||
params: { threadId, status: { type: 'active', activeFlags } }
|
||||
})
|
||||
|
||||
async function producer() {
|
||||
const codex = fakeCodex()
|
||||
@@ -218,287 +210,6 @@ describe('Codex structured child-work producer', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("folds the parent from the records, at every journal write, with each child's outcome and activity", async () => {
|
||||
const { adapter, momentsOf, records, recordedLiveness, byDescription, display } =
|
||||
await producer()
|
||||
const steps: {
|
||||
frame: Frame
|
||||
lead: 'working' | 'done'
|
||||
live: Liveness
|
||||
parent: AgentLeadStatusResolution
|
||||
check?: () => void
|
||||
}[] = [
|
||||
{
|
||||
frame: turn('turn/started', THREAD_ID, 'p1'),
|
||||
lead: 'working',
|
||||
live: null,
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
// Child turn notifications can precede their spawn announcement.
|
||||
{
|
||||
frame: turn('turn/started', REVIEWER, 'r1'),
|
||||
lead: 'working',
|
||||
live: null,
|
||||
parent: { stateName: 'working' },
|
||||
check: () => expect(records()).toEqual([])
|
||||
},
|
||||
{
|
||||
frame: spawned(REVIEWER, 'review', 'p1'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
// Approval starts use source=agent; exits arrive from unified exec.
|
||||
{
|
||||
frame: shellFrame('item/started', REVIEWER, 'r1', 'cmd-1', 'npm test', 'agent'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () => {
|
||||
expect(byDescription('review')?.operation).toMatchObject({
|
||||
toolName: 'Bash',
|
||||
input: 'npm test',
|
||||
basis: 'open'
|
||||
})
|
||||
expect(byDescription('npm test')).toMatchObject({
|
||||
membership: 'live',
|
||||
parentChildWorkId: byDescription('review')?.childWorkId
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
frame: shellFrame('item/started', REVIEWER, 'r1', 'exec-1', 'npm run dev'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () =>
|
||||
expect(byDescription('npm run dev')).toMatchObject({
|
||||
membership: 'live',
|
||||
parentChildWorkId: byDescription('review')?.childWorkId
|
||||
})
|
||||
},
|
||||
{
|
||||
frame: shellFrame('item/completed', REVIEWER, 'r1', 'cmd-1', 'npm test'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () => {
|
||||
expect(byDescription('npm test')).toBeUndefined()
|
||||
expect(byDescription('review')?.operation).toMatchObject({
|
||||
toolName: 'Bash',
|
||||
input: 'npm run dev'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
frame: item('item/completed', REVIEWER, 'r1', {
|
||||
type: 'agentMessage',
|
||||
id: 'msg-1',
|
||||
text: 'Dev server is up'
|
||||
}),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', THREAD_ID, 'p1'),
|
||||
lead: 'done',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () =>
|
||||
expect(byDescription('review')).toMatchObject({ membership: 'live', state: 'working' })
|
||||
},
|
||||
{
|
||||
frame: status(REVIEWER, ['waitingOnApproval']),
|
||||
lead: 'done',
|
||||
live: 'waiting',
|
||||
parent: { stateName: 'waiting' },
|
||||
check: () => expect(byDescription('review')?.state).toBe('waiting')
|
||||
},
|
||||
{
|
||||
frame: status(REVIEWER, []),
|
||||
lead: 'done',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', REVIEWER, 'r1'),
|
||||
lead: 'done',
|
||||
live: 'monitoring',
|
||||
parent: { stateName: 'working', workingMode: 'monitoring' },
|
||||
check: () => {
|
||||
expect(byDescription('review')).toMatchObject({
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded',
|
||||
lastMessage: 'Dev server is up'
|
||||
})
|
||||
expect(byDescription('npm run dev')).toMatchObject({
|
||||
membership: 'live',
|
||||
parentChildWorkId: byDescription('review')?.childWorkId
|
||||
})
|
||||
// Live descendants keep a settled child in monitoring state.
|
||||
expect(display('review')).toBe('monitoring')
|
||||
}
|
||||
},
|
||||
{
|
||||
frame: turn('turn/started', THREAD_ID, 'p2'),
|
||||
lead: 'working',
|
||||
live: 'monitoring',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/started', REVIEWER, 'r2'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () =>
|
||||
expect(byDescription('review')).toMatchObject({
|
||||
childWorkId: 'child-1',
|
||||
membership: 'live',
|
||||
invocation: { invocationId: 'r2', generation: 2 },
|
||||
previousInvocations: [expect.objectContaining({ outcome: 'succeeded' })]
|
||||
})
|
||||
},
|
||||
{
|
||||
frame: spawned(TESTER, 'test', 'p2'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/started', TESTER, 't1'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: spawned(LINTER, 'lint', 'p2'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/started', LINTER, 'l1'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: {
|
||||
method: 'error',
|
||||
params: { threadId: LINTER, turnId: 'l1', willRetry: false, error: { message: 'boom' } }
|
||||
},
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () => expect(byDescription('lint')).toMatchObject({ membership: 'live' })
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', LINTER, 'l1', 'failed'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () =>
|
||||
expect(byDescription('lint')).toMatchObject({ membership: 'settled', outcome: 'failed' })
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', REVIEWER, 'r2', 'interrupted'),
|
||||
lead: 'working',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' },
|
||||
check: () =>
|
||||
expect(byDescription('review')).toMatchObject({
|
||||
membership: 'settled',
|
||||
outcome: 'cancelled'
|
||||
})
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', THREAD_ID, 'p2'),
|
||||
lead: 'done',
|
||||
live: 'working',
|
||||
parent: { stateName: 'working' }
|
||||
},
|
||||
{
|
||||
frame: turn('turn/completed', TESTER, 't1', 'failed'),
|
||||
lead: 'done',
|
||||
live: 'monitoring',
|
||||
parent: { stateName: 'working', workingMode: 'monitoring' },
|
||||
check: () =>
|
||||
expect(byDescription('test')).toMatchObject({ membership: 'settled', outcome: 'failed' })
|
||||
},
|
||||
{
|
||||
frame: shellFrame('item/completed', REVIEWER, 'r1', 'exec-1', 'npm run dev'),
|
||||
lead: 'done',
|
||||
live: null,
|
||||
parent: { stateName: 'done' },
|
||||
check: () => {
|
||||
expect(byDescription('npm run dev')).toBeUndefined()
|
||||
expect(display('review')).toBe('interrupted')
|
||||
}
|
||||
}
|
||||
]
|
||||
let journalMoments = 0
|
||||
let before = steps[0].parent
|
||||
for (const [index, step] of steps.entries()) {
|
||||
const frameMoments = momentsOf(step.frame)
|
||||
journalMoments += frameMoments.length
|
||||
// Mid-frame publication may expose either boundary state, never done with a live command.
|
||||
for (const [at, { recorded, commandLive }] of frameMoments.entries()) {
|
||||
const parent = fold(step.lead, recorded)
|
||||
expect({
|
||||
index,
|
||||
at,
|
||||
parent: [before, step.parent].some((p) => isDeepStrictEqual(p, parent))
|
||||
}).toEqual({ index, at, parent: true })
|
||||
expect({ index, at, done: commandLive && parent.stateName === 'done' }).toEqual({
|
||||
index,
|
||||
at,
|
||||
done: false
|
||||
})
|
||||
}
|
||||
const live = recordedLiveness()
|
||||
expect({ index, live, parent: fold(step.lead, live) }).toEqual({
|
||||
index,
|
||||
live: step.live,
|
||||
parent: step.parent
|
||||
})
|
||||
before = step.parent
|
||||
step.check?.()
|
||||
}
|
||||
// Child-only changes can skip this parent's journal.
|
||||
expect(journalMoments).toBeGreaterThan(0)
|
||||
const settled = records()
|
||||
await adapter.closeSession('session-1')
|
||||
expect(records()).toEqual(settled)
|
||||
expect(
|
||||
records().map(({ description, membership, outcome }) => ({
|
||||
description,
|
||||
membership,
|
||||
outcome
|
||||
}))
|
||||
).toEqual([
|
||||
{ description: 'review', membership: 'settled', outcome: 'cancelled' },
|
||||
{ description: 'test', membership: 'settled', outcome: 'failed' },
|
||||
{ description: 'lint', membership: 'settled', outcome: 'failed' }
|
||||
])
|
||||
expect(adapter.backgroundTaskState('session-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it("never reads done while the main agent's own shell runs past its turn", async () => {
|
||||
const { momentsOf, send, recordedLiveness } = await producer()
|
||||
send(turn('turn/started', THREAD_ID, 'p1'))
|
||||
send(shellFrame('item/started', THREAD_ID, 'p1', 'exec-dev', 'npm run dev'))
|
||||
const monitoring = { stateName: 'working', workingMode: 'monitoring' }
|
||||
const turnEnd = momentsOf(turn('turn/completed', THREAD_ID, 'p1'))
|
||||
expect(turnEnd.length).toBeGreaterThan(0)
|
||||
for (const { recorded } of turnEnd) {
|
||||
expect(fold('done', recorded)).toEqual(monitoring)
|
||||
}
|
||||
expect(fold('done', recordedLiveness())).toEqual(monitoring)
|
||||
momentsOf(shellFrame('item/completed', THREAD_ID, 'p1', 'exec-dev', 'npm run dev'))
|
||||
expect(fold('done', recordedLiveness())).toEqual({ stateName: 'done' })
|
||||
})
|
||||
|
||||
it('ends an approval left unanswered when its turn ends: Codex never ran the command', async () => {
|
||||
const { adapter, codex, send, records, recordedLiveness, display, byDescription } =
|
||||
await producer()
|
||||
@@ -549,50 +260,4 @@ describe('Codex structured child-work producer', () => {
|
||||
expect.objectContaining({ kind: 'command', description: 'npm run dev' })
|
||||
])
|
||||
})
|
||||
|
||||
it("numbers a child's runs as the journal does: a row's attempt is its record's generation", async () => {
|
||||
const { send, byDescription, stampOf } = await producer()
|
||||
const says = (turnId: string, text: string) =>
|
||||
item('item/completed', REVIEWER, turnId, { type: 'agentMessage', id: `msg-${text}`, text })
|
||||
// First-run rows omit attempt; normalize them to generation 1.
|
||||
const runs = (text: string) => {
|
||||
const stamp = stampOf(text)
|
||||
return {
|
||||
agentId: stamp?.agentId,
|
||||
attempt: stamp ? (stamp.attempt ?? 1) : undefined,
|
||||
generation: byDescription('review')?.invocation.generation
|
||||
}
|
||||
}
|
||||
send(turn('turn/started', THREAD_ID, 'p1'))
|
||||
send(turn('turn/started', REVIEWER, 'r1'))
|
||||
send(spawned(REVIEWER, 'review', 'p1'))
|
||||
send(says('r1', 'run 1'))
|
||||
expect(runs('run 1')).toEqual({ agentId: REVIEWER, attempt: 1, generation: 1 })
|
||||
send(turn('turn/completed', REVIEWER, 'r1'))
|
||||
for (const run of [2, 3]) {
|
||||
send(turn('turn/started', REVIEWER, `r${run}`))
|
||||
send(says(`r${run}`, `run ${run}`))
|
||||
expect(runs(`run ${run}`)).toEqual({ agentId: REVIEWER, attempt: run, generation: run })
|
||||
send(turn('turn/completed', REVIEWER, `r${run}`))
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a live child with no reported outcome when the provider exits unexpectedly', async () => {
|
||||
const { codex, send, records } = await producer()
|
||||
send(turn('turn/started', THREAD_ID, 'p1'))
|
||||
send(spawned(REVIEWER, 'review', 'p1'))
|
||||
send(turn('turn/started', REVIEWER, 'r1'))
|
||||
expect(records()).toEqual([
|
||||
expect.objectContaining({ description: 'review', membership: 'live', state: 'working' })
|
||||
])
|
||||
codex.connections[0]!.handlers.onExit?.(new Error('provider exited'))
|
||||
expect(records()).toEqual([
|
||||
expect.objectContaining({
|
||||
description: 'review',
|
||||
membership: 'settled',
|
||||
state: 'done',
|
||||
outcome: 'unknown'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentChildWorkEvidence } from '../../../../shared/agent-status/child-work/agent-status-child-work-evidence'
|
||||
import { codexPrimaryCommandTaskId } from '../../../../shared/structured-session-foreground-commands'
|
||||
import { CodexAppServerRequestError } from '../../app-server/codex-app-server-request-error'
|
||||
import { CodexAppServerTimeoutError } from '../../app-server/codex-app-server-session'
|
||||
import {
|
||||
acquireReadyCodexForTest,
|
||||
@@ -16,15 +15,6 @@ const HELPER = 'thread-helper'
|
||||
const HELPER_TURN = 'helper-turn-1'
|
||||
const identity = identityFor('session-1')
|
||||
|
||||
function refusal(code: number, message: string): CodexAppServerRequestError {
|
||||
return new CodexAppServerRequestError(
|
||||
'turn/interrupt',
|
||||
code,
|
||||
`codex app-server turn/interrupt failed: ${message}`,
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
async function runningHelper(routes: Record<string, Route>) {
|
||||
const codex = fakeCodex(routes)
|
||||
const evidence: AgentChildWorkEvidence[] = []
|
||||
@@ -59,82 +49,7 @@ async function runningHelper(routes: Record<string, Route>) {
|
||||
return { adapter, evidence, notify, interrupts, stop }
|
||||
}
|
||||
|
||||
function helperEdges(evidence: AgentChildWorkEvidence[]) {
|
||||
return evidence.filter(
|
||||
(edge) =>
|
||||
(edge.type === 'live'
|
||||
? edge.child.handle.id
|
||||
: edge.type === 'ended'
|
||||
? edge.handle.id
|
||||
: null) === HELPER
|
||||
)
|
||||
}
|
||||
|
||||
describe('Codex sub-agent Stop', () => {
|
||||
it('offers a sub-agent its own Stop and interrupts the turn it runs', async () => {
|
||||
const codex = await runningHelper({ 'turn/interrupt': () => ({}) })
|
||||
expect(codex.adapter.backgroundTaskStops('session-1')).toEqual({
|
||||
supportsTaskStop: true,
|
||||
supportsStopAll: false
|
||||
})
|
||||
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
|
||||
type: 'live',
|
||||
child: { kind: 'agent', stoppable: true }
|
||||
})
|
||||
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
|
||||
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
|
||||
|
||||
codex.notify('turn/completed', {
|
||||
threadId: HELPER,
|
||||
turn: { id: HELPER_TURN, status: 'interrupted' }
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
|
||||
type: 'ended',
|
||||
outcome: 'cancelled'
|
||||
})
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false })
|
||||
expect(codex.interrupts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['has no turn running', 'no active turn to interrupt'],
|
||||
[
|
||||
'has moved on to another turn',
|
||||
`expected active turn id ${HELPER_TURN} but found helper-turn-2`
|
||||
],
|
||||
['was unloaded', `thread not found: ${HELPER}`]
|
||||
])('stays quiet when Codex says the child %s', async (_, message) => {
|
||||
const codex = await runningHelper({
|
||||
'turn/interrupt': () => {
|
||||
throw refusal(-32600, message)
|
||||
}
|
||||
})
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false })
|
||||
})
|
||||
|
||||
it('says the sub-agent still runs when Codex could not carry the interrupt out', async () => {
|
||||
const codex = await runningHelper({
|
||||
'turn/interrupt': () => {
|
||||
throw refusal(-32603, 'failed to interrupt turn')
|
||||
}
|
||||
})
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: false, stillRunning: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['times out', new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')],
|
||||
['loses the app-server', new Error('codex app-server connection closed')]
|
||||
])('leaves the Stop unconfirmed when the interrupt %s', async (_, error) => {
|
||||
const codex = await runningHelper({
|
||||
'turn/interrupt': () => {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
await expect(codex.stop([HELPER])).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('stays quiet when the interrupt landed as the run ended and Codex held it unanswered', async () => {
|
||||
let endRun = (): void => {}
|
||||
const codex = await runningHelper({
|
||||
@@ -159,76 +74,6 @@ describe('Codex sub-agent Stop', () => {
|
||||
expect(codex.interrupts()).toEqual([])
|
||||
})
|
||||
|
||||
it("ends the helper's own sub-agents with it", async () => {
|
||||
const codex = await runningHelper({ 'turn/interrupt': () => ({}) })
|
||||
codex.notify('item/started', {
|
||||
threadId: HELPER,
|
||||
turnId: HELPER_TURN,
|
||||
item: {
|
||||
type: 'subAgentActivity',
|
||||
id: 'spawn-nested',
|
||||
kind: 'started',
|
||||
agentThreadId: 'thread-nested',
|
||||
agentPath: '/root/helper/nested'
|
||||
}
|
||||
})
|
||||
codex.notify('turn/started', {
|
||||
threadId: 'thread-nested',
|
||||
turn: { id: 'nested-turn-1', status: 'inProgress' }
|
||||
})
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
|
||||
expect(codex.interrupts()).toEqual([
|
||||
{ threadId: HELPER, turnId: HELPER_TURN },
|
||||
{ threadId: 'thread-nested', turnId: 'nested-turn-1' }
|
||||
])
|
||||
})
|
||||
|
||||
it('terminates the command a helper is running, which its interrupt leaves running', async () => {
|
||||
const running = ['4343']
|
||||
const codex = await runningHelper({
|
||||
'turn/interrupt': () => ({}),
|
||||
'thread/backgroundTerminals/list': () => ({
|
||||
data: running.map((processId) => ({ processId })),
|
||||
nextCursor: null
|
||||
}),
|
||||
'thread/backgroundTerminals/terminate': (params) => {
|
||||
running.splice(running.indexOf(String(params?.processId)), 1)
|
||||
return { terminated: true }
|
||||
}
|
||||
})
|
||||
const command = (status: 'inProgress' | 'completed') => ({
|
||||
threadId: HELPER,
|
||||
turnId: HELPER_TURN,
|
||||
item: {
|
||||
type: 'commandExecution',
|
||||
id: 'helper-exec',
|
||||
processId: '4343',
|
||||
source: 'unifiedExecStartup',
|
||||
command: 'sleep 90',
|
||||
status
|
||||
}
|
||||
})
|
||||
codex.notify('item/started', command('inProgress'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
await expect(codex.stop([HELPER])).resolves.toEqual({ cancelled: true })
|
||||
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
|
||||
expect(running).toEqual([])
|
||||
codex.notify('turn/completed', {
|
||||
threadId: HELPER,
|
||||
turn: { id: HELPER_TURN, status: 'interrupted' }
|
||||
})
|
||||
codex.notify('item/completed', command('completed'))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(helperEdges(codex.evidence).at(-1)).toMatchObject({
|
||||
type: 'ended',
|
||||
outcome: 'cancelled'
|
||||
})
|
||||
expect(codex.evidence.at(-1)).toMatchObject({ type: 'removed' })
|
||||
})
|
||||
|
||||
async function helperAndDevServer(interrupt: Route) {
|
||||
const running = ['4242']
|
||||
const terminated: unknown[] = []
|
||||
@@ -261,22 +106,6 @@ describe('Codex sub-agent Stop', () => {
|
||||
return { ...codex, running, terminated, stopBoth }
|
||||
}
|
||||
|
||||
it('stops a sub-agent and a backgrounded command in one Stop', async () => {
|
||||
const codex = await helperAndDevServer(() => ({}))
|
||||
await expect(codex.stopBoth()).resolves.toEqual({ cancelled: true })
|
||||
expect(codex.interrupts()).toEqual([{ threadId: HELPER, turnId: HELPER_TURN }])
|
||||
expect(codex.running).toEqual([])
|
||||
})
|
||||
|
||||
it('does not ask an app-server that timed out on the sub-agent to stop the command too', async () => {
|
||||
const timeout = new CodexAppServerTimeoutError('codex app-server turn/interrupt timed out')
|
||||
const codex = await helperAndDevServer(() => {
|
||||
throw timeout
|
||||
})
|
||||
await expect(codex.stopBoth()).rejects.toBe(timeout)
|
||||
expect(codex.terminated).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves a helper's sibling and the session's own background process alone", async () => {
|
||||
const codex = await helperAndDevServer(() => ({}))
|
||||
codex.notify('item/started', {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import * as Files from 'node:fs/promises'
|
||||
import type * as Files from 'node:fs/promises'
|
||||
import { scanClaudeCommandFolders } from '../../../claude/claude-command-folder-scan'
|
||||
import * as Catalog from '../../../native-chat/agent-session-wire/at-rest-command-catalog'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture'
|
||||
import type { CodexAppServerInvocation } from '../../app-server/codex-app-server-session'
|
||||
import type { CodexAtRestSkillsDeps } from './codex-at-rest-skills-probe'
|
||||
import { createCodexAtRestSkillCatalog } from './codex-at-rest-skills-probe'
|
||||
import type * as Catalog from '../../../native-chat/agent-session-wire/at-rest-command-catalog'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const folders = vi.hoisted(() => ({ open: vi.fn(), read: vi.fn() }))
|
||||
vi.mock('node:fs/promises', async (original) => ({
|
||||
@@ -17,16 +12,6 @@ vi.mock('node:fs/promises', async (original) => ({
|
||||
}))
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function floatingCodexChat(sessionId: string, launchDirectory: string) {
|
||||
return agentSessionRecordFixture({
|
||||
sessionId,
|
||||
provider: 'codex',
|
||||
accountHome: { variable: 'CODEX_HOME', path: '/home/me/.codex' },
|
||||
launchDirectory,
|
||||
location: { workspaceId: FLOATING_TERMINAL_WORKTREE_ID }
|
||||
})
|
||||
}
|
||||
|
||||
const visits = vi.hoisted(() => vi.fn((current: boolean) => current))
|
||||
vi.mock('../../../native-chat/agent-session-wire/at-rest-command-catalog', async (original) => {
|
||||
const actual = await original<typeof Catalog>()
|
||||
@@ -41,43 +26,6 @@ vi.mock('../../../native-chat/agent-session-wire/at-rest-command-catalog', async
|
||||
})
|
||||
}
|
||||
})
|
||||
function harness(deps: Partial<CodexAtRestSkillsDeps> = {}) {
|
||||
const initialized = Promise.withResolvers<void>(),
|
||||
closed = Promise.withResolvers<void>()
|
||||
let live = 0,
|
||||
peak = 0
|
||||
const calls: CodexAppServerInvocation[] = []
|
||||
const request = vi.fn(listingRpc.request)
|
||||
const catalog = createCodexAtRestSkillCatalog({
|
||||
resolveWorkspacePath: async () => '/work/one',
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveCommand: () => ({ command: '/bin/codex', prefixArgs: [] }),
|
||||
runSession: async (invocation, body) => {
|
||||
calls.push(invocation)
|
||||
peak = Math.max(peak, ++live)
|
||||
try {
|
||||
await initialized.promise
|
||||
return await body({ request, notify: () => {} })
|
||||
} finally {
|
||||
await closed.promise
|
||||
live--
|
||||
}
|
||||
},
|
||||
...deps
|
||||
})
|
||||
return { catalog, calls, request, initialized, closed, peak: () => peak }
|
||||
}
|
||||
const listingRpc = {
|
||||
request: async (_method: string, params: Record<string, unknown> = {}) => ({
|
||||
data: (Array.isArray(params.cwds) ? params.cwds.map(String) : [])
|
||||
.map((cwd) => {
|
||||
const name = `${cwd.split('/').at(-1)}-skill`
|
||||
return { cwd, skills: [{ name, path: `${cwd}/${name}/SKILL.md` }] }
|
||||
})
|
||||
.toReversed()
|
||||
}),
|
||||
notify: () => {}
|
||||
}
|
||||
|
||||
it('closes a directory opened after cancellation before any read or recursion', async () => {
|
||||
const opened = Promise.withResolvers<unknown>()
|
||||
@@ -95,245 +43,3 @@ it('closes a directory opened after cancellation before any read or recursion',
|
||||
expect(folders.open).toHaveBeenCalledExactlyOnceWith('/commands')
|
||||
expect(folders.read).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('a resting Codex chat lists the skills Codex would load', () => {
|
||||
it('answers every pinned folder from one probe before it settles, which then ends the whole tree', async () => {
|
||||
const invocations: CodexAppServerInvocation[] = []
|
||||
const settling: (() => void)[] = []
|
||||
let prefixArgs = ['run', '--', 'codex']
|
||||
const catalog = createCodexAtRestSkillCatalog({
|
||||
resolveWorkspacePath: async () => '/unused',
|
||||
resolveLaunchArgs: () => [
|
||||
'--disable',
|
||||
'plugins',
|
||||
'-c',
|
||||
'features.test=true',
|
||||
'--sandbox',
|
||||
'read-only'
|
||||
],
|
||||
resolveEnvironment: async () => ({ PATH: '/bin', CUSTOM_AGENT_ENV: 'configured' }),
|
||||
resolveCommand: () => ({ command: '/bin/launcher', prefixArgs }),
|
||||
sleep: () => new Promise((resolve) => settling.push(resolve)),
|
||||
runSession: async (invocation, body) => {
|
||||
invocations.push(invocation)
|
||||
return body(listingRpc)
|
||||
}
|
||||
})
|
||||
const one = floatingCodexChat('one', '/work/one')
|
||||
const two = floatingCodexChat('two', '/work/two')
|
||||
|
||||
expect(catalog.read(one)).toBeUndefined()
|
||||
expect(catalog.read(two)).toBeUndefined()
|
||||
await Promise.all([catalog.settled(one, 1_000), catalog.settled(two, 1_000)])
|
||||
|
||||
expect(catalog.read(one)?.filter((row) => row.kind === 'skill')).toEqual([
|
||||
{ name: 'one-skill', kind: 'skill', path: '/work/one/one-skill/SKILL.md' }
|
||||
])
|
||||
expect(catalog.read(two)?.filter((row) => row.kind === 'skill')).toEqual([
|
||||
{ name: 'two-skill', kind: 'skill', path: '/work/two/two-skill/SKILL.md' }
|
||||
])
|
||||
expect(invocations.map((invocation) => invocation.args)).toEqual([
|
||||
['run', '--', 'codex', '--disable', 'plugins', '-c', 'features.test=true', 'app-server']
|
||||
])
|
||||
// A settling probe must not hold the folder open, or Windows cannot remove the worktree.
|
||||
expect(invocations[0]?.cwd?.startsWith('/work')).toBe(false)
|
||||
expect(invocations[0]?.treeKillOnFinish).toBe(true)
|
||||
expect(invocations[0]?.env).toMatchObject({
|
||||
CODEX_HOME: '/home/me/.codex',
|
||||
CUSTOM_AGENT_ENV: 'configured'
|
||||
})
|
||||
expect(invocations[0]?.envToDelete).toEqual(['ORCA_CHAT_SCRATCH_DIR', 'ORCA_CHAT_VISUALS_DIR'])
|
||||
expect(settling).toHaveLength(1)
|
||||
prefixArgs = ['run', '--profile', 'other', '--', 'codex']
|
||||
catalog.retireContext()
|
||||
await catalog.settled(one, 1_000)
|
||||
expect(catalog.read(one)?.some((row) => row.name === 'one-skill')).toBe(true)
|
||||
expect(invocations).toHaveLength(2)
|
||||
expect(invocations[1]?.args).toEqual([
|
||||
...prefixArgs,
|
||||
'--disable',
|
||||
'plugins',
|
||||
'-c',
|
||||
'features.test=true',
|
||||
'app-server'
|
||||
])
|
||||
settling.forEach((resolve) => resolve())
|
||||
catalog.dispose()
|
||||
})
|
||||
|
||||
it.each(['method not found', 'temporary failure'])(
|
||||
'retains the floor after %s without holding the answer',
|
||||
async (message) => {
|
||||
const floor = Promise.withResolvers<void>()
|
||||
let closed = false
|
||||
const sleep = vi.fn(() => floor.promise)
|
||||
const catalog = createCodexAtRestSkillCatalog({
|
||||
resolveWorkspacePath: async () => '/work',
|
||||
resolveLaunchArgs: () => [],
|
||||
resolveCommand: () => ({ command: '/bin/codex', prefixArgs: [] }),
|
||||
now: () => 0,
|
||||
sleep,
|
||||
runSession: async (_invocation, body) => {
|
||||
try {
|
||||
return await body({
|
||||
request: async () => {
|
||||
throw new Error(message)
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
} finally {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
const record = floatingCodexChat('failed', '/work')
|
||||
catalog.read(record)
|
||||
await catalog.settled(record, 1_000)
|
||||
expect(sleep).toHaveBeenCalledWith(70_000)
|
||||
expect(closed).toBe(false)
|
||||
floor.resolve()
|
||||
await vi.waitFor(() => expect(closed).toBe(true))
|
||||
}
|
||||
)
|
||||
it.each([16, 160])(
|
||||
'keeps %i queued folders linear through cancellation and teardown',
|
||||
async (count) => {
|
||||
vi.useFakeTimers()
|
||||
visits.mockClear()
|
||||
let canonical = true,
|
||||
mirrored = true
|
||||
const h = harness({
|
||||
prepareHome: () => {
|
||||
mirrored = canonical
|
||||
}
|
||||
}),
|
||||
removed = vi.spyOn(Set.prototype, 'delete')
|
||||
const chats = Array.from({ length: count }, (_, i) =>
|
||||
floatingCodexChat(String(i), `/work/w${i}`)
|
||||
)
|
||||
chats.forEach(h.catalog.read)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
canonical = false
|
||||
h.catalog.retireContext()
|
||||
chats.forEach(h.catalog.read)
|
||||
h.request.mockImplementation(async (_method, params = {}) => ({
|
||||
data: (Array.isArray(params.cwds) ? params.cwds.map(String) : []).map((cwd) => ({
|
||||
cwd,
|
||||
skills: mirrored ? [{ name: 'plugin', path: '/plugin/SKILL.md' }] : []
|
||||
}))
|
||||
}))
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
h.initialized.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.calls).toHaveLength(1)
|
||||
expect(h.request).toHaveBeenCalledTimes(count / 8)
|
||||
expect(visits.mock.calls.length).toBeLessThan(8 * count)
|
||||
// Pool requests and removable answer waiters each have a linear work budget.
|
||||
const values = removed.mock.calls.map(([value]) => value)
|
||||
expect(
|
||||
values.filter((value) => value && typeof value === 'object' && 'cwd' in value)
|
||||
).toHaveLength(4 * count)
|
||||
expect(
|
||||
values.filter((value) => value && typeof value === 'object' && 'ready' in value)
|
||||
).toHaveLength(16 * count)
|
||||
removed.mockRestore()
|
||||
expect(chats.map(h.catalog.read).every(Boolean)).toBe(true)
|
||||
h.catalog.retireContext()
|
||||
h.request.mockRejectedValueOnce(new Error('reload failed'))
|
||||
h.catalog.read(chats[0])
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
await h.catalog.settled(chats[0], 1_000)
|
||||
expect(h.request.mock.calls[0][1]?.forceReload).toBe(true)
|
||||
expect(h.request.mock.lastCall?.[1]?.forceReload).toBe(true)
|
||||
expect(h.catalog.read(chats[0])?.filter((row) => row.kind === 'skill')).toEqual([])
|
||||
h.catalog.retireContext()
|
||||
await vi.advanceTimersByTimeAsync(59_999)
|
||||
expect(h.calls[0].signal?.aborted).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
const identities = chats.map((chat, i) => ({
|
||||
...chat,
|
||||
accountHome: { ...chat.accountHome, path: String(i) }
|
||||
}))
|
||||
identities.slice(0, 2).forEach(h.catalog.read)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.calls).toHaveLength(2)
|
||||
expect(h.peak()).toBe(2)
|
||||
h.closed.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(h.calls).toHaveLength(3)
|
||||
expect(h.peak()).toBe(2)
|
||||
h.catalog.dispose()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
const failed = harness()
|
||||
failed.closed.resolve()
|
||||
failed.initialized.reject(new Error('initialize failed'))
|
||||
identities.forEach(failed.catalog.read)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(failed.calls).toHaveLength(count)
|
||||
for (const seconds of [10, 30, 90, 300]) {
|
||||
const before = failed.calls.length
|
||||
failed.catalog.retireContext()
|
||||
failed.catalog.read(identities[0])
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(failed.calls).toHaveLength(before)
|
||||
await vi.advanceTimersByTimeAsync(seconds * 1_000)
|
||||
failed.catalog.retireContext()
|
||||
await failed.catalog.settled(identities[0], 1_000)
|
||||
expect(failed.calls).toHaveLength(before + 1)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(3_170_000)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await vi.advanceTimersByTimeAsync(430_000)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
failed.catalog.dispose()
|
||||
warned.mockRestore()
|
||||
}
|
||||
)
|
||||
it('caps per-key failures, ages success from admission, skips missing cwd and evicts unread entries', async () => {
|
||||
vi.useFakeTimers()
|
||||
const completion = Promise.withResolvers<void>()
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const scan = vi.fn<Catalog.AtRestCommandSource['scan']>(async (...args) => {
|
||||
if (scan.mock.calls.length <= 3) {
|
||||
throw new Error('source failed')
|
||||
}
|
||||
args[5]()
|
||||
await (scan.mock.calls.length === 4 ? completion.promise : undefined)
|
||||
return { commands: [], fromAgent: true }
|
||||
})
|
||||
const catalog = new Catalog.AtRestCommandCatalog({
|
||||
accepts: () => true,
|
||||
resolveWorkspacePath: async () => '/work',
|
||||
label: 'fixture',
|
||||
scan
|
||||
})
|
||||
const chat = floatingCodexChat('one', '/work/one')
|
||||
for (const [attempt, ms] of [0, 10_000, 10_000, 299_999].entries()) {
|
||||
await vi.advanceTimersByTimeAsync(ms)
|
||||
catalog.read(chat)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(scan).toHaveBeenCalledTimes(Math.min(attempt + 1, 3))
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
catalog.read(chat)
|
||||
await vi.advanceTimersByTimeAsync(105_000)
|
||||
completion.resolve()
|
||||
await catalog.settled(chat, 1_000)
|
||||
expect(catalog.read(chat)).toEqual([])
|
||||
await vi.advanceTimersByTimeAsync(195_000)
|
||||
await catalog.settled(chat, 1_000)
|
||||
expect(scan).toHaveBeenCalledTimes(5)
|
||||
catalog.retireContext()
|
||||
vi.mocked(Files.stat).mockRejectedValueOnce({ code: 'ENOENT' })
|
||||
expect(await catalog.settled(chat, 1_000)).toBeUndefined()
|
||||
expect(scan).toHaveBeenCalledTimes(5)
|
||||
const removed = vi.spyOn(Map.prototype, 'delete')
|
||||
await vi.advanceTimersByTimeAsync(3_600_000)
|
||||
await catalog.settled(floatingCodexChat('two', '/work/two'), 1_000)
|
||||
expect(removed.mock.calls.flat().join()).toContain('/work/one')
|
||||
removed.mockRestore()
|
||||
catalog.dispose()
|
||||
warned.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { AgentModelCatalogUnavailableError } from '../../../native-chat/agent-model-catalog/agent-model-catalog-unavailable'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createCodexModelCatalogProbe } from './codex-model-catalog-probe'
|
||||
import { resolveCodexStructuredInvocation } from '../launch/codex-structured-launch-resolution'
|
||||
import {
|
||||
runCodexAppServerSession,
|
||||
type CodexAppServerInvocation
|
||||
} from '../../app-server/codex-app-server-session'
|
||||
import { runCodexAppServerSession } from '../../app-server/codex-app-server-session'
|
||||
import { resolveStructuredAgentCommand } from '../../../native-chat/structured-agent-command-resolution'
|
||||
|
||||
const MODEL_ROW = {
|
||||
@@ -17,61 +12,6 @@ const MODEL_ROW = {
|
||||
}
|
||||
|
||||
describe('codex model catalog probe', () => {
|
||||
it.each([`"${process.execPath}"`, '', `"${process.execPath}" run -- codex`])(
|
||||
'lists with the session executable for Command %j',
|
||||
async (command) => {
|
||||
const resolveEnvironment = async (): Promise<NodeJS.ProcessEnv> => ({
|
||||
PATH: '/resolved/bin',
|
||||
HOME: '/homes/user',
|
||||
OPENAI_BASE_URL: 'https://gateway.example',
|
||||
DROPPED: undefined
|
||||
})
|
||||
const resolveCommand = vi.fn((options?: { pathEnv?: string | null; homePath?: string }) => {
|
||||
expect(options?.pathEnv).toBe('/resolved/bin')
|
||||
expect(options?.homePath).toBe('/homes/user')
|
||||
return resolveStructuredAgentCommand(
|
||||
'codex',
|
||||
{
|
||||
agentCmdOverrides: { codex: command }
|
||||
},
|
||||
options
|
||||
)
|
||||
})
|
||||
const invocations: CodexAppServerInvocation[] = []
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment,
|
||||
resolveCommand,
|
||||
runSession: async (invocation, body) => {
|
||||
invocations.push(invocation)
|
||||
return body({
|
||||
request: async () => ({ data: [MODEL_ROW], nextCursor: null }),
|
||||
notify: () => {}
|
||||
})
|
||||
}
|
||||
})
|
||||
const success = await probe({ variable: 'CODEX_HOME', path: '/homes/account-a' })
|
||||
expect(success.origin).toBe('probe')
|
||||
expect(success.models.map((model) => model.id)).toEqual(['gpt-live'])
|
||||
const sessionInvocation = await resolveCodexStructuredInvocation({
|
||||
resolveEnvironment,
|
||||
resolveCommand
|
||||
})
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0]!.cliPath).toBe(sessionInvocation.command)
|
||||
expect(invocations[0]!.args.slice(0, sessionInvocation.prefixArgs.length)).toEqual(
|
||||
sessionInvocation.prefixArgs
|
||||
)
|
||||
expect(invocations[0]!.env).toEqual({
|
||||
PATH: '/resolved/bin',
|
||||
HOME: '/homes/user',
|
||||
OPENAI_BASE_URL: 'https://gateway.example',
|
||||
CODEX_HOME: '/homes/account-a'
|
||||
})
|
||||
// Marketplace clones may outlive short-lived probe teardown.
|
||||
expect(invocations[0]!.args.join(' ')).toContain('features.plugins=false')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the listing when config/read never answers', async () => {
|
||||
const server = String.raw`
|
||||
const readline = require('node:readline')
|
||||
@@ -131,18 +71,6 @@ describe('codex model catalog probe', () => {
|
||||
])
|
||||
}, 10_000)
|
||||
|
||||
it('refuses an empty listing rather than reporting it as a catalog', async () => {
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({ PATH: '/bin' }),
|
||||
resolveCommand: () => ({ command: '/bin/codex', prefixArgs: [] }),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({ request: async () => ({ data: [], nextCursor: null }), notify: () => {} })
|
||||
})
|
||||
await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toThrow(
|
||||
/listed no models/
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['launcher $SECRET codex', 'FOO=1 codex', '/missing/codex', './codex'])(
|
||||
'lists nothing, and never probes the stock CLI, for Command %s',
|
||||
async (command) => {
|
||||
@@ -164,185 +92,3 @@ describe('codex model catalog probe', () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('Codex catalog availability', () => {
|
||||
function withAccount(account: unknown) {
|
||||
return createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
// Isolate account classification from the real ~/.codex/auth.json.
|
||||
resolveAccountKind: (home) => (home === '/homes/system' ? 'system' : 'managed'),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({
|
||||
request: async (method, params, options) => {
|
||||
if (method === 'account/read') {
|
||||
expect(params).toEqual({ refreshToken: false })
|
||||
expect(options?.timeoutMs).toBe(2000)
|
||||
if (account instanceof Error) {
|
||||
throw account
|
||||
}
|
||||
return account
|
||||
}
|
||||
return { data: [MODEL_ROW], nextCursor: null }
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
})
|
||||
}
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
{ account: null },
|
||||
{ requiresOpenaiAuth: true },
|
||||
{ account: null, requiresOpenaiAuth: false },
|
||||
{ account: {}, requiresOpenaiAuth: true },
|
||||
{ account: null, requiresOpenaiAuth: 'true' },
|
||||
new Error('unsupported method'),
|
||||
new Error('timeout')
|
||||
])('unknown or authenticated account does not block: %j', async (account) => {
|
||||
expect(
|
||||
(await withAccount(account)({ variable: 'CODEX_HOME', path: '/homes/a' })).models
|
||||
).toHaveLength(1)
|
||||
})
|
||||
it.each([
|
||||
['/homes/a', 'managed'],
|
||||
['/homes/system', 'system']
|
||||
] as const)(
|
||||
'reports explicit signed-out account for %s beside its list',
|
||||
async (home, account) => {
|
||||
expect(
|
||||
await withAccount({ account: null, requiresOpenaiAuth: true })({
|
||||
variable: 'CODEX_HOME',
|
||||
path: home
|
||||
})
|
||||
).toMatchObject({
|
||||
models: [{ id: 'gpt-live' }],
|
||||
unavailable: { reason: 'notSignedIn', account }
|
||||
})
|
||||
}
|
||||
)
|
||||
it('omits unknown account context from a positive signed-out fact', async () => {
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({
|
||||
request: async (method) =>
|
||||
method === 'account/read'
|
||||
? { account: null, requiresOpenaiAuth: true }
|
||||
: { data: [MODEL_ROW], nextCursor: null },
|
||||
notify: () => {}
|
||||
})
|
||||
})
|
||||
expect((await probe({ variable: 'CODEX_HOME', path: '/custom/home' })).unavailable).toEqual({
|
||||
reason: 'notSignedIn'
|
||||
})
|
||||
})
|
||||
it('lists models while the account check is still answering', async () => {
|
||||
let answerAccount!: (value: unknown) => void
|
||||
const methods: string[] = []
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({
|
||||
request: async (method) => {
|
||||
methods.push(method)
|
||||
if (method === 'account/read') {
|
||||
return new Promise((resolve) => (answerAccount = resolve))
|
||||
}
|
||||
return { data: [MODEL_ROW], nextCursor: null }
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
})
|
||||
const pending = probe({ variable: 'CODEX_HOME', path: '/homes/a' })
|
||||
await vi.waitFor(() => expect(methods).toContain('model/list'))
|
||||
expect(methods).toContain('account/read')
|
||||
answerAccount({ account: { type: 'chatgpt' }, requiresOpenaiAuth: true })
|
||||
expect((await pending).models).toHaveLength(1)
|
||||
})
|
||||
it('a signed-out verdict wins over a model listing that failed', async () => {
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({
|
||||
request: async (method) => {
|
||||
if (method === 'account/read') {
|
||||
return { account: null, requiresOpenaiAuth: true }
|
||||
}
|
||||
throw new Error('401 Unauthorized')
|
||||
},
|
||||
notify: () => {}
|
||||
})
|
||||
})
|
||||
await expect(probe({ variable: 'CODEX_HOME', path: '/homes/a' })).rejects.toMatchObject({
|
||||
unavailable: { reason: 'notSignedIn' }
|
||||
})
|
||||
})
|
||||
it.each(['ENOENT', 'EACCES'])('classifies only missing executable errors: %s', async (code) => {
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
runSession: async () => {
|
||||
throw Object.assign(new Error('spawn failed'), { code, path: 'codex' })
|
||||
}
|
||||
})
|
||||
const failure = await probe({ variable: 'CODEX_HOME', path: '/homes/a' }).catch(
|
||||
(error: unknown) => error
|
||||
)
|
||||
expect(failure instanceof AgentModelCatalogUnavailableError).toBe(code === 'ENOENT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex catalog probe reads the home a launch would', () => {
|
||||
it('runs the launch sync on the home before its app-server reads it', async () => {
|
||||
const order: string[] = []
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({}),
|
||||
resolveCommand: () => ({ command: 'codex', prefixArgs: [] }),
|
||||
prepareHome: (prepared) => order.push(`prepare ${prepared}`),
|
||||
runSession: async (_invocation, body) => {
|
||||
order.push('spawn')
|
||||
return body({
|
||||
request: async (method) =>
|
||||
method === 'account/read'
|
||||
? { account: { type: 'apiKey' }, requiresOpenaiAuth: true }
|
||||
: { data: [MODEL_ROW], nextCursor: null },
|
||||
notify: () => {}
|
||||
})
|
||||
}
|
||||
})
|
||||
expect((await probe({ variable: 'CODEX_HOME', path: '/homes/mirror' })).models).toHaveLength(1)
|
||||
expect(order).toEqual(['prepare /homes/mirror', 'spawn'])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unknown or unsupported configuration incomplete and observes genuine empty config', async () => {
|
||||
for (const config of [undefined, [], { model: 42 }, {}]) {
|
||||
const probe = createCodexModelCatalogProbe({
|
||||
resolveEnvironment: async () => ({ PATH: '/bin' }),
|
||||
resolveCommand: () => ({ command: '/bin/codex', prefixArgs: [] }),
|
||||
runSession: async (_invocation, body) =>
|
||||
body({
|
||||
notify: () => {},
|
||||
request: async (method) => {
|
||||
if (method === 'model/list') {
|
||||
return { data: [MODEL_ROW], nextCursor: null }
|
||||
}
|
||||
if (method === 'config/read') {
|
||||
if (config === undefined) {
|
||||
throw new Error('method not found')
|
||||
}
|
||||
return { config }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
})
|
||||
})
|
||||
expect((await probe({ variable: 'CODEX_HOME', path: '/account' })).complete).toBe(
|
||||
config !== undefined && !Array.isArray(config) && !('model' in config)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acquireReadyCodexForTest,
|
||||
codexStartReport,
|
||||
THREAD_ID,
|
||||
USER_MESSAGE,
|
||||
adapterFor,
|
||||
answerWithOpenedTurn,
|
||||
fakeCodex,
|
||||
identityFor
|
||||
} from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
import { AgentModelCatalogStore } from '../../../native-chat/agent-model-catalog/agent-model-catalog-store'
|
||||
import { agentModelCatalogSessionAccess } from '../../../native-chat/agent-model-catalog/agent-model-catalog-fingerprint'
|
||||
import type { CodexStructuredSessionAdapter } from '../session-lifecycle/codex-structured-session-adapter'
|
||||
import { CODEX_STRUCTURED_AGENT } from '../launch/codex-structured-agent-definition'
|
||||
|
||||
function listing(tier = 'priority') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [{ id: tier, name: 'Fast' }]
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
|
||||
async function acquire(adapter: CodexStructuredSessionAdapter, sessionId = 'session-1') {
|
||||
return acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor(sessionId),
|
||||
fence: 7,
|
||||
spawnToken: `spawn-${sessionId}`,
|
||||
options: { serviceTier: 'priority' }
|
||||
})
|
||||
}
|
||||
|
||||
async function send(adapter: CodexStructuredSessionAdapter, id: string, sessionId = 'session-1') {
|
||||
return adapter.dispatch({ sessionId, clientMessageId: id, body: USER_MESSAGE, fence: 7 })
|
||||
}
|
||||
|
||||
function finishTurn(codex: ReturnType<typeof fakeCodex>, turnId: string) {
|
||||
codex.connections.at(-1)?.handlers.onNotification?.('turn/completed', {
|
||||
threadId: THREAD_ID,
|
||||
turn: { id: turnId, status: 'completed' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('Codex structured service tier without send-path catalog waits', () => {
|
||||
it('starts the chat and sends the saved tier without asking its child for an inventory', async () => {
|
||||
const codex = fakeCodex({
|
||||
'model/list': () => {
|
||||
throw new Error('model listing unavailable')
|
||||
}
|
||||
})
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier')
|
||||
const adapter = adapterFor(codex)
|
||||
|
||||
await acquire(adapter)
|
||||
expect(codexStartReport(adapter).reportedOptions).toMatchObject({
|
||||
serviceTier: 'priority'
|
||||
})
|
||||
await send(adapter, 'first')
|
||||
expect(
|
||||
codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
|
||||
).toMatchObject({ serviceTier: 'priority' })
|
||||
})
|
||||
|
||||
it('steers an active turn without querying the child inventory', async () => {
|
||||
const codex = fakeCodex()
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn')
|
||||
codex.routes['turn/steer'] = () => ({ turn: { id: 'running-turn' } })
|
||||
const adapter = adapterFor(codex)
|
||||
await acquire(adapter)
|
||||
await send(adapter, 'first')
|
||||
await send(adapter, 'follow-up')
|
||||
expect(codex.connections[0].calls.some((call) => call.method === 'turn/steer')).toBe(true)
|
||||
expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('stops a running turn and starts its replacement on the saved tier', async () => {
|
||||
const codex = fakeCodex()
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'running-turn')
|
||||
const adapter = adapterFor(codex)
|
||||
await acquire(adapter)
|
||||
await send(adapter, 'first')
|
||||
|
||||
await expect(adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).resolves.toMatchObject({
|
||||
cancelled: true
|
||||
})
|
||||
finishTurn(codex, 'running-turn')
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'replacement-turn')
|
||||
await send(adapter, 'replacement')
|
||||
const starts = codex.connections[0].calls.filter((call) => call.method === 'turn/start')
|
||||
expect(starts).toHaveLength(2)
|
||||
expect(starts[1]?.params).toMatchObject({ serviceTier: 'priority' })
|
||||
})
|
||||
|
||||
it('restores the saved model and tier over the resumed thread’s', async () => {
|
||||
const codex = fakeCodex()
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-tier')
|
||||
const adapter = adapterFor(codex, { codexHome: '/codex/home', resumeThreadId: THREAD_ID })
|
||||
await acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor('session-1'),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-resume',
|
||||
options: { model: 'gpt-next', serviceTier: 'ultrafast' }
|
||||
})
|
||||
|
||||
expect(codex.connections[0].calls.some((call) => call.method === 'thread/resume')).toBe(true)
|
||||
expect(codexStartReport(adapter).reportedOptions).toEqual({
|
||||
model: 'gpt-next',
|
||||
reportedPermissionMode: null,
|
||||
serviceTier: 'ultrafast'
|
||||
})
|
||||
await send(adapter, 'first')
|
||||
expect(
|
||||
codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
|
||||
).toMatchObject({ model: 'gpt-next', serviceTier: 'ultrafast' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex service tier across clients and rest', () => {
|
||||
it.each([
|
||||
['opening null', null, 'default'],
|
||||
['configured tier', undefined, 'priority'],
|
||||
['configuration failure', undefined, undefined]
|
||||
] as const)(
|
||||
'seeds exact-child tier knowledge from %s without filling a sparse request',
|
||||
async (name, openingTier, expected) => {
|
||||
const codex = fakeCodex({
|
||||
'thread/start': () => ({
|
||||
thread: { id: THREAD_ID },
|
||||
model: 'gpt-live',
|
||||
reasoningEffort: 'high',
|
||||
...(openingTier === null ? { serviceTier: null } : {})
|
||||
}),
|
||||
'config/read': () => {
|
||||
if (name === 'configuration failure') {
|
||||
throw new Error('config unavailable')
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
service_tier: 'priority',
|
||||
model: 'unrelated-config-model',
|
||||
model_reasoning_effort: 'low'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'sparse')
|
||||
const adapter = adapterFor(codex)
|
||||
try {
|
||||
const acquired = await acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor('session-1'),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
const applied = adapter.readAppliedInputOptions({
|
||||
sessionId: 'session-1',
|
||||
fence: 7,
|
||||
acquisitionGeneration: acquired.acquisitionGeneration,
|
||||
next: {}
|
||||
})
|
||||
expect(applied?.active).toEqual({
|
||||
model: 'gpt-live',
|
||||
effort: 'high',
|
||||
...(expected ? { serviceTier: expected } : {})
|
||||
})
|
||||
const config = codex.connections[0].calls.filter((call) => call.method === 'config/read')
|
||||
expect(config).toHaveLength(openingTier === null ? 0 : 1)
|
||||
if (config.length) {
|
||||
expect(config[0].params).toEqual({ cwd: '/work/repo' })
|
||||
}
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
fence: 7,
|
||||
clientMessageId: 'sparse',
|
||||
body: USER_MESSAGE,
|
||||
executionOptions: {}
|
||||
})
|
||||
const sent = codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
|
||||
expect(sent).not.toHaveProperty('model')
|
||||
expect(sent).not.toHaveProperty('effort')
|
||||
expect(sent).not.toHaveProperty('serviceTier')
|
||||
} finally {
|
||||
await adapter.closeAll()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('applies an older client Fast toggle through the adapter as the Fast tier', async () => {
|
||||
const codex = fakeCodex({ 'model/list': () => listing() })
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-fast')
|
||||
const modelCatalog = new AgentModelCatalogStore()
|
||||
const access = agentModelCatalogSessionAccess(
|
||||
modelCatalog,
|
||||
CODEX_STRUCTURED_AGENT,
|
||||
'/codex/home'
|
||||
)!
|
||||
modelCatalog.recordSuccess(
|
||||
access.fingerprint,
|
||||
'codex',
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-live',
|
||||
label: 'GPT Live',
|
||||
isDefault: true,
|
||||
efforts: [],
|
||||
serviceTiers: [{ value: 'priority', label: 'Fast' }]
|
||||
}
|
||||
],
|
||||
origin: 'probe',
|
||||
complete: true
|
||||
},
|
||||
'discovery'
|
||||
)
|
||||
const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], { modelCatalog })
|
||||
await acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor('session-1'),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'fastMode', value: 'true', fence: 7 })
|
||||
).resolves.toEqual({ serviceTier: 'priority' })
|
||||
await expect(
|
||||
adapter.setOption({ sessionId: 'session-1', key: 'approvalPolicy', value: 'never', fence: 7 })
|
||||
).rejects.toThrow('no thread option named approvalPolicy')
|
||||
await send(adapter, 'first')
|
||||
const turn = codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
|
||||
expect(turn).toMatchObject({ serviceTier: 'priority' })
|
||||
expect(turn).not.toHaveProperty('fastMode')
|
||||
finishTurn(codex, 'turn-fast')
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'captured-fast')
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
fence: 7,
|
||||
clientMessageId: 'captured-fast',
|
||||
body: USER_MESSAGE,
|
||||
executionOptions: { fastMode: 'true' }
|
||||
})
|
||||
const captured = codex.connections[0].calls.findLast(
|
||||
(call) => call.method === 'turn/start'
|
||||
)?.params
|
||||
expect(captured).toMatchObject({ serviceTier: 'priority' })
|
||||
expect(captured).not.toHaveProperty('model')
|
||||
expect(captured).not.toHaveProperty('effort')
|
||||
})
|
||||
|
||||
it('takes a tier at rest but not an older client Fast toggle', () => {
|
||||
expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('serviceTier')).toBe(true)
|
||||
expect(CODEX_STRUCTURED_AGENT.restingOptions?.acceptsKey('fastMode')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,734 +0,0 @@
|
||||
import {
|
||||
acquireReadyCodexForTest,
|
||||
USER_MESSAGE,
|
||||
adapterFor,
|
||||
answerWithOpenedTurn,
|
||||
fakeCodex,
|
||||
identityFor
|
||||
} from '../session-lifecycle/codex-structured-session-adapter-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyCodexStructuredSessionOption,
|
||||
readLiveCodexSessionOptions,
|
||||
restoredCodexSessionOptions
|
||||
} from './codex-structured-session-options'
|
||||
import { reportedCodexThreadOptions } from './codex-structured-service-tier'
|
||||
import type { CodexSession } from '../session-lifecycle/codex-structured-session-state'
|
||||
import { startCodexTurn } from '../turns/codex-structured-turn-start'
|
||||
import { AgentModelCatalogStore } from '../../../native-chat/agent-model-catalog/agent-model-catalog-store'
|
||||
import { optionSession } from './codex-structured-option-session.test-fixture'
|
||||
|
||||
import {
|
||||
fetchCodexModelCatalogListing,
|
||||
composeCodexSessionOptionCatalog
|
||||
} from './codex-structured-model-catalog'
|
||||
|
||||
async function readCodexStructuredSessionOptionCatalog(
|
||||
input: Parameters<typeof fetchCodexModelCatalogListing>[0] &
|
||||
Parameters<typeof composeCodexSessionOptionCatalog>[1]
|
||||
) {
|
||||
return composeCodexSessionOptionCatalog(await fetchCodexModelCatalogListing(input), input)
|
||||
}
|
||||
const readCodexStructuredSessionOptions = readCodexStructuredSessionOptionCatalog
|
||||
|
||||
async function primePicker(session: CodexSession): Promise<void> {
|
||||
session.catalogAccess = {
|
||||
store: new AgentModelCatalogStore(),
|
||||
fingerprint: 'codex-test-account',
|
||||
accountHomePath: '/test-account'
|
||||
}
|
||||
const listing = await readCodexStructuredSessionOptionCatalog({
|
||||
connection: session.connection,
|
||||
current: {}
|
||||
})
|
||||
session.catalogAccess.store.recordSuccess(
|
||||
session.catalogAccess.fingerprint,
|
||||
'codex',
|
||||
{
|
||||
models: listing.models,
|
||||
origin: 'probe',
|
||||
complete: true,
|
||||
...(listing.fastModeSupport ? { fastModeSupport: listing.fastModeSupport } : {})
|
||||
},
|
||||
'discovery'
|
||||
)
|
||||
}
|
||||
|
||||
describe('structured Codex session options', () => {
|
||||
it.each([
|
||||
['model', 'gpt-next'],
|
||||
['effort', 'high'],
|
||||
['serviceTier', 'ultrafast']
|
||||
])('keeps a cold %s pick as intent without starting a model request', async (key, value) => {
|
||||
const request = vi.fn(async () => ({ data: [] }))
|
||||
const session = optionSession(request)
|
||||
|
||||
await expect(applyCodexStructuredSessionOption(session, key, value)).resolves.toMatchObject({
|
||||
[key]: value
|
||||
})
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves the saved effort through a cold model change', async () => {
|
||||
const session = optionSession(vi.fn(async () => ({ data: [] })))
|
||||
session.options.set('model', 'gpt-live')
|
||||
session.options.set('effort', 'xhigh')
|
||||
|
||||
await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-live')).resolves.toEqual({
|
||||
model: 'gpt-live',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-next')).resolves.toEqual({
|
||||
model: 'gpt-next',
|
||||
effort: 'xhigh'
|
||||
})
|
||||
})
|
||||
|
||||
it('turns Fast off immediately even without a catalog', async () => {
|
||||
const request = vi.fn(async () => ({ data: [] }))
|
||||
const session = optionSession(request)
|
||||
session.options.set('serviceTier', 'priority-old')
|
||||
|
||||
await expect(applyCodexStructuredSessionOption(session, 'fastMode', 'false')).resolves.toEqual({
|
||||
serviceTier: 'default'
|
||||
})
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('encodes legacy Fast with the native value when no menu names its tier', async () => {
|
||||
const session = optionSession(vi.fn(async () => ({ data: [] })))
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'fastMode', 'true')
|
||||
).resolves.toMatchObject({
|
||||
serviceTier: 'fast'
|
||||
})
|
||||
expect(session.options.get('serviceTier')).toBe('fast')
|
||||
})
|
||||
|
||||
it('filters restored records to recognized turn options', () => {
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
restoredCodexSessionOptions({
|
||||
model: 'gpt-live',
|
||||
effort: 'high',
|
||||
approvalPolicy: 'never',
|
||||
threadId: 'thread-injected',
|
||||
input: 'input-injected'
|
||||
})
|
||||
)
|
||||
).toEqual({ model: 'gpt-live', effort: 'high' })
|
||||
expect(Object.fromEntries(restoredCodexSessionOptions({ serviceTier: 'priority' }))).toEqual({
|
||||
serviceTier: 'priority'
|
||||
})
|
||||
expect(Object.fromEntries(restoredCodexSessionOptions({ fastMode: 'true' }))).toEqual({})
|
||||
})
|
||||
|
||||
it('hydrates paged provider models and their supported efforts', async () => {
|
||||
const request = vi.fn(async (_method: string, params?: Record<string, unknown>) =>
|
||||
params?.cursor
|
||||
? {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-second',
|
||||
displayName: 'GPT Second',
|
||||
description: 'Fast',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [
|
||||
{ reasoningEffort: 'low', description: 'Quick reasoning' }
|
||||
],
|
||||
defaultReasoningEffort: 'low',
|
||||
isDefault: false
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
: {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
displayName: 'GPT Live',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [
|
||||
{ reasoningEffort: 'medium', description: 'Balanced' },
|
||||
{ reasoningEffort: 'high', description: 'Deep reasoning' }
|
||||
],
|
||||
defaultReasoningEffort: 'medium',
|
||||
isDefault: true
|
||||
}
|
||||
],
|
||||
nextCursor: 'page-2'
|
||||
}
|
||||
)
|
||||
|
||||
await expect(
|
||||
readCodexStructuredSessionOptions({
|
||||
connection: { request } as never,
|
||||
current: { model: 'gpt-live', effort: 'medium' }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-live',
|
||||
label: 'GPT Live',
|
||||
isDefault: true,
|
||||
defaultEffort: 'medium',
|
||||
declaredDefaultEffort: 'medium',
|
||||
efforts: [
|
||||
{ value: 'medium', label: 'Medium', description: 'Balanced' },
|
||||
{ value: 'high', label: 'High', description: 'Deep reasoning' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gpt-second',
|
||||
label: 'GPT Second',
|
||||
description: 'Fast',
|
||||
isDefault: false,
|
||||
defaultEffort: 'low',
|
||||
declaredDefaultEffort: 'low',
|
||||
efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }]
|
||||
}
|
||||
],
|
||||
current: { model: 'gpt-live', effort: 'medium' }
|
||||
})
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'model/list',
|
||||
{ limit: 100, includeHidden: false, cursor: 'page-2' },
|
||||
{ timeoutMs: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('hydrates current values from thread start or resume', () => {
|
||||
expect(
|
||||
reportedCodexThreadOptions({
|
||||
threadId: 'thread-1',
|
||||
model: 'gpt-live',
|
||||
effort: 'high'
|
||||
})
|
||||
).toEqual({ model: 'gpt-live', effort: 'high' })
|
||||
})
|
||||
|
||||
it('does not invent effort when only the model changes', async () => {
|
||||
const session = optionSession(
|
||||
vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
|
||||
defaultReasoningEffort: 'high'
|
||||
},
|
||||
{
|
||||
model: 'gpt-fast',
|
||||
supportedReasoningEfforts: [{ reasoningEffort: 'low' }],
|
||||
defaultReasoningEffort: 'low'
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
)
|
||||
|
||||
await primePicker(session)
|
||||
await expect(applyCodexStructuredSessionOption(session, 'model', 'gpt-fast')).resolves.toEqual({
|
||||
model: 'gpt-fast'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps literal values absent from the provider catalog', async () => {
|
||||
const session = optionSession(
|
||||
vi.fn(async () => ({
|
||||
data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }],
|
||||
nextCursor: null
|
||||
}))
|
||||
)
|
||||
|
||||
await primePicker(session)
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'not-entitled')
|
||||
).resolves.toMatchObject({ model: 'not-entitled' })
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'effort', 'high')
|
||||
).resolves.toMatchObject({ effort: 'high' })
|
||||
})
|
||||
|
||||
it('maps canonical Fast on and off to the exact advertised tier and Standard', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const request = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'model/list'
|
||||
? {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [
|
||||
{ id: 'rush-v7', name: 'Fast', description: 'Provider-routed Fast tier' }
|
||||
]
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
: { turn: { id: `turn-${requests.length}` } }
|
||||
})
|
||||
const session = optionSession(request)
|
||||
await primePicker(session)
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'fastMode', 'true')
|
||||
).resolves.toMatchObject({ serviceTier: 'rush-v7' })
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-on',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'on' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'rush-v7'
|
||||
})
|
||||
|
||||
await applyCodexStructuredSessionOption(session, 'fastMode', 'false')
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-off',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'off' }] }
|
||||
})
|
||||
expect(requests.filter((entry) => entry.method === 'turn/start')[1]?.params).toMatchObject({
|
||||
serviceTier: 'default'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the current Fast value only when the opened thread tier matches the catalog', async () => {
|
||||
const connection = {
|
||||
request: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [{ id: 'priority-current', name: 'Fast' }]
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
}
|
||||
|
||||
await expect(
|
||||
readCodexStructuredSessionOptions({
|
||||
connection,
|
||||
current: { model: 'gpt-live' },
|
||||
reportedServiceTier: 'priority-current',
|
||||
reportedServiceTierKnown: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
current: {
|
||||
serviceTier: 'priority-current',
|
||||
fastMode: true,
|
||||
confirmed: ['serviceTier', 'fastMode']
|
||||
}
|
||||
})
|
||||
const unknown = await readCodexStructuredSessionOptions({
|
||||
connection,
|
||||
current: { model: 'gpt-live' },
|
||||
reportedServiceTier: 'unrecognized-tier',
|
||||
reportedServiceTierKnown: true
|
||||
})
|
||||
expect(unknown.current).toEqual({
|
||||
model: 'gpt-live',
|
||||
serviceTier: 'unrecognized-tier',
|
||||
confirmed: ['serviceTier']
|
||||
})
|
||||
// A menu default cannot fill an unknown applied model.
|
||||
await expect(
|
||||
readCodexStructuredSessionOptions({ connection, current: {} })
|
||||
).resolves.toMatchObject({ current: {} })
|
||||
})
|
||||
|
||||
it('keeps catalog presentation separate from legacy native Fast input', async () => {
|
||||
const session = optionSession(
|
||||
vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: []
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
)
|
||||
|
||||
await primePicker(session)
|
||||
await expect(readLiveCodexSessionOptions(session, undefined)).resolves.toMatchObject({
|
||||
models: [expect.objectContaining({ supportsFastMode: false })],
|
||||
fastModeSupport: { supported: false }
|
||||
})
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'fastMode', 'true')
|
||||
).resolves.toMatchObject({
|
||||
serviceTier: 'fast'
|
||||
})
|
||||
})
|
||||
|
||||
it('runs a restored tier as Standard without erasing the pick when the selected model no longer lists it', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const session = optionSession(
|
||||
vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'model/list'
|
||||
? {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: []
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
: { turn: { id: 'turn-standard' } }
|
||||
})
|
||||
)
|
||||
session.options.set('serviceTier', 'priority')
|
||||
await primePicker(session)
|
||||
|
||||
expect((await readLiveCodexSessionOptions(session, undefined)).current).not.toHaveProperty(
|
||||
'serviceTier'
|
||||
)
|
||||
expect(Object.fromEntries(session.options)).toEqual({ serviceTier: 'priority' })
|
||||
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-standard',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'standard' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'default'
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a restored tier before any listing names it', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const session = optionSession(
|
||||
vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'turn/start'
|
||||
? { turn: { id: 'turn-unlisted' } }
|
||||
: { data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }], nextCursor: null }
|
||||
})
|
||||
)
|
||||
session.options.set('serviceTier', 'priority-saved')
|
||||
|
||||
const options = await readLiveCodexSessionOptions(session, undefined)
|
||||
expect(options.current).not.toHaveProperty('serviceTier')
|
||||
expect(options.current).not.toHaveProperty('fastMode')
|
||||
expect(options.models[0]?.serviceTiers).toBeUndefined()
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-unlisted',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'unlisted' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'priority-saved'
|
||||
})
|
||||
})
|
||||
|
||||
it('allows explicit Fast off without positive model support', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const session = optionSession(
|
||||
vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'model/list'
|
||||
? {
|
||||
data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }],
|
||||
nextCursor: null
|
||||
}
|
||||
: { turn: { id: 'turn-standard' } }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'fastMode', 'false')
|
||||
).resolves.toMatchObject({ serviceTier: 'default' })
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-standard',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'standard' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'default'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the legacy Fast tier beside an empty service tier list', async () => {
|
||||
const result = await readCodexStructuredSessionOptions({
|
||||
connection: {
|
||||
request: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [],
|
||||
additionalSpeedTiers: ['fast']
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
},
|
||||
current: { model: 'gpt-live' }
|
||||
})
|
||||
expect(result.models[0]).toMatchObject({
|
||||
supportsFastMode: true,
|
||||
serviceTiers: [{ value: 'fast', label: 'Fast' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses only the bounded legacy Fast tier value the provider advertised', async () => {
|
||||
const result = await readCodexStructuredSessionOptions({
|
||||
connection: {
|
||||
request: vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
additionalSpeedTiers: ['fast']
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
},
|
||||
current: { model: 'gpt-live' }
|
||||
})
|
||||
expect(result.models[0]).toMatchObject({
|
||||
supportsFastMode: true,
|
||||
serviceTiers: [{ value: 'fast', label: 'Fast' }]
|
||||
})
|
||||
expect(result.fastModeSupport).toEqual({ supported: true })
|
||||
})
|
||||
|
||||
it('retains the tier pick when switching to a model without the picked tier', async () => {
|
||||
const session = optionSession(
|
||||
vi.fn(async () => ({
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-live',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [{ id: 'priority-x', name: 'Fast', description: 'Fast' }]
|
||||
},
|
||||
{ model: 'gpt-standard', supportedReasoningEfforts: [], serviceTiers: [] }
|
||||
],
|
||||
nextCursor: null
|
||||
}))
|
||||
)
|
||||
session.options.set('serviceTier', 'priority-x')
|
||||
await primePicker(session)
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'gpt-standard')
|
||||
).resolves.toMatchObject({ model: 'gpt-standard', serviceTier: 'priority-x' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex service tiers', () => {
|
||||
const tierListing = {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-6.1-sol',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [
|
||||
{ id: 'priority', name: 'Fast', description: '2x speed, increased usage' },
|
||||
{ id: 'ultrafast', name: 'Ultrafast', description: '3x speed' }
|
||||
]
|
||||
},
|
||||
{
|
||||
model: 'gpt-6-luna',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [{ id: 'priority', name: 'Fast' }]
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
|
||||
it('lists every tier a model advertises, valued by its id', async () => {
|
||||
const result = await readCodexStructuredSessionOptions({
|
||||
connection: { request: vi.fn(async () => tierListing) },
|
||||
current: { model: 'gpt-6.1-sol' }
|
||||
})
|
||||
expect(result.models[0]).toMatchObject({
|
||||
supportsFastMode: true,
|
||||
serviceTiers: [
|
||||
{ value: 'priority', label: 'Fast', description: '2x speed, increased usage' },
|
||||
{ value: 'ultrafast', label: 'Ultrafast', description: '3x speed' }
|
||||
]
|
||||
})
|
||||
expect(result.models[1]?.serviceTiers).toEqual([{ value: 'priority', label: 'Fast' }])
|
||||
})
|
||||
|
||||
it('sends Ultrafast as its id and reports it to older clients as neither on nor off', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const session = optionSession(
|
||||
vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'model/list' ? tierListing : { turn: { id: 'turn-ultrafast' } }
|
||||
})
|
||||
)
|
||||
session.reportedOptions = { model: 'gpt-6.1-sol' }
|
||||
await primePicker(session)
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
|
||||
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
|
||||
expect((await readLiveCodexSessionOptions(session, undefined)).current).not.toHaveProperty(
|
||||
'serviceTier'
|
||||
)
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-ultrafast',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'ultrafast'
|
||||
})
|
||||
const options = await readLiveCodexSessionOptions(session, undefined)
|
||||
expect(options.current).toMatchObject({ serviceTier: 'ultrafast' })
|
||||
expect(options.current).not.toHaveProperty('fastMode')
|
||||
})
|
||||
|
||||
it('keeps an unlisted tier pick and preserves it on model changes', async () => {
|
||||
const session = optionSession(vi.fn(async () => tierListing))
|
||||
session.reportedOptions = { model: 'gpt-6-luna' }
|
||||
await primePicker(session)
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
|
||||
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
|
||||
await applyCodexStructuredSessionOption(session, 'model', 'gpt-6.1-sol')
|
||||
await applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna')
|
||||
).resolves.toMatchObject({ model: 'gpt-6-luna', serviceTier: 'ultrafast' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex reported tier', () => {
|
||||
const listing = {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-6.1-sol',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [
|
||||
{ id: 'priority', name: 'Fast' },
|
||||
{ id: 'ultrafast', name: 'Ultrafast' }
|
||||
]
|
||||
},
|
||||
{
|
||||
model: 'gpt-6-luna',
|
||||
supportedReasoningEfforts: [],
|
||||
serviceTiers: [{ id: 'priority', name: 'Fast' }]
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
|
||||
it('never rewrites a tier Codex reports, such as a configured Flex', async () => {
|
||||
const session = optionSession(vi.fn(async () => listing))
|
||||
session.reportedOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex', serviceTierKnown: true }
|
||||
session.appliedTurnOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex' }
|
||||
await primePicker(session)
|
||||
|
||||
await expect(readLiveCodexSessionOptions(session, undefined)).resolves.toMatchObject({
|
||||
current: { serviceTier: 'flex' }
|
||||
})
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna')
|
||||
).resolves.toEqual({ model: 'gpt-6-luna' })
|
||||
})
|
||||
|
||||
it('keeps a re-picked reported Flex through a read, a model switch, and the next message', async () => {
|
||||
const requests: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const session = optionSession(
|
||||
vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
requests.push({ method, params })
|
||||
return method === 'model/list' ? listing : { turn: { id: 'turn-flex' } }
|
||||
})
|
||||
)
|
||||
session.reportedOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex', serviceTierKnown: true }
|
||||
session.appliedTurnOptions = { model: 'gpt-6.1-sol', serviceTier: 'flex' }
|
||||
await primePicker(session)
|
||||
|
||||
await applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'serviceTier', 'flex')
|
||||
).resolves.toEqual({ serviceTier: 'flex' })
|
||||
await expect(readLiveCodexSessionOptions(session, undefined)).resolves.toMatchObject({
|
||||
current: { serviceTier: 'flex' }
|
||||
})
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna')
|
||||
).resolves.toEqual({ model: 'gpt-6-luna', serviceTier: 'flex' })
|
||||
await startCodexTurn(session, {
|
||||
clientMessageId: 'message-flex',
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] }
|
||||
})
|
||||
expect(requests.find((entry) => entry.method === 'turn/start')?.params).toMatchObject({
|
||||
serviceTier: 'flex'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['untouched', []],
|
||||
['re-picked', ['priority', 'ultrafast']]
|
||||
])('preserves a %s tier choice when switching models', async (_case, picks) => {
|
||||
const session = optionSession(vi.fn(async () => listing))
|
||||
session.reportedOptions = {
|
||||
model: 'gpt-6.1-sol',
|
||||
serviceTier: 'ultrafast',
|
||||
serviceTierKnown: true
|
||||
}
|
||||
await primePicker(session)
|
||||
for (const pick of picks) {
|
||||
await applyCodexStructuredSessionOption(session, 'serviceTier', pick)
|
||||
}
|
||||
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'model', 'gpt-6-luna')
|
||||
).resolves.toEqual({
|
||||
model: 'gpt-6-luna',
|
||||
...(picks.length ? { serviceTier: 'ultrafast' } : {})
|
||||
})
|
||||
await expect(
|
||||
applyCodexStructuredSessionOption(session, 'serviceTier', 'ultrafast')
|
||||
).resolves.toMatchObject({ serviceTier: 'ultrafast' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex option picks before the model list arrives', () => {
|
||||
it('accepts launch-held model, effort and tier picks while its own listing is held', async () => {
|
||||
const pending = Promise.withResolvers<unknown>()
|
||||
const codex = fakeCodex({ 'model/list': () => pending.promise })
|
||||
codex.routes['turn/start'] = answerWithOpenedTurn(codex, 'turn-1')
|
||||
const adapter = adapterFor(codex, { codexHome: '/codex/home' }, [], {
|
||||
modelCatalog: new AgentModelCatalogStore()
|
||||
})
|
||||
await acquireReadyCodexForTest(adapter, {
|
||||
identity: identityFor('session-1'),
|
||||
fence: 7,
|
||||
spawnToken: 'spawn-1'
|
||||
})
|
||||
expect(codex.connections[0].calls.some((call) => call.method === 'model/list')).toBe(false)
|
||||
const pick = (key: string, value: string) =>
|
||||
adapter.setOption({ sessionId: 'session-1', key, value, fence: 7 })
|
||||
|
||||
await expect(pick('model', 'gpt-next')).resolves.toMatchObject({ model: 'gpt-next' })
|
||||
await expect(pick('effort', 'low')).resolves.toMatchObject({ model: 'gpt-next', effort: 'low' })
|
||||
await expect(pick('serviceTier', 'priority')).resolves.toEqual({
|
||||
model: 'gpt-next',
|
||||
effort: 'low',
|
||||
serviceTier: 'priority'
|
||||
})
|
||||
await adapter.dispatch({
|
||||
sessionId: 'session-1',
|
||||
clientMessageId: 'first',
|
||||
body: USER_MESSAGE,
|
||||
fence: 7
|
||||
})
|
||||
expect(
|
||||
codex.connections[0].calls.find((call) => call.method === 'turn/start')?.params
|
||||
).toMatchObject({ model: 'gpt-next', effort: 'low', serviceTier: 'priority' })
|
||||
expect(codex.connections[0].calls.filter((call) => call.method === 'model/list')).toHaveLength(
|
||||
0
|
||||
)
|
||||
pending.resolve({ data: [], nextCursor: null })
|
||||
})
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { isAdmissibleAgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-schemas'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
reduceStructuredAgentSession,
|
||||
type StructuredAgentSessionState
|
||||
} from '../../../../shared/agent-session/structured-agent-session-reducer'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import {
|
||||
nativeChatTurnDiffEditFile,
|
||||
nativeChatTurnDiffs
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { agentJournalItemForWire } from '../../../native-chat/agent-session-wire/agent-session-item-wire-form'
|
||||
import {
|
||||
createCodexHistoryTranslator,
|
||||
createCodexJournalTranslator
|
||||
} from '../journal/codex-structured-journal-translation'
|
||||
import type { CodexStructuredSessionEvent } from '../session-lifecycle/codex-structured-session-state'
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
afterEach(() => disposers.splice(0).forEach((dispose) => dispose()))
|
||||
const patch = '@@ -1 +1 @@\n-old\n+new'
|
||||
// Protocol shape also exercised by codex-structured-reasoning-lifecycle.test.ts; no real CLI run is claimed.
|
||||
const change = (status?: string) => ({
|
||||
type: 'fileChange',
|
||||
id: 'patch',
|
||||
...(status ? { status } : {}),
|
||||
changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: patch }]
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
let sequence = 0
|
||||
let state: StructuredAgentSessionState = { ...EMPTY_STRUCTURED_AGENT_SESSION, epoch: 'epoch' }
|
||||
const apply = (items: AgentJournalRenderItem[]) => {
|
||||
state = reduceStructuredAgentSession(state, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'batch',
|
||||
sessionId: 'session',
|
||||
batch: {
|
||||
cursor: { epoch: 'epoch', sequence: ++sequence },
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body, options) => {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
const previous = state.items.find((item) => item.itemId === itemId)
|
||||
apply([
|
||||
agentJournalItemForWire({
|
||||
itemId,
|
||||
body,
|
||||
revision: (previous?.revision ?? 0) + 1,
|
||||
sequence: previous?.sequence ?? sequence + 1,
|
||||
observedAt: options.observedAt ?? 1
|
||||
}).item
|
||||
])
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
const live = createCodexJournalTranslator({ sink, schedule: () => () => {} })
|
||||
const history = createCodexHistoryTranslator({ sink })
|
||||
disposers.push(live.dispose, history.dispose)
|
||||
const send = (method: string, params: unknown) =>
|
||||
live.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session',
|
||||
threadId: 'thread',
|
||||
method,
|
||||
params
|
||||
} satisfies CodexStructuredSessionEvent)
|
||||
const outcome = () => {
|
||||
const messages = projectStructuredItemsToNativeChat(state.items)
|
||||
return nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).get('turn')
|
||||
}
|
||||
return {
|
||||
live,
|
||||
history,
|
||||
send,
|
||||
outcome,
|
||||
replay: () => {
|
||||
const items = state.items.map((item) => {
|
||||
const restored: unknown = JSON.parse(JSON.stringify(item))
|
||||
if (!isAdmissibleAgentJournalRenderItem(restored)) {
|
||||
throw new Error('Journal item failed replay validation')
|
||||
}
|
||||
return restored
|
||||
})
|
||||
state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'history-page',
|
||||
page: {
|
||||
sessionId: 'session',
|
||||
epoch: 'epoch',
|
||||
direction: 'tail',
|
||||
items,
|
||||
submissions: [],
|
||||
removedItemIds: [],
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
liveCursor: { epoch: 'epoch', sequence },
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch', sequence: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('shows one landed patch only after completion, including stream updates and replay revisions', () => {
|
||||
const f = fixture()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/fileChange/outputDelta', {
|
||||
itemId: 'patch',
|
||||
delta: '@@ -1 +1 @@\n-stdout\n+not-a-patch'
|
||||
})
|
||||
f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes })
|
||||
f.live.flush()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/completed', { item: change('completed') })
|
||||
f.live.flush()
|
||||
expect(f.outcome()?.files.map((file) => file.path)).toEqual(['src/app.ts'])
|
||||
expect(f.outcome()).toMatchObject({ added: 1, removed: 1 })
|
||||
expect(f.outcome()?.files[0]?.edits).toHaveLength(1)
|
||||
expect(nativeChatTurnDiffEditFile(f.outcome()!.files[0]!.edits[0]!)).toMatchObject({
|
||||
path: 'src/app.ts',
|
||||
added: 1,
|
||||
removed: 1
|
||||
})
|
||||
f.replay()
|
||||
f.send('item/completed', { item: change('completed') })
|
||||
expect(f.outcome()?.files[0]?.edits).toHaveLength(1)
|
||||
expect(f.outcome()).toMatchObject({ added: 1, removed: 1 })
|
||||
})
|
||||
|
||||
it.each(['failed', 'declined', 'inProgress', undefined])(
|
||||
'never promotes %s changes via a completion wrapper or history import',
|
||||
(status) => {
|
||||
const f = fixture()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
f.send('item/completed', { item: change(status) })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.history.restoreThread('thread', {
|
||||
turns: [{ id: 'turn', status: 'completed', items: [change(status)] }]
|
||||
})
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.replay()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an orphan patch update unconfirmed and settles buffered stdout without a landed file', () => {
|
||||
const f = fixture()
|
||||
f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes })
|
||||
f.live.flush()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
f.send('item/fileChange/outputDelta', { itemId: 'patch', delta: patch })
|
||||
f.live.handle({ type: 'ended', sessionId: 'session', reason: 'app-server exited' })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.replay()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
})
|
||||
@@ -1,875 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import {
|
||||
briefToolArg,
|
||||
createToolInputDisplay,
|
||||
describeToolInput
|
||||
} from '../../../../shared/native-chat/tools/native-chat-tool-summary'
|
||||
import {
|
||||
codexItemBody,
|
||||
codexJournalItem,
|
||||
codexMessageBlocks,
|
||||
codexStreamingJournalItem
|
||||
} from './codex-structured-item-translation'
|
||||
import {
|
||||
codexItemIdentity,
|
||||
isCodexMessageItemType,
|
||||
readCodexThreadItem,
|
||||
type CodexThreadItem
|
||||
} from './codex-thread-item-identity'
|
||||
import {
|
||||
CodexTurnOrdinals,
|
||||
MAX_CODEX_TURN_ORDINAL_BYTES,
|
||||
MAX_CODEX_TURN_ORDINAL_ENTRIES
|
||||
} from '../journal/codex-turn-ordinals'
|
||||
|
||||
function toolCallInput(item: CodexThreadItem): unknown {
|
||||
const body = codexItemBody(item)
|
||||
return body !== null && body.kind === 'tool-call' ? body.input : null
|
||||
}
|
||||
|
||||
const THREAD_ID = 'thread-abc'
|
||||
const TURN_ID = 'turn-1'
|
||||
|
||||
// Captured from a live app-server turn.
|
||||
const LIVE_TURN: CodexThreadItem[] = [
|
||||
{ type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list the files' }] },
|
||||
{ type: 'agentMessage', id: 'item-1', text: 'Let me look.' },
|
||||
{
|
||||
type: 'commandExecution',
|
||||
id: 'item-2',
|
||||
command: 'ls',
|
||||
cwd: '/tmp',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
aggregatedOutput: 'a\nb\n'
|
||||
},
|
||||
{ type: 'agentMessage', id: 'item-3', text: 'Two files.' }
|
||||
]
|
||||
|
||||
// The same turn after resume: IDs were renumbered and command execution omitted.
|
||||
const RESUMED_TURN: CodexThreadItem[] = [
|
||||
{ type: 'userMessage', id: 'item-1', content: [{ type: 'text', text: 'list the files' }] },
|
||||
{ type: 'agentMessage', id: 'item-2', text: 'Let me look.' },
|
||||
{ type: 'agentMessage', id: 'item-3', text: 'Two files.' }
|
||||
]
|
||||
|
||||
function keysFor(items: CodexThreadItem[]): string[] {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
return items
|
||||
.filter((item) => isCodexMessageItemType(item.type))
|
||||
.map((item) =>
|
||||
agentJournalItemKey(
|
||||
codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
describe('codex turn ordinals', () => {
|
||||
it('bounds forgotten turn tombstones while retaining the recent window', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
const total = MAX_CODEX_TURN_ORDINAL_ENTRIES + 12
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const turnId = `turn-${index}`
|
||||
expect(ordinals.ordinalFor('thread-many', turnId, 'item-0')).toBe(0)
|
||||
ordinals.forgetTurn('thread-many', turnId)
|
||||
}
|
||||
|
||||
expect(ordinals.forgottenTurnCount).toBe(MAX_CODEX_TURN_ORDINAL_ENTRIES)
|
||||
expect(ordinals.ordinalFor('thread-many', `turn-${total - 1}`, 'item-late')).toBe(1)
|
||||
expect(ordinals.ordinalFor('thread-many', 'turn-0', 'item-late')).toBe(0)
|
||||
})
|
||||
|
||||
it('releases a forgotten turn without ever reusing an ordinal it assigned', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0)
|
||||
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1)
|
||||
|
||||
ordinals.forgetTurn('thread-1', 'turn-1')
|
||||
|
||||
// Reusing a released ordinal would overwrite another item row.
|
||||
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(2)
|
||||
expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-3')).toBe(3)
|
||||
expect(ordinals.ordinalFor('thread-1', 'turn-2', 'item-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds aggregate provider identifier bytes retained by one active turn', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
for (let index = 0; index < 3_000; index += 1) {
|
||||
ordinals.ordinalFor('thread', 'turn', `${index}:${'x'.repeat(512)}`)
|
||||
}
|
||||
|
||||
expect(ordinals.bytes).toBeLessThanOrEqual(MAX_CODEX_TURN_ORDINAL_BYTES)
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex item identity', () => {
|
||||
it('gives a resumed turn the same message keys as the live turn it renumbered', () => {
|
||||
expect(keysFor(LIVE_TURN)).toEqual(keysFor(RESUMED_TURN))
|
||||
})
|
||||
|
||||
it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
const live = LIVE_TURN.map((item) =>
|
||||
codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals })
|
||||
)
|
||||
|
||||
expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2])
|
||||
})
|
||||
|
||||
it('survives an item type this build does not model without consuming a message ordinal', () => {
|
||||
const withUnknown = [
|
||||
LIVE_TURN[0] as CodexThreadItem,
|
||||
{ type: 'somethingCodexAddedLater', id: 'item-9' },
|
||||
LIVE_TURN[1] as CodexThreadItem
|
||||
]
|
||||
|
||||
expect(keysFor(withUnknown)).toEqual(keysFor([LIVE_TURN[0], LIVE_TURN[1]] as CodexThreadItem[]))
|
||||
})
|
||||
|
||||
it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
|
||||
|
||||
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1)
|
||||
expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0)
|
||||
})
|
||||
|
||||
it('restarts numbering per turn', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')
|
||||
|
||||
expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('keys a non-message item and a turnless message in the orca namespace', () => {
|
||||
const ordinals = new CodexTurnOrdinals()
|
||||
const command = codexItemIdentity({
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
item: LIVE_TURN[2] as CodexThreadItem,
|
||||
ordinals
|
||||
})
|
||||
const orphan = codexItemIdentity({
|
||||
threadId: THREAD_ID,
|
||||
turnId: null,
|
||||
item: LIVE_TURN[1] as CodexThreadItem,
|
||||
ordinals
|
||||
})
|
||||
|
||||
expect(command).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-2' })
|
||||
expect(orphan).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex item bodies', () => {
|
||||
it('reads structured user content and flat agent text alike', () => {
|
||||
expect(codexMessageBlocks(LIVE_TURN[0] as CodexThreadItem)).toEqual([
|
||||
{ type: 'text', text: 'list the files' }
|
||||
])
|
||||
expect(codexMessageBlocks(LIVE_TURN[1] as CodexThreadItem)).toEqual([
|
||||
{ type: 'text', text: 'Let me look.' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps provider image echoes in mixed user content', () => {
|
||||
expect(
|
||||
codexMessageBlocks({
|
||||
type: 'userMessage',
|
||||
id: 'm',
|
||||
content: [
|
||||
{ type: 'text', text: 'look' },
|
||||
{ type: 'image', url: 'https://example.test/a.png' },
|
||||
{ type: 'localImage', path: '/tmp/a.png' }
|
||||
]
|
||||
})
|
||||
).toEqual([
|
||||
{ type: 'text', text: 'look' },
|
||||
{ type: 'image-ref', url: 'https://example.test/a.png' },
|
||||
{ type: 'image-ref', path: '/tmp/a.png' }
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a finished zero-exit command to a completed shell tool call', () => {
|
||||
expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-2',
|
||||
input: { command: 'ls', cwd: '/tmp' },
|
||||
exitCode: 0,
|
||||
state: 'completed',
|
||||
output: { head: 'a\nb\n', byteLength: 4, truncated: false, digest: expect.any(String) }
|
||||
})
|
||||
})
|
||||
|
||||
it('names a classified read command by its class and keeps the raw command', () => {
|
||||
const body = codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-read',
|
||||
command: "sed -n '1,200p' notes.txt",
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [
|
||||
{
|
||||
type: 'read',
|
||||
command: "sed -n '1,200p' notes.txt",
|
||||
name: 'notes.txt',
|
||||
path: '/repo/notes.txt'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(body).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-read',
|
||||
input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
|
||||
expect(display.filePath).toBe('/repo/notes.txt')
|
||||
expect(display.label).toBe('/repo/notes.txt')
|
||||
})
|
||||
|
||||
it('carries a classified search query so the row labels by term, not scan root', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-search',
|
||||
command: 'rg -n --no-heading beta .',
|
||||
cwd: '/repo',
|
||||
status: 'inProgress',
|
||||
commandActions: [
|
||||
{ type: 'search', command: 'rg -n --no-heading beta .', query: 'beta', path: '.' }
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'search',
|
||||
callId: 'item-search',
|
||||
input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' },
|
||||
state: 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('omits a null classified field rather than standing it in as a target', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-search-bare',
|
||||
command: 'rg beta',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [{ type: 'search', command: 'rg beta', query: null, path: null }]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'search',
|
||||
callId: 'item-search-bare',
|
||||
input: { command: 'rg beta', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('names a classified listFiles command `list` and invents no target for a null path', () => {
|
||||
const body = codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-list',
|
||||
command: 'ls',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [{ type: 'listFiles', command: 'ls', path: null }]
|
||||
})
|
||||
|
||||
expect(body).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'list',
|
||||
callId: 'item-list',
|
||||
input: { command: 'ls', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
// A directory in path would become a broken open-file link on mobile.
|
||||
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
|
||||
expect(display.filePath).toBeNull()
|
||||
expect(display.label).toBe('ls')
|
||||
})
|
||||
|
||||
it('keeps the shell row when one command did two different classified things', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-mixed',
|
||||
command: 'cat a.txt && ls src',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [
|
||||
{ type: 'read', command: 'cat a.txt', name: 'a.txt', path: 'a.txt' },
|
||||
{ type: 'listFiles', command: 'ls src', path: 'src' }
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-mixed',
|
||||
input: { command: 'cat a.txt && ls src', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one class run twice, naming no target when the two disagree', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-two-reads',
|
||||
command: 'cat a.ts && cat b.ts',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [
|
||||
{ type: 'read', command: 'cat a.ts', path: 'a.ts' },
|
||||
{ type: 'read', command: 'cat b.ts', path: 'b.ts' }
|
||||
]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-two-reads',
|
||||
input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a target both entries of one class name', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-same-read',
|
||||
command: 'head a.ts && tail a.ts',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [
|
||||
{ type: 'read', command: 'head a.ts', path: 'a.ts' },
|
||||
{ type: 'read', command: 'tail a.ts', path: 'a.ts' }
|
||||
]
|
||||
})
|
||||
).toMatchObject({ name: 'read', input: { path: 'a.ts' } })
|
||||
})
|
||||
|
||||
it('keeps the listed directory as a label, never as a file target', () => {
|
||||
const body = codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-list-path',
|
||||
command: 'ls src',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [{ type: 'listFiles', command: 'ls src', path: 'src' }]
|
||||
})
|
||||
|
||||
expect(body).toMatchObject({ name: 'list', input: { directory: 'src' } })
|
||||
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
|
||||
expect(display.filePath).toBeNull()
|
||||
expect(display.label).toBe('src')
|
||||
})
|
||||
|
||||
it('keeps a scan root off the file-target key even when the search has no term', () => {
|
||||
const body = codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-search-root',
|
||||
command: 'rg --files src',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [{ type: 'search', command: 'rg --files src', query: null, path: 'src' }]
|
||||
})
|
||||
|
||||
expect(body).toMatchObject({ name: 'search', input: { directory: 'src' } })
|
||||
expect(
|
||||
createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null).filePath
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the other classes without a stand-in target', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-read-null',
|
||||
command: 'cat',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [{ type: 'read', command: 'cat', path: null, name: null }]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'read',
|
||||
callId: 'item-read-null',
|
||||
input: { command: 'cat', cwd: '/repo' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips unclassified actions to reach the first classified one', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-piped',
|
||||
command: 'true && cat a.ts',
|
||||
cwd: '/repo',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
commandActions: [
|
||||
{ type: 'unknown', command: 'true' },
|
||||
{ type: 'read', command: 'cat a.ts', name: 'a.ts', path: 'a.ts' }
|
||||
]
|
||||
})
|
||||
).toMatchObject({ name: 'read', input: { path: 'a.ts' } })
|
||||
})
|
||||
|
||||
it('falls back to the unclassified shell row for absent or malformed commandActions', () => {
|
||||
const shellRow = {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
callId: 'item-fallback',
|
||||
input: { command: 'ls', cwd: '/tmp' },
|
||||
exitCode: 0,
|
||||
state: 'completed'
|
||||
}
|
||||
const base = {
|
||||
type: 'commandExecution',
|
||||
id: 'item-fallback',
|
||||
command: 'ls',
|
||||
cwd: '/tmp',
|
||||
status: 'completed',
|
||||
exitCode: 0
|
||||
}
|
||||
|
||||
expect(codexItemBody(base)).toEqual(shellRow)
|
||||
expect(codexItemBody({ ...base, commandActions: null })).toEqual(shellRow)
|
||||
expect(codexItemBody({ ...base, commandActions: [] })).toEqual(shellRow)
|
||||
expect(
|
||||
codexItemBody({ ...base, commandActions: [{ type: 'unknown', command: 'ls' }] })
|
||||
).toEqual(shellRow)
|
||||
expect(codexItemBody({ ...base, commandActions: 'read' })).toEqual(shellRow)
|
||||
expect(codexItemBody({ ...base, commandActions: [null, 7, 'read', {}, { type: 5 }] })).toEqual(
|
||||
shellRow
|
||||
)
|
||||
expect(
|
||||
codexItemBody({ ...base, commandActions: [{ type: '__proto__', command: 'ls' }] })
|
||||
).toEqual(shellRow)
|
||||
expect(
|
||||
codexItemBody({ ...base, commandActions: [{ type: 'constructor', command: 'ls' }] })
|
||||
).toEqual(shellRow)
|
||||
expect(
|
||||
codexItemBody({ ...base, parsedCmd: [{ type: 'read', cmd: 'ls', path: 'a.ts' }] })
|
||||
).toEqual(shellRow)
|
||||
})
|
||||
|
||||
it('accepts snake-case command completion output and preserves blob evidence', () => {
|
||||
const output = 'x'.repeat(1_100_000)
|
||||
const translated = codexJournalItem({
|
||||
type: 'commandExecution',
|
||||
id: 'item-large',
|
||||
command: 'python big.py',
|
||||
status: 'completed',
|
||||
exitCode: 0,
|
||||
aggregated_output: output
|
||||
})
|
||||
const body = translated.body
|
||||
|
||||
expect(body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
state: 'completed',
|
||||
output: {
|
||||
byteLength: 1_100_000,
|
||||
truncated: false,
|
||||
digest: expect.any(String)
|
||||
}
|
||||
})
|
||||
if (body?.kind !== 'tool-call' || !body.output) {
|
||||
throw new Error('expected command output')
|
||||
}
|
||||
expect(body.output.head).toBe(output)
|
||||
})
|
||||
|
||||
it('continues to accept camel-case command completion output', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-camel',
|
||||
command: 'printf ok',
|
||||
status: 'completed',
|
||||
aggregatedOutput: 'ok'
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
output: { head: 'ok', byteLength: 2, truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a long assistant reply whole, its content parts joined', () => {
|
||||
const parts = Array.from({ length: 200 }, () => 'a'.repeat(10_000))
|
||||
const body = codexItemBody({
|
||||
type: 'agentMessage',
|
||||
id: 'assistant-parts',
|
||||
content: parts.map((text) => ({ type: 'text', text }))
|
||||
})
|
||||
const text =
|
||||
body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : ''
|
||||
|
||||
expect(body).toMatchObject({ kind: 'message', role: 'assistant' })
|
||||
expect(body?.kind === 'message' ? body.blocks : []).toHaveLength(1)
|
||||
expect(text).toBe(parts.join('\n'))
|
||||
})
|
||||
|
||||
it('calls a nonzero exit a failure even though codex calls the status completed', () => {
|
||||
const body = codexItemBody({
|
||||
type: 'commandExecution',
|
||||
id: 'item-2',
|
||||
command: 'false',
|
||||
status: 'completed',
|
||||
exitCode: 1
|
||||
})
|
||||
|
||||
expect(body).toMatchObject({ state: 'failed' })
|
||||
})
|
||||
|
||||
it('treats an unfinished command as running and an aborted one as failed', () => {
|
||||
expect(
|
||||
codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'inProgress' })
|
||||
).toMatchObject({ state: 'running' })
|
||||
expect(
|
||||
codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'aborted' })
|
||||
).toMatchObject({ state: 'failed' })
|
||||
})
|
||||
|
||||
it('maps file changes to one bounded diff item', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'fileChange',
|
||||
id: 'patch-1',
|
||||
status: 'completed',
|
||||
changes: [
|
||||
{ path: 'src/a.ts', diff: '@@ a @@' },
|
||||
{ path: 'src/b.ts', diff: '@@ b @@' }
|
||||
]
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'diff',
|
||||
path: '2 files',
|
||||
patch: { head: '@@ a @@\n@@ b @@', truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves plan prose documents byte-for-byte as status text', () => {
|
||||
const text =
|
||||
' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n '
|
||||
|
||||
expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({
|
||||
body: { kind: 'status', text, presentation: 'plan-document' },
|
||||
handled: true
|
||||
})
|
||||
expect(codexItemBody({ type: 'plan', id: 'plan-document', text })).not.toMatchObject({
|
||||
kind: 'message',
|
||||
role: 'reasoning'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders reasoning as a typed message and exposes an unknown item as a provider frame', () => {
|
||||
expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text: 'thinking' }]
|
||||
})
|
||||
expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'somethingCodexAddedLater', id: 'x' })).toMatchObject({
|
||||
kind: 'status',
|
||||
text: 'codex · item:somethingCodexAddedLater',
|
||||
providerFrame: { provider: 'codex', kind: 'item:somethingCodexAddedLater' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps non-reasoning item streams as status activity', () => {
|
||||
expect(
|
||||
codexStreamingJournalItem({ type: 'somethingCodexAddedLater', id: 'x' }, 'still working')
|
||||
).toEqual({
|
||||
body: { kind: 'status', text: 'still working' },
|
||||
handled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('gives an mcp tool call a typed body with its own arguments as input', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'mcpToolCall',
|
||||
id: 'mcp-1',
|
||||
server: 'weather',
|
||||
tool: 'get_forecast',
|
||||
status: 'completed',
|
||||
arguments: { city: 'Oslo' },
|
||||
result: { content: [{ type: 'text', text: '12C' }] }
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'weather/get_forecast',
|
||||
callId: 'mcp-1',
|
||||
mcpIdentity: { server: 'weather', tool: 'get_forecast' },
|
||||
input: { city: 'Oslo' },
|
||||
state: 'completed',
|
||||
output: { head: '12C', byteLength: 3, truncated: false, digest: expect.any(String) }
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an mcp tool name through with no casing transform', () => {
|
||||
for (const tool of ['get_forecast', 'mcp__server__tool', 'ns.tool', 'urn:tool', 'listTools']) {
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', tool, status: 'inProgress' }),
|
||||
tool
|
||||
).toMatchObject({ kind: 'tool-call', name: tool, state: 'running' })
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'srv', tool, status: 'inProgress' }),
|
||||
tool
|
||||
).toMatchObject({ kind: 'tool-call', name: `srv/${tool}`, state: 'running' })
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the bare tool, then to `mcp`, when the item is under-specified', () => {
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 'get_forecast', status: 'inProgress' })
|
||||
).toMatchObject({ name: 'get_forecast' })
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', server: '', tool: 'ping', status: 'completed' })
|
||||
).toMatchObject({ name: 'ping' })
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'weather', status: 'completed' })
|
||||
).toMatchObject({ name: 'mcp' })
|
||||
})
|
||||
|
||||
it('keeps non-object mcp arguments addressable and empty ones off the label', () => {
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: 'raw text' })
|
||||
).toMatchObject({ input: { arguments: 'raw text' } })
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: [1, 2] })
|
||||
).toMatchObject({ input: { arguments: [1, 2] } })
|
||||
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 't',
|
||||
callId: 'm',
|
||||
input: null,
|
||||
state: 'running'
|
||||
})
|
||||
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't' })).toMatchObject({
|
||||
input: null
|
||||
})
|
||||
expect(
|
||||
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: null })
|
||||
).toMatchObject({ input: null })
|
||||
})
|
||||
|
||||
it('renders an argument-less mcp call as a bare server/tool row', () => {
|
||||
const input = toolCallInput({
|
||||
type: 'mcpToolCall',
|
||||
id: 'm',
|
||||
server: 'srv',
|
||||
tool: 'list_tools',
|
||||
arguments: {}
|
||||
})
|
||||
expect(describeToolInput(input)).toBe('')
|
||||
expect(briefToolArg(input)).toBe('')
|
||||
})
|
||||
|
||||
it('reports an mcp error as a failed call carrying the server message', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'mcpToolCall',
|
||||
id: 'mcp-2',
|
||||
server: 's',
|
||||
tool: 'ping',
|
||||
status: 'completed',
|
||||
error: { message: 'server unreachable' }
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 's/ping',
|
||||
state: 'failed',
|
||||
output: { head: 'server unreachable', truncated: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('models a web search as a tool call that runs until codex sends the action', () => {
|
||||
expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
callId: 'w',
|
||||
input: null,
|
||||
state: 'running'
|
||||
})
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'webSearch',
|
||||
id: 'w',
|
||||
query: 'orca release notes',
|
||||
action: { type: 'search', query: 'orca release notes', queries: null },
|
||||
results: null
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
callId: 'w',
|
||||
input: {
|
||||
query: 'orca release notes',
|
||||
description: 'search',
|
||||
action: { type: 'search', query: 'orca release notes', queries: null }
|
||||
},
|
||||
state: 'completed'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an image generation as running while codex sends a Responses API status', () => {
|
||||
for (const status of ['in_progress', 'generating']) {
|
||||
expect(codexItemBody({ type: 'imageGeneration', id: 'i', status })).toEqual({
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'Generating image…' }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the web search hits as the call output', () => {
|
||||
const results = [{ title: 'Orca 1.0', url: 'https://example.com/notes' }]
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'webSearch',
|
||||
id: 'w',
|
||||
query: 'orca release notes',
|
||||
action: { type: 'search', query: 'orca release notes', queries: null },
|
||||
results
|
||||
})
|
||||
).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'web_search',
|
||||
state: 'completed',
|
||||
output: { head: JSON.stringify(results), truncated: false }
|
||||
})
|
||||
for (const empty of [undefined, null, []]) {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'webSearch',
|
||||
id: 'w',
|
||||
query: 'q',
|
||||
action: { type: 'search' },
|
||||
results: empty
|
||||
}),
|
||||
String(empty)
|
||||
).not.toHaveProperty('output')
|
||||
}
|
||||
})
|
||||
|
||||
it('labels every web search shape without falling back to raw JSON', () => {
|
||||
const url = 'https://example.com/docs/page'
|
||||
const cases: [string, unknown, string, string][] = [
|
||||
['started', null, '', ''],
|
||||
[
|
||||
'search',
|
||||
{ type: 'search', query: 'a sample query', queries: null },
|
||||
'a sample query',
|
||||
'a sample query'
|
||||
],
|
||||
['openPage', { type: 'openPage', url }, url, ''],
|
||||
[
|
||||
'findInPage',
|
||||
{ type: 'findInPage', url, pattern: 'a needle' },
|
||||
'a sample query',
|
||||
'a sample query'
|
||||
],
|
||||
['other', { type: 'other' }, 'other', '']
|
||||
]
|
||||
for (const [name, action, label, brief] of cases) {
|
||||
const query = name === 'search' || name === 'findInPage' ? 'a sample query' : ''
|
||||
const input = toolCallInput({ type: 'webSearch', id: 'w', query, action })
|
||||
expect(describeToolInput(input), name).toBe(label)
|
||||
expect(briefToolArg(input), name).toBe(brief)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops the raw subagent item now the roster row renders it', () => {
|
||||
expect(
|
||||
codexJournalItem({
|
||||
type: 'subAgentActivity',
|
||||
id: 'a-1',
|
||||
kind: 'started',
|
||||
agentThreadId: 'thread-child',
|
||||
agentPath: '/root/list_directory'
|
||||
})
|
||||
).toMatchObject({ handled: true, body: null })
|
||||
})
|
||||
|
||||
it('drops the sleep item, which codex itself renders as nothing', () => {
|
||||
expect(codexJournalItem({ type: 'sleep', id: 's-1', durationMs: 20_000 })).toEqual({
|
||||
body: null,
|
||||
handled: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps streamed reasoning as a message and leaves streamed plans as status', () => {
|
||||
expect(codexStreamingJournalItem({ type: 'reasoning', id: 'r' }, 'thinking')).toEqual({
|
||||
handled: true,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text: 'thinking' }]
|
||||
}
|
||||
})
|
||||
expect(codexStreamingJournalItem({ type: 'reasoning', id: 'r' }, ' \n ')).toEqual({
|
||||
handled: true,
|
||||
body: null
|
||||
})
|
||||
expect(codexStreamingJournalItem({ type: 'plan', id: 'p' }, 'First\nSecond')).toEqual({
|
||||
handled: true,
|
||||
body: { kind: 'status', text: 'First\nSecond', presentation: 'plan-document' }
|
||||
})
|
||||
})
|
||||
|
||||
it('omits blank reasoning and preserves the plan document body', () => {
|
||||
expect(codexItemBody({ type: 'reasoning', id: 'r', text: ' \n ' })).toBeNull()
|
||||
expect(codexItemBody({ type: 'plan', id: 'p', text: 'First\nSecond' })).toEqual({
|
||||
kind: 'status',
|
||||
text: 'First\nSecond',
|
||||
presentation: 'plan-document'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders array-shaped reasoning content', () => {
|
||||
expect(
|
||||
codexItemBody({
|
||||
type: 'reasoning',
|
||||
id: 'r',
|
||||
summary: ['first', 'second'],
|
||||
content: [{ text: 'fallback' }]
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text: 'first\nsecond' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a value that is not a thread item at all', () => {
|
||||
expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull()
|
||||
expect(readCodexThreadItem(null)).toBeNull()
|
||||
expect(readCodexThreadItem({ type: 'agentMessage', id: 'm' })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -127,28 +127,6 @@ async function session() {
|
||||
}
|
||||
|
||||
describe('Codex context usage in the journal', () => {
|
||||
it('follows each response of a turn and holds once the turn ends', async () => {
|
||||
const { on, tokenUsage, contextUsage } = await session()
|
||||
on('turn/started', { turn: { id: 'turn-1' } })
|
||||
|
||||
tokenUsage('turn-1', FIRST_RESPONSE)
|
||||
expect(await contextUsage()).toEqual({
|
||||
usedTokens: 17611,
|
||||
windowTokens: 258400,
|
||||
percentage: 7,
|
||||
estimated: false,
|
||||
categories: []
|
||||
})
|
||||
|
||||
// Context usage measures the response, not cumulative thread spend.
|
||||
tokenUsage('turn-1', AFTER_TOOL_CALL)
|
||||
expect(await contextUsage()).toMatchObject({ usedTokens: 17684, windowTokens: 475000 })
|
||||
|
||||
tokenUsage('turn-1', FINAL_RESPONSE)
|
||||
on('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })
|
||||
expect(await contextUsage()).toMatchObject({ usedTokens: 17714, windowTokens: 475000 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['while the turn runs', false],
|
||||
['after the turn ended', true]
|
||||
@@ -221,18 +199,6 @@ describe('Codex context usage in the journal', () => {
|
||||
expect(await contextUsage()).toBeNull()
|
||||
})
|
||||
|
||||
it('holds a reading that reached the open turn row under another turn name', async () => {
|
||||
const { on, tokenUsage, contextUsage } = await session()
|
||||
on('turn/started', { turn: { id: 'turn-1' } })
|
||||
tokenUsage('turn-1', FINAL_RESPONSE)
|
||||
// An unknown turn falls back to the newest row.
|
||||
tokenUsage('turn-nobody-opened', AFTER_COMPACTION)
|
||||
|
||||
on('turn/completed', { turn: { id: 'turn-1', status: 'completed' } })
|
||||
|
||||
expect(await contextUsage()).toMatchObject({ usedTokens: 6033, windowTokens: 475000 })
|
||||
})
|
||||
|
||||
it('keeps the saved reading through a resume that restores history, then takes the replayed frame', async () => {
|
||||
const { on, tokenUsage, connect } = await session()
|
||||
on('turn/started', { turn: { id: 'turn-1' } })
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import type { AgentJournalItemBody } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { AgentJournalItemIdentity } from '../../../../shared/agent-session/journal/agent-session-journal-identity'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { CodexJournalGoals } from './codex-structured-journal-goals'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import {
|
||||
MAX_CODEX_GENERIC_ROWS_PER_TURN,
|
||||
MAX_CODEX_GOAL_THREADS
|
||||
} from './codex-structured-journal-limits'
|
||||
|
||||
const THREAD = '01a08cc2-f96e-76d0-bb74-88b9bc0b03fc'
|
||||
|
||||
@@ -31,10 +24,6 @@ function goalFrame(goal: Record<string, unknown> = {}): Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function texts(rows: readonly AgentJournalItemBody[]): string[] {
|
||||
return rows.map((row) => (row.kind === 'status' ? row.text : ''))
|
||||
}
|
||||
|
||||
describe('codex goal lifecycle admission', () => {
|
||||
it.each(['append', 'publish'] as const)(
|
||||
'retries the same goal after rejected %s without losing or duplicating its row',
|
||||
@@ -87,144 +76,4 @@ describe('codex goal lifecycle admission', () => {
|
||||
translator.dispose()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let the generic-row cap permanently hide the first goal evidence', () => {
|
||||
const rows: AgentJournalItemBody[] = []
|
||||
const sink = {
|
||||
appendItem: (_identity: AgentJournalItemIdentity, body: AgentJournalItemBody) =>
|
||||
rows.push(body),
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
} satisfies StructuredAgentSessionEventSink
|
||||
const translator = createCodexJournalTranslator({ sink })
|
||||
for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN; index += 1) {
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session',
|
||||
threadId: THREAD,
|
||||
method: 'process/exited',
|
||||
params: { threadId: THREAD, turnId: 'turn-1', processId: `process-${index}` }
|
||||
})
|
||||
}
|
||||
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session',
|
||||
threadId: THREAD,
|
||||
method: 'thread/goal/updated',
|
||||
params: goalFrame()
|
||||
})
|
||||
translator.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session',
|
||||
threadId: THREAD,
|
||||
method: 'thread/goal/updated',
|
||||
params: { ...goalFrame({ tokensUsed: 1 }), turnId: 'turn-2' }
|
||||
})
|
||||
|
||||
expect(texts(rows).filter((text) => text.startsWith('Goal '))).toEqual([
|
||||
'Goal set: Keep the current scratch directory tidy.'
|
||||
])
|
||||
translator.dispose()
|
||||
})
|
||||
|
||||
it('keeps repeated lifecycle states distinct across status cycles and goal recreation', () => {
|
||||
const rows = new Map<string, AgentJournalItemBody>()
|
||||
const sink = {
|
||||
appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) =>
|
||||
rows.set(agentJournalItemKey(identity), body),
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
} satisfies StructuredAgentSessionEventSink
|
||||
const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }))
|
||||
const update = (goal: Record<string, unknown> = {}) =>
|
||||
goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame(goal) })
|
||||
const clear = () =>
|
||||
goals.handle({
|
||||
threadId: THREAD,
|
||||
method: 'thread/goal/cleared',
|
||||
params: { threadId: THREAD }
|
||||
})
|
||||
|
||||
update()
|
||||
update({ status: 'paused' })
|
||||
update()
|
||||
clear()
|
||||
update()
|
||||
clear()
|
||||
clear()
|
||||
|
||||
expect(texts([...rows.values()])).toEqual([
|
||||
'Goal set: Keep the current scratch directory tidy.',
|
||||
'Goal paused: Keep the current scratch directory tidy.',
|
||||
'Goal set: Keep the current scratch directory tidy.',
|
||||
'Goal cleared',
|
||||
'Goal set: Keep the current scratch directory tidy.',
|
||||
'Goal cleared'
|
||||
])
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it('bounds thread state with LRU eviction while stable identities keep one history row', () => {
|
||||
const writes: string[] = []
|
||||
const rows = new Map<string, AgentJournalItemBody>()
|
||||
const sink = {
|
||||
appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => {
|
||||
const key = agentJournalItemKey(identity)
|
||||
writes.push(key)
|
||||
rows.set(key, body)
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
} satisfies StructuredAgentSessionEventSink
|
||||
const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }))
|
||||
const send = (threadId: string) =>
|
||||
goals.handle({ threadId, method: 'thread/goal/updated', params: goalFrame() })
|
||||
|
||||
for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) {
|
||||
send(`thread-${index}`)
|
||||
}
|
||||
const threadZeroIdentity = writes[0]
|
||||
const threadOneIdentity = writes[1]
|
||||
send('thread-0')
|
||||
send('thread-over-cap')
|
||||
expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 1)
|
||||
|
||||
send('thread-1')
|
||||
expect(writes).toHaveLength(MAX_CODEX_GOAL_THREADS + 2)
|
||||
expect(writes.at(-1)).toBe(threadOneIdentity)
|
||||
expect(rows).toHaveLength(MAX_CODEX_GOAL_THREADS + 1)
|
||||
|
||||
send('thread-0')
|
||||
expect(writes.at(-1)).toBe(threadOneIdentity)
|
||||
expect(writes.filter((identity) => identity === threadZeroIdentity)).toHaveLength(1)
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it('releases duplicate-suppression state on session clear and dispose', () => {
|
||||
const identities: string[] = []
|
||||
const sink = {
|
||||
appendItem: (identity: AgentJournalItemIdentity) => {
|
||||
identities.push(agentJournalItemKey(identity))
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
} satisfies StructuredAgentSessionEventSink
|
||||
const goals = new CodexJournalGoals(sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }))
|
||||
const event = { threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }
|
||||
|
||||
goals.handle(event)
|
||||
goals.handle(event)
|
||||
expect(identities).toHaveLength(1)
|
||||
|
||||
goals.clear()
|
||||
goals.handle(event)
|
||||
expect(identities).toHaveLength(2)
|
||||
expect(new Set(identities)).toHaveLength(1)
|
||||
|
||||
goals.dispose()
|
||||
goals.handle(event)
|
||||
expect(identities).toHaveLength(3)
|
||||
expect(new Set(identities)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type StructuredAgentSessionEventTarget
|
||||
} from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { CodexJournalGoals } from './codex-structured-journal-goals'
|
||||
import { MAX_CODEX_GOAL_THREADS } from './codex-structured-journal-limits'
|
||||
import { withJournalQueueMembers } from '../../../native-chat/agent-session-wire/structured-agent-session-journal-double-test-support'
|
||||
import { testEventSinkLogging } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
|
||||
@@ -128,57 +127,6 @@ function texts(rows: readonly AgentJournalItemBody[]): string[] {
|
||||
}
|
||||
|
||||
describe('codex goal lifecycle resume', () => {
|
||||
it('does not append a cleared snapshot when the journal has no prior goal occurrence', async () => {
|
||||
const journal = goalJournal()
|
||||
journal.unbind()
|
||||
const resumed = new CodexJournalGoals(journal.sink, () => ({
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}))
|
||||
|
||||
expect(
|
||||
resumed.handle({
|
||||
threadId: THREAD,
|
||||
method: 'thread/goal/cleared',
|
||||
params: { threadId: THREAD, turnId: null, clearedAt: 1789068999 }
|
||||
})
|
||||
).toEqual({ accepted: true })
|
||||
expect(journal.writes).toHaveLength(0)
|
||||
|
||||
journal.rebind()
|
||||
await journal.drained()
|
||||
|
||||
expect(journal.writes).toHaveLength(0)
|
||||
expect(journal.publishes()).toBe(0)
|
||||
resumed.dispose()
|
||||
})
|
||||
|
||||
it('does not revisit durable history for accounting-only updates', async () => {
|
||||
const journal = goalJournal()
|
||||
const goals = new CodexJournalGoals(journal.sink, () => ({
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}))
|
||||
goals.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() })
|
||||
await journal.drained()
|
||||
const visits = journal.visits()
|
||||
|
||||
for (let index = 1; index <= 10; index += 1) {
|
||||
goals.handle({
|
||||
threadId: THREAD,
|
||||
method: 'thread/goal/updated',
|
||||
params: goalFrame({
|
||||
tokensUsed: index * 1_000,
|
||||
timeUsedSeconds: index,
|
||||
updatedAt: 1789067988 + index
|
||||
})
|
||||
})
|
||||
}
|
||||
await journal.drained()
|
||||
|
||||
expect(journal.visits()).toBe(visits)
|
||||
expect(journal.writes).toHaveLength(1)
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it('rebuilds dedupe state after the journal epoch is replaced', async () => {
|
||||
const journal = goalJournal()
|
||||
const goals = new CodexJournalGoals(journal.sink, () => ({
|
||||
@@ -202,62 +150,6 @@ describe('codex goal lifecycle resume', () => {
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it('visits a large journal once per epoch when thread churn exceeds the transient LRU', async () => {
|
||||
const journal = goalJournal()
|
||||
journal.seedProviderItems(10_000)
|
||||
const goals = new CodexJournalGoals(journal.sink, () => ({
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}))
|
||||
const threadCount = MAX_CODEX_GOAL_THREADS + 1
|
||||
const sendRound = () => {
|
||||
for (let index = 0; index < threadCount; index += 1) {
|
||||
goals.handle({
|
||||
threadId: `thread-${index}`,
|
||||
method: 'thread/goal/updated',
|
||||
params: goalFrame()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sendRound()
|
||||
await journal.drained()
|
||||
for (let round = 0; round < 10; round += 1) {
|
||||
sendRound()
|
||||
}
|
||||
await journal.drained()
|
||||
|
||||
expect(journal.visits()).toBe(1)
|
||||
expect(journal.visitedItems()).toBe(10_000)
|
||||
expect(journal.writes).toHaveLength(threadCount)
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it('resolves queued thread transitions from one shared durable projection', async () => {
|
||||
const journal = goalJournal()
|
||||
journal.seedProviderItems(10_000)
|
||||
journal.unbind()
|
||||
const goals = new CodexJournalGoals(journal.sink, () => ({
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}))
|
||||
|
||||
for (let index = 0; index < MAX_CODEX_GOAL_THREADS; index += 1) {
|
||||
goals.handle({
|
||||
threadId: `thread-${index}`,
|
||||
method: 'thread/goal/updated',
|
||||
params: goalFrame()
|
||||
})
|
||||
}
|
||||
expect(journal.visits()).toBe(0)
|
||||
|
||||
journal.rebind()
|
||||
await journal.drained()
|
||||
|
||||
expect(journal.visits()).toBe(1)
|
||||
expect(journal.visitedItems()).toBe(10_000)
|
||||
expect(journal.writes).toHaveLength(MAX_CODEX_GOAL_THREADS)
|
||||
goals.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'paused',
|
||||
|
||||
Reference in New Issue
Block a user