subagent-codex.spec.ts 84 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311
  1. import { readFileSync } from 'node:fs'
  2. import { dirname, resolve } from 'node:path'
  3. import { PassThrough } from 'node:stream'
  4. import { fileURLToPath } from 'node:url'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import Loader from '@deepseek-ai/cordis-plugin-loader'
  7. import * as yaml from 'js-yaml'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import type { Agent } from '@deepseek-ai/dsh-agent'
  10. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  11. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  12. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  13. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  14. import type {
  15. SubprocessHandle,
  16. SubprocessOutcome,
  17. SubprocessSpawnSpec,
  18. } from '@deepseek-ai/dsh-subprocess'
  19. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  20. import * as codex from '../src/index.ts'
  21. import {
  22. CODEX_PERMISSION_MODES,
  23. DEFAULT_CODEX_PERMISSION_MODE,
  24. codexAppServerArgv,
  25. DEFAULT_DISPOSE_GRACE_MS,
  26. disposeCodexChild,
  27. startCodexRun,
  28. textTask,
  29. type CodexRunSpec,
  30. } from '../src/run.ts'
  31. import { CodexAppServerWire } from '../src/wire.ts'
  32. const { hostStderrWrite } = vi.hoisted(() => ({
  33. hostStderrWrite: {
  34. capture: false,
  35. failNext: false,
  36. chunks: [] as Buffer[],
  37. },
  38. }))
  39. vi.mock('node:fs', async (importOriginal) => {
  40. const actual = await importOriginal<typeof import('node:fs')>()
  41. return {
  42. ...actual,
  43. writeFileSync(
  44. fd: number,
  45. value: string | Uint8Array,
  46. ): void {
  47. if (fd === 2 && hostStderrWrite.capture) {
  48. if (hostStderrWrite.failNext) {
  49. hostStderrWrite.failNext = false
  50. throw Object.assign(new Error('host stderr broke'), { code: 'EIO' })
  51. }
  52. const bytes = typeof value === 'string'
  53. ? Buffer.from(value)
  54. : Buffer.from(value.buffer, value.byteOffset, value.byteLength)
  55. hostStderrWrite.chunks.push(bytes)
  56. return
  57. }
  58. actual.writeFileSync(fd, value)
  59. },
  60. }
  61. })
  62. type JsonObject = Record<string, unknown>
  63. const CODEX_VERSION = '0.153.4'
  64. const CODEX_PLATFORM_PACKAGES = [
  65. '@openai/codex-darwin-arm64',
  66. '@openai/codex-darwin-x64',
  67. '@openai/codex-linux-arm64',
  68. '@openai/codex-linux-x64',
  69. '@openai/codex-win32-arm64',
  70. '@openai/codex-win32-x64',
  71. ] as const
  72. const fakeParent = {
  73. id: 'parent',
  74. session: { header: { cwd: process.cwd() } },
  75. } as unknown as Agent
  76. function request(
  77. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  78. signal = new AbortController().signal,
  79. ) {
  80. return { prompt, parent: fakeParent, signal }
  81. }
  82. async function nextTask(): Promise<void> {
  83. await new Promise<void>((resolve) => { setImmediate(resolve) })
  84. }
  85. class ProtocolPeer {
  86. private buffer = ''
  87. private readonly frames: JsonObject[] = []
  88. private readonly wakeups = new Set<() => void>()
  89. constructor(
  90. input: PassThrough,
  91. private readonly output: PassThrough,
  92. ) {
  93. input.on('data', (chunk: Buffer | string) => {
  94. this.buffer += chunk.toString()
  95. for (;;) {
  96. const newline = this.buffer.indexOf('\n')
  97. if (newline < 0) break
  98. const line = this.buffer.slice(0, newline)
  99. this.buffer = this.buffer.slice(newline + 1)
  100. if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject)
  101. }
  102. for (const wake of this.wakeups) wake()
  103. this.wakeups.clear()
  104. })
  105. }
  106. async next(predicate: (frame: JsonObject) => boolean): Promise<JsonObject> {
  107. for (;;) {
  108. const index = this.frames.findIndex(predicate)
  109. if (index >= 0) return this.frames.splice(index, 1)[0]!
  110. await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
  111. }
  112. }
  113. nextMethod(method: string): Promise<JsonObject> {
  114. return this.next(frame => frame.method === method)
  115. }
  116. nextResponse(id: unknown): Promise<JsonObject> {
  117. return this.next(frame => frame.id === id && frame.method === undefined)
  118. }
  119. send(...frames: readonly JsonObject[]): void {
  120. this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
  121. }
  122. respond(requestFrame: JsonObject, result: unknown): void {
  123. this.send({ id: requestFrame.id, result })
  124. }
  125. }
  126. interface FakeChildOptions {
  127. readonly exitOnTerminate?: boolean
  128. readonly doneError?: Error
  129. readonly waitForExitError?: Error
  130. }
  131. interface FakeChild {
  132. readonly handle: SubprocessHandle
  133. readonly peer: ProtocolPeer
  134. readonly fromChild: PassThrough
  135. readonly toChild: PassThrough
  136. readonly stderr: PassThrough
  137. readonly settle: (outcome?: SubprocessOutcome) => void
  138. readonly fail: (error: Error) => void
  139. readonly setStderr: (text: string) => void
  140. readonly terminate: () => void
  141. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  142. }
  143. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  144. const fromChild = new PassThrough()
  145. const toChild = new PassThrough()
  146. const stderr = new PassThrough()
  147. const peer = new ProtocolPeer(toChild, fromChild)
  148. let exited = false
  149. let resolveDone!: (outcome: SubprocessOutcome) => void
  150. let rejectDone!: (error: Error) => void
  151. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  152. resolveDone = resolve
  153. rejectDone = reject
  154. })
  155. const settle = (
  156. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  157. ): void => {
  158. if (exited) return
  159. exited = true
  160. resolveDone(outcome)
  161. }
  162. const fail = (error: Error): void => {
  163. if (exited) return
  164. exited = true
  165. rejectDone(error)
  166. }
  167. if (options.doneError !== undefined) fail(options.doneError)
  168. const terminate = vi.fn(() => {
  169. if (options.exitOnTerminate !== false) settle()
  170. })
  171. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  172. if (options.waitForExitError !== undefined) {
  173. throw options.waitForExitError
  174. }
  175. if (exited) return true
  176. if (signal === undefined) {
  177. await done.catch(() => {})
  178. return true
  179. }
  180. return await new Promise<boolean>((resolve) => {
  181. const onAbort = (): void => { resolve(false) }
  182. signal.addEventListener('abort', onAbort, { once: true })
  183. void done.then(
  184. () => {
  185. signal.removeEventListener('abort', onAbort)
  186. resolve(true)
  187. },
  188. () => {
  189. signal.removeEventListener('abort', onAbort)
  190. resolve(true)
  191. },
  192. )
  193. })
  194. })
  195. const handle: SubprocessHandle = {
  196. control: undefined,
  197. stdin: toChild,
  198. stdout: fromChild,
  199. stderr,
  200. collected: {},
  201. done,
  202. terminate,
  203. waitForExit,
  204. }
  205. return {
  206. handle,
  207. peer,
  208. fromChild,
  209. toChild,
  210. stderr,
  211. settle,
  212. fail,
  213. setStderr: (text: string): void => { stderr.write(text) },
  214. terminate,
  215. waitForExit,
  216. }
  217. }
  218. function defaultWire(child: FakeChild): CodexAppServerWire {
  219. return new CodexAppServerWire(
  220. child.handle.stdout!,
  221. child.handle.stdin!,
  222. DEFAULT_CODEX_PERMISSION_MODE,
  223. )
  224. }
  225. function runSpec(
  226. child: FakeChild,
  227. overrides: Partial<CodexRunSpec> = {},
  228. ): CodexRunSpec {
  229. return {
  230. cwd: process.cwd(),
  231. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  232. env: {},
  233. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  234. spawn: () => child.handle,
  235. ...overrides,
  236. }
  237. }
  238. async function initializeWire(): Promise<{
  239. readonly child: FakeChild
  240. readonly wire: CodexAppServerWire
  241. }> {
  242. const child = fakeChild()
  243. const wire = defaultWire(child)
  244. wire.start()
  245. const initializing = wire.initialize(new AbortController().signal)
  246. const initialize = await child.peer.nextMethod('initialize')
  247. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  248. await initializing
  249. expect(await child.peer.nextMethod('initialized')).toEqual({
  250. jsonrpc: '2.0',
  251. method: 'initialized',
  252. })
  253. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  254. const threadStart = await child.peer.nextMethod('thread/start')
  255. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  256. await starting
  257. return { child, wire }
  258. }
  259. async function publishRun(
  260. child = fakeChild(),
  261. signal = new AbortController().signal,
  262. specOverrides: Partial<CodexRunSpec> = {},
  263. ) {
  264. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  265. const initialize = await child.peer.nextMethod('initialize')
  266. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  267. await child.peer.nextMethod('initialized')
  268. const threadStart = await child.peer.nextMethod('thread/start')
  269. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  270. const run = await starting
  271. const turnStart = await child.peer.nextMethod('turn/start')
  272. return { child, run, turnStart }
  273. }
  274. function agentMessage(
  275. text: unknown,
  276. phase: unknown,
  277. turnId = 'turn-1',
  278. threadId = 'thread-1',
  279. ): JsonObject {
  280. return {
  281. method: 'item/completed',
  282. params: {
  283. threadId,
  284. turnId,
  285. item: { type: 'agentMessage', text, phase },
  286. },
  287. }
  288. }
  289. function turnCompleted(
  290. status: unknown,
  291. turnId = 'turn-1',
  292. threadId = 'thread-1',
  293. error: unknown = null,
  294. ): JsonObject {
  295. return {
  296. method: 'turn/completed',
  297. params: {
  298. threadId,
  299. turn: { id: turnId, status, error },
  300. },
  301. }
  302. }
  303. function expectedFailureDiagnostic(
  304. stage: 'initialize' | 'thread-start' | 'turn-start' | 'turn' | 'process' | 'teardown',
  305. category: string,
  306. options: {
  307. readonly httpStatus?: number
  308. readonly outcome?: Partial<SubprocessOutcome>
  309. } = {},
  310. ): string {
  311. const fields = [
  312. 'product: Codex',
  313. `stage: ${stage}`,
  314. `category: ${category}`,
  315. ]
  316. if (options.httpStatus !== undefined) {
  317. fields.push(`HTTP status: ${options.httpStatus}`)
  318. }
  319. if (
  320. options.outcome?.exitCode !== null
  321. && options.outcome?.exitCode !== undefined
  322. ) {
  323. fields.push(`exit code: ${options.outcome.exitCode}`)
  324. }
  325. if (
  326. options.outcome?.signal !== null
  327. && options.outcome?.signal !== undefined
  328. ) {
  329. fields.push(`signal: ${options.outcome.signal}`)
  330. }
  331. return `Product subagent failure (${fields.join('; ')})`
  332. }
  333. describe('task admission and package contracts', () => {
  334. it('ships one independently installable provider-only Bundle patch', () => {
  335. const root = fileURLToPath(new URL('..', import.meta.url))
  336. const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
  337. dependencies?: Record<string, string>
  338. files?: string[]
  339. dsh?: { bundle?: { patch?: string } }
  340. }
  341. expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml')
  342. expect(manifest.files).toContain('cordis.patch.yml')
  343. expect(manifest.dependencies).toHaveProperty(
  344. '@deepseek-ai/dsh-sdk-protocol',
  345. 'workspace:^',
  346. )
  347. expect(manifest.dependencies).toHaveProperty('@openai/codex', CODEX_VERSION)
  348. expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
  349. const codexPackageJson = fileURLToPath(import.meta.resolve('@openai/codex/package.json'))
  350. const codexManifest = JSON.parse(readFileSync(codexPackageJson, 'utf8')) as {
  351. version: string
  352. bin: { codex: string }
  353. optionalDependencies: Record<string, string>
  354. }
  355. expect(codexManifest.version).toBe(CODEX_VERSION)
  356. expect(codexManifest.bin).toEqual({ codex: 'bin/codex.js' })
  357. expect(codexManifest.optionalDependencies).toEqual(Object.fromEntries(
  358. CODEX_PLATFORM_PACKAGES.map(packageName => [
  359. packageName,
  360. `npm:@openai/codex@${CODEX_VERSION}-${packageName.slice('@openai/codex-'.length)}`,
  361. ]),
  362. ))
  363. expect(codexAppServerArgv()).toEqual([
  364. process.execPath,
  365. resolve(dirname(codexPackageJson), codexManifest.bin.codex),
  366. 'app-server',
  367. '--stdio',
  368. ])
  369. const lockfile = readFileSync(resolve(root, '../../../pnpm-lock.yaml'), 'utf8')
  370. for (const packageName of CODEX_PLATFORM_PACKAGES) {
  371. const suffix = packageName.slice('@openai/codex-'.length)
  372. expect(lockfile).toContain(` '@openai/codex@${CODEX_VERSION}-${suffix}':`)
  373. expect(lockfile).toContain(
  374. ` '${packageName}': '@openai/codex@${CODEX_VERSION}-${suffix}'`,
  375. )
  376. }
  377. const parsed = yaml.load(readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'))
  378. const rows = Array.isArray(parsed)
  379. ? (parsed as Array<{ insert?: Array<{ id?: string; name?: string }> }>).flatMap(entry => entry.insert ?? [])
  380. : []
  381. expect(rows).toEqual([{
  382. id: 'subagent-codex',
  383. name: '@deepseek-ai/dsh-subagent-codex',
  384. }])
  385. expect(JSON.stringify(rows)).not.toContain('tool-subagent')
  386. })
  387. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  388. expect(textTask([
  389. { type: 'text', text: 'one' },
  390. { type: 'text', text: 'two' },
  391. ])).toEqual(['one', 'two'])
  392. expect(() => textTask([])).toThrow('only text blocks')
  393. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  394. .toThrow('only text blocks')
  395. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  396. .toThrow('must not be empty')
  397. })
  398. it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
  399. const ctx = new Context()
  400. await ctx.plugin(SessionProjectionRegistry)
  401. await ctx.plugin(SubagentRuntime)
  402. await ctx.plugin(LocalSubprocessRuntime)
  403. const fiber = await ctx.plugin(codex, {})
  404. const provider = ctx.subagents.getProvider('codex')!
  405. expect(provider).toMatchObject({
  406. name: 'codex',
  407. capabilities: {
  408. outputSchema: false,
  409. depthLimit: false,
  410. toolFilter: false,
  411. persona: false,
  412. },
  413. inheritsParentContext: false,
  414. })
  415. expect(ctx.subagents.list()).toEqual(['codex'])
  416. await fiber.dispose()
  417. expect(ctx.subagents.list()).toEqual([])
  418. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  419. await expect(ctx.plugin(codex, { disposeGraceMs }))
  420. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  421. }
  422. await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
  423. .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  424. await ctx.fiber.dispose()
  425. })
  426. it('keeps named instances, runs, and HMR ownership isolated', async () => {
  427. const ctx = new Context()
  428. await ctx.plugin(SessionProjectionRegistry)
  429. await ctx.plugin(SubagentRuntime)
  430. await ctx.plugin(LocalSubprocessRuntime)
  431. const safeChild = fakeChild()
  432. const bypassChild = fakeChild()
  433. const spawnSpecs: SubprocessSpawnSpec[] = []
  434. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
  435. spawnSpecs.push(spec)
  436. return spec.env?.DSH_CODEX_INSTANCE === 'safe'
  437. ? safeChild.handle
  438. : bypassChild.handle
  439. })
  440. const added: string[] = []
  441. const started: string[] = []
  442. const ended: string[] = []
  443. const removed: string[] = []
  444. ctx.on('subagent/provider-added', provider => void added.push(provider.name))
  445. ctx.on('subagent/start', info => void started.push(info.provider))
  446. ctx.on('subagent/end', info => void ended.push(info.provider))
  447. ctx.on('subagent/provider-removed', providerName => void removed.push(providerName))
  448. const safeFiber = await ctx.plugin(codex, {
  449. providerName: 'codex-safe',
  450. model: 'codex-safe-model',
  451. env: { DSH_CODEX_INSTANCE: 'safe' },
  452. permissionMode: 'never',
  453. disposeGraceMs: 11,
  454. })
  455. const bypassFiber = await ctx.plugin(codex, {
  456. providerName: 'codex-bypass',
  457. model: 'codex-bypass-model',
  458. env: { DSH_CODEX_INSTANCE: 'bypass' },
  459. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  460. disposeGraceMs: 29,
  461. })
  462. expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass'])
  463. expect(added).toEqual(['codex-safe', 'codex-bypass'])
  464. const safeController = new AbortController()
  465. const safeStarting = ctx.subagents.start(
  466. 'codex-safe',
  467. request(undefined, safeController.signal),
  468. )
  469. const bypassStarting = ctx.subagents.start('codex-bypass', request())
  470. for (const [child, model] of [
  471. [safeChild, 'codex-safe-model'],
  472. [bypassChild, 'codex-bypass-model'],
  473. ] as const) {
  474. const initialize = await child.peer.nextMethod('initialize')
  475. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  476. await child.peer.nextMethod('initialized')
  477. const threadStart = await child.peer.nextMethod('thread/start')
  478. expect(threadStart.params).toMatchObject({ model })
  479. child.peer.respond(threadStart, {
  480. thread: { id: 'thread-1', ephemeral: true },
  481. })
  482. }
  483. const [safeRun, bypassRun] = await Promise.all([
  484. safeStarting,
  485. bypassStarting,
  486. ])
  487. await safeFiber.dispose()
  488. expect(ctx.subagents.list()).toEqual(['codex-bypass'])
  489. expect(removed).toEqual(['codex-safe'])
  490. await expect(ctx.subagents.start('codex-safe', request()))
  491. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  492. const safeTurn = await safeChild.peer.nextMethod('turn/start')
  493. const bypassTurn = await bypassChild.peer.nextMethod('turn/start')
  494. safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } })
  495. bypassChild.peer.send(
  496. { id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } },
  497. agentMessage('bypass answer', 'final_answer', 'turn-bypass'),
  498. turnCompleted('completed', 'turn-bypass'),
  499. )
  500. await expect(bypassRun.result).resolves.toEqual({
  501. output: [{ type: 'text', text: 'bypass answer' }],
  502. stopReason: 'completed',
  503. })
  504. safeController.abort(new Error('stop only the safe instance'))
  505. await expect(safeRun.result).resolves.toEqual({
  506. output: [],
  507. stopReason: 'aborted',
  508. })
  509. expect(spawnSpecs.map(spec => ({
  510. instance: spec.env?.DSH_CODEX_INSTANCE,
  511. graceMs: spec.graceMs,
  512. }))).toEqual([
  513. { instance: 'safe', graceMs: 11 },
  514. { instance: 'bypass', graceMs: 29 },
  515. ])
  516. await Promise.all([safeRun.dispose(), bypassRun.dispose()])
  517. expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe'])
  518. expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe'])
  519. expect(safeChild.terminate).toHaveBeenCalledOnce()
  520. expect(bypassChild.terminate).toHaveBeenCalledOnce()
  521. await bypassFiber.dispose()
  522. expect(removed).toEqual(['codex-safe', 'codex-bypass'])
  523. await ctx.fiber.dispose()
  524. })
  525. it('rejects duplicate provider names without replacing the first instance', async () => {
  526. const ctx = new Context()
  527. await ctx.plugin(SessionProjectionRegistry)
  528. await ctx.plugin(SubagentRuntime)
  529. await ctx.plugin(LocalSubprocessRuntime)
  530. const firstFiber = await ctx.plugin(codex, {
  531. providerName: 'codex-duplicate',
  532. })
  533. const first = ctx.subagents.getProvider('codex-duplicate')
  534. await expect(ctx.plugin(codex, {
  535. providerName: 'codex-duplicate',
  536. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  537. })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
  538. expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first)
  539. expect(ctx.subagents.list()).toEqual(['codex-duplicate'])
  540. await firstFiber.dispose()
  541. await ctx.fiber.dispose()
  542. })
  543. it('accepts an optional non-empty model and the three fixed permission modes', () => {
  544. expect(codex.Config({}).providerName).toBe('codex')
  545. expect(codex.Config({}).model).toBeUndefined()
  546. expect(codex.Config({ providerName: 'codex-safe' }).providerName)
  547. .toBe('codex-safe')
  548. expect(() => codex.Config({ providerName: '' })).toThrow()
  549. expect(codex.Config({ model: 'gpt-codex' }).model).toBe('gpt-codex')
  550. expect(() => codex.Config({ model: '' })).toThrow()
  551. expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
  552. for (const permissionMode of CODEX_PERMISSION_MODES) {
  553. expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
  554. }
  555. for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
  556. expect(() => codex.Config({ permissionMode } as never)).toThrow()
  557. }
  558. })
  559. it('resolves the safe permission default when apply is called directly', async () => {
  560. const ctx = new Context()
  561. await ctx.plugin(SessionProjectionRegistry)
  562. await ctx.plugin(SubagentRuntime)
  563. await ctx.plugin(LocalSubprocessRuntime)
  564. const child = fakeChild()
  565. vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  566. codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  567. expect(ctx.subagents.getProvider('codex')).toBeDefined()
  568. const starting = ctx.subagents.start('codex', request())
  569. const initialize = await child.peer.nextMethod('initialize')
  570. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  571. await child.peer.nextMethod('initialized')
  572. const threadStart = await child.peer.nextMethod('thread/start')
  573. expect(threadStart.params).not.toHaveProperty('model')
  574. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  575. const run = await starting
  576. const turnStart = await child.peer.nextMethod('turn/start')
  577. child.peer.send(
  578. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  579. agentMessage('native model answer', 'final_answer'),
  580. turnCompleted('completed'),
  581. )
  582. await expect(run.result).resolves.toEqual({
  583. output: [{ type: 'text', text: 'native model answer' }],
  584. stopReason: 'completed',
  585. })
  586. await run.dispose()
  587. await ctx.fiber.dispose()
  588. })
  589. it.each([
  590. ['never', { approvalPolicy: 'never' }],
  591. ['approve-for-me', {
  592. approvalPolicy: 'on-request',
  593. approvalsReviewer: 'auto_review',
  594. sandbox: 'workspace-write',
  595. }],
  596. ['dangerously-bypass-approvals-and-sandbox', {
  597. approvalPolicy: 'never',
  598. sandbox: 'danger-full-access',
  599. }],
  600. ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
  601. const child = fakeChild()
  602. const wire = new CodexAppServerWire(
  603. child.handle.stdout!,
  604. child.handle.stdin!,
  605. permissionMode,
  606. )
  607. wire.start()
  608. const initializing = wire.initialize(new AbortController().signal)
  609. const initialize = await child.peer.nextMethod('initialize')
  610. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  611. await initializing
  612. await child.peer.nextMethod('initialized')
  613. const starting = wire.startThread('/workspace', new AbortController().signal)
  614. const threadStart = await child.peer.nextMethod('thread/start')
  615. expect(threadStart.params).toEqual({
  616. cwd: '/workspace',
  617. ephemeral: true,
  618. ...expected,
  619. })
  620. expect(threadStart.params).not.toHaveProperty('model')
  621. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  622. await starting
  623. wire.close()
  624. })
  625. it('sends an explicit model on each ephemeral thread', async () => {
  626. const child = fakeChild()
  627. const wire = new CodexAppServerWire(
  628. child.handle.stdout!,
  629. child.handle.stdin!,
  630. 'never',
  631. 'codex-explicit-model',
  632. )
  633. wire.start()
  634. const initializing = wire.initialize(new AbortController().signal)
  635. const initialize = await child.peer.nextMethod('initialize')
  636. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  637. await initializing
  638. await child.peer.nextMethod('initialized')
  639. const starting = wire.startThread('/workspace', new AbortController().signal)
  640. const threadStart = await child.peer.nextMethod('thread/start')
  641. expect(threadStart.params).toEqual({
  642. cwd: '/workspace',
  643. ephemeral: true,
  644. model: 'codex-explicit-model',
  645. approvalPolicy: 'never',
  646. })
  647. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  648. await starting
  649. wire.close()
  650. })
  651. it('requires a parent session cwd without suggesting unsupported config', async () => {
  652. const ctx = new Context()
  653. await ctx.plugin(SessionProjectionRegistry)
  654. await ctx.plugin(SubagentRuntime)
  655. await ctx.plugin(LocalSubprocessRuntime)
  656. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  657. await ctx.plugin(codex, {})
  658. await expect(ctx.subagents.start('codex', {
  659. prompt: [{ type: 'text', text: 'task' }],
  660. parent: {
  661. id: 'parent-without-cwd',
  662. session: { header: {} },
  663. } as unknown as Agent,
  664. signal: new AbortController().signal,
  665. })).rejects.toThrow(
  666. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  667. )
  668. expect(spawn).not.toHaveBeenCalled()
  669. await ctx.fiber.dispose()
  670. })
  671. it('keeps the namespace export shape', () => {
  672. expect('default' in codex).toBe(false)
  673. expect(codex.name).toBe('subagent-codex')
  674. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  675. const loader = Object.create(Loader.prototype) as Loader
  676. expect(loader.unwrapExports(codex)).toBe(codex)
  677. })
  678. })
  679. describe('CodexAppServerWire', () => {
  680. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  681. const child = fakeChild()
  682. const wire = defaultWire(child)
  683. expect(wire.collectOutput()).toEqual([])
  684. wire.start()
  685. const initializing = wire.initialize(new AbortController().signal)
  686. const initialize = await child.peer.nextMethod('initialize')
  687. expect(initialize.params).toEqual({
  688. clientInfo: {
  689. name: 'deepseek-harness',
  690. title: 'DeepSeek Harness',
  691. version: '0.0.1',
  692. },
  693. capabilities: {
  694. experimentalApi: false,
  695. requestAttestation: false,
  696. },
  697. })
  698. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  699. await initializing
  700. await child.peer.nextMethod('initialized')
  701. const starting = wire.startThread('/workspace', new AbortController().signal)
  702. const threadStart = await child.peer.nextMethod('thread/start')
  703. expect(threadStart.params).toEqual({
  704. cwd: '/workspace',
  705. ephemeral: true,
  706. approvalPolicy: 'never',
  707. })
  708. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  709. await starting
  710. const result = wire.runTurn(
  711. ['first', 'second'],
  712. new AbortController().signal,
  713. )
  714. const turnStart = await child.peer.nextMethod('turn/start')
  715. expect(turnStart.params).toEqual({
  716. threadId: 'thread-1',
  717. input: [
  718. { type: 'text', text: 'first', text_elements: [] },
  719. { type: 'text', text: 'second', text_elements: [] },
  720. ],
  721. })
  722. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  723. await nextTask()
  724. child.peer.send(
  725. {
  726. method: 'turn/started',
  727. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  728. },
  729. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  730. agentMessage('other turn', 'final_answer', 'turn-2'),
  731. {
  732. method: 'item/completed',
  733. params: {
  734. threadId: 'thread-1',
  735. turnId: 'turn-1',
  736. item: { type: 'reasoning', text: 'not output' },
  737. },
  738. },
  739. agentMessage('commentary', 'commentary'),
  740. agentMessage('unphased', null),
  741. agentMessage('first final', 'final_answer'),
  742. agentMessage('last final', 'final_answer'),
  743. turnCompleted('completed'),
  744. )
  745. await expect(result).resolves.toEqual({
  746. output: [{ type: 'text', text: 'last final' }],
  747. stopReason: 'completed',
  748. })
  749. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  750. wire.close()
  751. wire.close()
  752. })
  753. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  754. const { child, wire } = await initializeWire()
  755. const result = wire.runTurn(['task'], new AbortController().signal)
  756. const turnStart = await child.peer.nextMethod('turn/start')
  757. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  758. child.peer.send(
  759. agentMessage('first', null),
  760. agentMessage('fallback', null),
  761. turnCompleted('completed'),
  762. )
  763. await expect(result).resolves.toEqual({
  764. output: [{ type: 'text', text: 'fallback' }],
  765. stopReason: 'completed',
  766. })
  767. wire.close()
  768. })
  769. it('groups representative string errors without changing stop reasons', async () => {
  770. const scenarios = [
  771. ['contextWindowExceeded', 'limit', 'max-tokens'],
  772. ['sessionBudgetExceeded', 'limit', 'error'],
  773. ['cyberPolicy', 'access-policy', 'error'],
  774. ['misalignmentPolicyViolation', 'access-policy', 'error'],
  775. ['serverOverloaded', 'service', 'error'],
  776. ['badRequest', 'product-error', 'error'],
  777. ['sandboxError', 'access-policy', 'error'],
  778. ] as const
  779. for (const [codexErrorInfo, category, stopReason] of scenarios) {
  780. const { child, wire } = await initializeWire()
  781. const result = wire.runTurn(['task'], new AbortController().signal)
  782. const turnStart = await child.peer.nextMethod('turn/start')
  783. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  784. child.peer.send(
  785. agentMessage('partial answer', null),
  786. turnCompleted('failed', 'turn-1', 'thread-1', {
  787. message: 'SECRET_TOKEN in /private/secret.txt',
  788. codexErrorInfo,
  789. }),
  790. )
  791. if (stopReason === 'max-tokens') {
  792. await expect(result).resolves.toEqual({
  793. output: [{ type: 'text', text: 'partial answer' }],
  794. stopReason: 'max-tokens',
  795. })
  796. } else {
  797. await expect(result).rejects.toThrow(`status failed: ${category}`)
  798. }
  799. expect(wire.collectFailure()).toEqual({
  800. stage: 'turn',
  801. category,
  802. })
  803. expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN')
  804. expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt')
  805. wire.close()
  806. }
  807. })
  808. it('groups object errors and retains only numeric HTTP status', async () => {
  809. const scenarios = [
  810. ['httpConnectionFailed', { httpStatusCode: 503 }, 'transport', 503],
  811. ['responseStreamDisconnected', {}, 'transport', undefined],
  812. ['responseTooManyFailedAttempts', { httpStatusCode: '503' }, 'transport', undefined],
  813. ['activeTurnNotSteerable', { turnKind: 'review' }, 'product-error', undefined],
  814. ] as const
  815. for (const [codexErrorInfo, detail, category, httpStatus] of scenarios) {
  816. const { child, wire } = await initializeWire()
  817. const result = wire.runTurn(['task'], new AbortController().signal)
  818. const turnStart = await child.peer.nextMethod('turn/start')
  819. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  820. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  821. message: 'SECRET_TOKEN in /private/secret.txt',
  822. codexErrorInfo: { [codexErrorInfo]: detail },
  823. }))
  824. await expect(result).rejects.toThrow(`status failed: ${category}`)
  825. expect(wire.collectFailure()).toEqual({
  826. stage: 'turn',
  827. category,
  828. ...(httpStatus === undefined ? {} : { httpStatus }),
  829. })
  830. expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind')
  831. wire.close()
  832. }
  833. })
  834. it('uses unknown for version-external or malformed error info', async () => {
  835. for (const codexErrorInfo of [
  836. 'futureError',
  837. { futureVariant: { message: 'SECRET_TOKEN' } },
  838. {
  839. httpConnectionFailed: { httpStatusCode: 503 },
  840. otherVariant: {},
  841. },
  842. { httpConnectionFailed: null },
  843. ]) {
  844. const { child, wire } = await initializeWire()
  845. const result = wire.runTurn(['task'], new AbortController().signal)
  846. const turnStart = await child.peer.nextMethod('turn/start')
  847. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  848. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  849. message: 'SECRET_TOKEN in /private/secret.txt',
  850. codexErrorInfo,
  851. }))
  852. await expect(result).rejects.toThrow('status failed: unknown')
  853. expect(wire.collectFailure()).toEqual({
  854. stage: 'turn',
  855. category: 'unknown',
  856. })
  857. wire.close()
  858. }
  859. })
  860. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  861. {
  862. const child = fakeChild()
  863. const wire = defaultWire(child)
  864. wire.start()
  865. const pending = wire.initialize(new AbortController().signal)
  866. const frame = await child.peer.nextMethod('initialize')
  867. child.peer.respond(frame, null)
  868. await expect(pending).rejects.toThrow('invalid initialize response')
  869. wire.close()
  870. }
  871. {
  872. const child = fakeChild()
  873. const wire = defaultWire(child)
  874. wire.start()
  875. const pending = wire.startThread('/workspace', new AbortController().signal)
  876. const frame = await child.peer.nextMethod('thread/start')
  877. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  878. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  879. wire.close()
  880. }
  881. {
  882. const { child, wire } = await initializeWire()
  883. const pending = wire.runTurn(['task'], new AbortController().signal)
  884. const frame = await child.peer.nextMethod('turn/start')
  885. child.peer.respond(frame, { turn: { id: '' } })
  886. await expect(pending).rejects.toThrow('turn/start turn id')
  887. expect(wire.collectFailure()).toEqual({
  888. stage: 'turn-start',
  889. category: 'unknown',
  890. })
  891. wire.close()
  892. }
  893. })
  894. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  895. const scenarios: Array<{
  896. readonly frames: JsonObject[]
  897. readonly message: string
  898. readonly category: 'invalid-result' | 'unknown'
  899. }> = [
  900. {
  901. frames: [turnCompleted('completed')],
  902. message: 'without a final answer',
  903. category: 'invalid-result',
  904. },
  905. {
  906. frames: [
  907. agentMessage('fallback', null),
  908. agentMessage(' \n ', 'final_answer'),
  909. turnCompleted('completed'),
  910. ],
  911. message: 'without a final answer',
  912. category: 'invalid-result',
  913. },
  914. {
  915. frames: [agentMessage(42, 'final_answer')],
  916. message: 'invalid agent message',
  917. category: 'unknown',
  918. },
  919. {
  920. frames: [agentMessage('answer', 'future_phase')],
  921. message: 'unknown agent message phase',
  922. category: 'unknown',
  923. },
  924. {
  925. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  926. message: 'status failed',
  927. category: 'unknown',
  928. },
  929. {
  930. frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')],
  931. message: 'status failed',
  932. category: 'unknown',
  933. },
  934. {
  935. frames: [turnCompleted('interrupted')],
  936. message: 'status interrupted',
  937. category: 'unknown',
  938. },
  939. {
  940. frames: [turnCompleted('inProgress')],
  941. message: 'invalid terminal turn status',
  942. category: 'unknown',
  943. },
  944. ]
  945. for (const scenario of scenarios) {
  946. const { child, wire } = await initializeWire()
  947. const result = wire.runTurn(['task'], new AbortController().signal)
  948. const turnStart = await child.peer.nextMethod('turn/start')
  949. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  950. await nextTask()
  951. child.peer.send(...scenario.frames)
  952. await expect(result).rejects.toThrow(scenario.message)
  953. expect(wire.collectFailure()).toEqual({
  954. stage: 'turn',
  955. category: scenario.category,
  956. })
  957. wire.close()
  958. }
  959. })
  960. it('fails closed when terminal notification params are not an object', async () => {
  961. const { child, wire } = await initializeWire()
  962. const result = wire.runTurn(['task'], new AbortController().signal)
  963. const turnStart = await child.peer.nextMethod('turn/start')
  964. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  965. child.peer.send({ method: 'turn/completed', params: null })
  966. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  967. wire.close()
  968. })
  969. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  970. const { child, wire } = await initializeWire()
  971. const result = wire.runTurn(['task'], new AbortController().signal)
  972. const turnStart = await child.peer.nextMethod('turn/start')
  973. child.peer.send(
  974. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  975. { id: 'future-request', method: 'future/request', params: {} },
  976. agentMessage('early answer', 'final_answer'),
  977. turnCompleted('completed'),
  978. )
  979. await expect(result).rejects.toThrow('unsupported app-server request')
  980. wire.close()
  981. })
  982. it('answers all five unattended request classes without granting authority', async () => {
  983. const { child, wire } = await initializeWire()
  984. const result = wire.runTurn(['task'], new AbortController().signal)
  985. const turnStart = await child.peer.nextMethod('turn/start')
  986. child.peer.send({
  987. id: 'command',
  988. method: 'item/commandExecution/requestApproval',
  989. params: {
  990. threadId: 'thread-1',
  991. turnId: 'turn-1',
  992. availableDecisions: ['decline', 'cancel'],
  993. command: 'cat /private/secret.txt',
  994. },
  995. })
  996. expect(await child.peer.nextResponse('command')).toMatchObject({
  997. result: { decision: 'cancel' },
  998. })
  999. expect(wire.collectDiagnostic()).toBeUndefined()
  1000. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1001. await nextTask()
  1002. expect(wire.collectDiagnostic()).toBe(
  1003. 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1004. )
  1005. const requests = [
  1006. {
  1007. id: 'command-decline',
  1008. method: 'item/commandExecution/requestApproval',
  1009. params: {
  1010. threadId: 'thread-1',
  1011. turnId: 'turn-1',
  1012. availableDecisions: ['decline'],
  1013. },
  1014. result: { decision: 'decline' },
  1015. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
  1016. },
  1017. {
  1018. id: 'file',
  1019. method: 'item/fileChange/requestApproval',
  1020. params: {
  1021. threadId: 'thread-1',
  1022. turnId: 'turn-1',
  1023. availableDecisions: ['decline'],
  1024. },
  1025. result: { decision: 'decline' },
  1026. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  1027. },
  1028. {
  1029. id: 'file-cancel',
  1030. method: 'item/fileChange/requestApproval',
  1031. params: {
  1032. threadId: 'thread-1',
  1033. turnId: 'turn-1',
  1034. availableDecisions: ['cancel'],
  1035. },
  1036. result: { decision: 'cancel' },
  1037. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval',
  1038. },
  1039. {
  1040. id: 'file-default',
  1041. method: 'item/fileChange/requestApproval',
  1042. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1043. result: { decision: 'decline' },
  1044. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  1045. },
  1046. {
  1047. id: 'permissions',
  1048. method: 'item/permissions/requestApproval',
  1049. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1050. result: { permissions: {}, scope: 'turn' },
  1051. diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
  1052. },
  1053. {
  1054. id: 'user-input',
  1055. method: 'item/tool/requestUserInput',
  1056. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  1057. result: { answers: {} },
  1058. diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
  1059. },
  1060. {
  1061. id: 'mcp',
  1062. method: 'mcpServer/elicitation/request',
  1063. params: { threadId: 'thread-1', turnId: null },
  1064. result: { action: 'decline', content: null, _meta: null },
  1065. diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  1066. },
  1067. ] as const
  1068. for (const serverRequest of requests) {
  1069. child.peer.send(serverRequest)
  1070. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  1071. result: serverRequest.result,
  1072. })
  1073. expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
  1074. }
  1075. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1076. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1077. await expect(result).resolves.toEqual({
  1078. output: [{ type: 'text', text: 'answer' }],
  1079. stopReason: 'completed',
  1080. })
  1081. wire.close()
  1082. })
  1083. it('records only a safe diagnostic for an explicit sandbox failure', async () => {
  1084. const { child, wire } = await initializeWire()
  1085. const result = wire.runTurn(['task'], new AbortController().signal)
  1086. const turnStart = await child.peer.nextMethod('turn/start')
  1087. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1088. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1089. message: 'failed at /private/secret.txt with SECRET_TOKEN',
  1090. additionalDetails: 'raw command payload',
  1091. codexErrorInfo: 'sandboxError',
  1092. }))
  1093. await expect(result).rejects.toThrow('status failed')
  1094. expect(wire.collectDiagnostic()).toBe(
  1095. 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
  1096. )
  1097. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  1098. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1099. wire.close()
  1100. })
  1101. it('records declined command and file items without retaining their payloads', async () => {
  1102. const { child, wire } = await initializeWire()
  1103. const result = wire.runTurn(['task'], new AbortController().signal)
  1104. const turnStart = await child.peer.nextMethod('turn/start')
  1105. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1106. child.peer.send({
  1107. method: 'item/completed',
  1108. params: {
  1109. threadId: 'thread-1',
  1110. turnId: 'turn-1',
  1111. item: {
  1112. type: 'commandExecution',
  1113. status: 'declined',
  1114. command: 'cat /private/secret.txt',
  1115. },
  1116. },
  1117. })
  1118. await nextTask()
  1119. expect(wire.collectDiagnostic()).toBe(
  1120. 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
  1121. )
  1122. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1123. child.peer.send(
  1124. {
  1125. method: 'item/completed',
  1126. params: {
  1127. threadId: 'thread-1',
  1128. turnId: 'turn-1',
  1129. item: {
  1130. type: 'fileChange',
  1131. status: 'declined',
  1132. patch: 'SECRET_TOKEN in /private/secret.txt',
  1133. },
  1134. },
  1135. },
  1136. turnCompleted('failed', 'turn-1', 'thread-1', {
  1137. message: 'SECRET_TOKEN in /private/secret.txt',
  1138. codexErrorInfo: 'other',
  1139. }),
  1140. )
  1141. await expect(result).rejects.toThrow('status failed')
  1142. expect(wire.collectDiagnostic()).toBe(
  1143. 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode',
  1144. )
  1145. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  1146. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1147. wire.close()
  1148. })
  1149. it('keeps a newer request diagnostic after replaying an older early item', async () => {
  1150. const { child, wire } = await initializeWire()
  1151. const result = wire.runTurn(['task'], new AbortController().signal)
  1152. const turnStart = await child.peer.nextMethod('turn/start')
  1153. child.peer.send({
  1154. method: 'item/completed',
  1155. params: {
  1156. threadId: 'thread-1',
  1157. turnId: 'turn-1',
  1158. item: { type: 'fileChange', status: 'declined' },
  1159. },
  1160. })
  1161. await nextTask()
  1162. child.peer.send({
  1163. id: 'newer-command-request',
  1164. method: 'item/commandExecution/requestApproval',
  1165. params: {
  1166. threadId: 'thread-1',
  1167. turnId: 'turn-1',
  1168. availableDecisions: ['cancel'],
  1169. },
  1170. })
  1171. await child.peer.nextResponse('newer-command-request')
  1172. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1173. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1174. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1175. expect(wire.collectDiagnostic()).toContain('request: command approval')
  1176. wire.close()
  1177. })
  1178. it('fails the run on unknown requests or wrong request association', async () => {
  1179. for (const serverRequest of [
  1180. {
  1181. id: 'unknown',
  1182. method: 'future/request',
  1183. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1184. },
  1185. {
  1186. id: 'approval',
  1187. method: 'item/commandExecution/requestApproval',
  1188. params: {
  1189. threadId: 'thread-1',
  1190. turnId: 'turn-1',
  1191. availableDecisions: ['accept'],
  1192. },
  1193. },
  1194. {
  1195. id: 'malformed-approval',
  1196. method: 'item/fileChange/requestApproval',
  1197. params: {
  1198. threadId: 'thread-1',
  1199. turnId: 'turn-1',
  1200. availableDecisions: 'decline',
  1201. },
  1202. },
  1203. {
  1204. id: 'thread',
  1205. method: 'item/fileChange/requestApproval',
  1206. params: { threadId: 'thread-2', turnId: 'turn-1' },
  1207. },
  1208. {
  1209. id: 'turn',
  1210. method: 'item/fileChange/requestApproval',
  1211. params: { threadId: 'thread-1', turnId: 'turn-2' },
  1212. },
  1213. ]) {
  1214. const { child, wire } = await initializeWire()
  1215. const result = wire.runTurn(['task'], new AbortController().signal)
  1216. const turnStart = await child.peer.nextMethod('turn/start')
  1217. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1218. await nextTask()
  1219. child.peer.send(serverRequest)
  1220. const response = await child.peer.nextResponse(serverRequest.id)
  1221. expect(response.error).toMatchObject({ code: -32603 })
  1222. await expect(result).rejects.toThrow()
  1223. wire.close()
  1224. }
  1225. })
  1226. it('rejects conflicting early turn identities before accepting output', async () => {
  1227. const { child, wire } = await initializeWire()
  1228. const result = wire.runTurn(['task'], new AbortController().signal)
  1229. const turnStart = await child.peer.nextMethod('turn/start')
  1230. child.peer.send({
  1231. method: 'turn/started',
  1232. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  1233. })
  1234. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1235. await expect(result).rejects.toThrow('did not match the active turn')
  1236. wire.close()
  1237. })
  1238. it('does not retain a diagnostic from a mismatched early item', async () => {
  1239. const { child, wire } = await initializeWire()
  1240. const result = wire.runTurn(['task'], new AbortController().signal)
  1241. const turnStart = await child.peer.nextMethod('turn/start')
  1242. child.peer.send({
  1243. method: 'item/completed',
  1244. params: {
  1245. threadId: 'thread-1',
  1246. turnId: 'turn-early',
  1247. item: { type: 'fileChange', status: 'declined' },
  1248. },
  1249. })
  1250. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1251. await expect(result).rejects.toThrow('did not match the active turn')
  1252. expect(wire.collectDiagnostic()).toBeUndefined()
  1253. wire.close()
  1254. })
  1255. it('does not retain a diagnostic from a mismatched provisional request', async () => {
  1256. const { child, wire } = await initializeWire()
  1257. const result = wire.runTurn(['task'], new AbortController().signal)
  1258. const turnStart = await child.peer.nextMethod('turn/start')
  1259. child.peer.send({
  1260. id: 'provisional-approval',
  1261. method: 'item/commandExecution/requestApproval',
  1262. params: {
  1263. threadId: 'thread-1',
  1264. turnId: 'turn-early',
  1265. availableDecisions: ['cancel'],
  1266. },
  1267. })
  1268. await child.peer.nextResponse('provisional-approval')
  1269. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1270. await expect(result).rejects.toThrow('did not match the active turn')
  1271. expect(wire.collectDiagnostic()).toBeUndefined()
  1272. wire.close()
  1273. })
  1274. it('rejects conflicting early notifications and requests before turn/start', async () => {
  1275. {
  1276. const { child, wire } = await initializeWire()
  1277. child.peer.send({
  1278. id: 'too-early',
  1279. method: 'item/fileChange/requestApproval',
  1280. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1281. })
  1282. const response = await child.peer.nextResponse('too-early')
  1283. expect(response.error).toMatchObject({ code: -32603 })
  1284. wire.close()
  1285. }
  1286. {
  1287. const { child, wire } = await initializeWire()
  1288. const result = wire.runTurn(['task'], new AbortController().signal)
  1289. await child.peer.nextMethod('turn/start')
  1290. child.peer.send(
  1291. {
  1292. method: 'turn/started',
  1293. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  1294. },
  1295. agentMessage('wrong', 'final_answer', 'turn-2'),
  1296. )
  1297. await expect(result).rejects.toThrow('conflicting turns')
  1298. wire.close()
  1299. }
  1300. })
  1301. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  1302. const { child, wire } = await initializeWire()
  1303. wire.interrupt()
  1304. const result = wire.runTurn(['task'], new AbortController().signal)
  1305. const turnStart = await child.peer.nextMethod('turn/start')
  1306. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1307. await nextTask()
  1308. wire.interrupt()
  1309. const interrupt = await child.peer.nextMethod('turn/interrupt')
  1310. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  1311. child.peer.send({
  1312. id: interrupt.id,
  1313. error: { code: -32000, message: 'already done' },
  1314. })
  1315. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1316. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1317. wire.close()
  1318. wire.interrupt()
  1319. })
  1320. it('ignores unrelated and out-of-window notifications', async () => {
  1321. const { child, wire } = await initializeWire()
  1322. child.peer.send(
  1323. {
  1324. method: 'turn/started',
  1325. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  1326. },
  1327. {
  1328. method: 'turn/started',
  1329. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  1330. },
  1331. agentMessage('before', 'final_answer'),
  1332. { method: 'future/notification', params: {} },
  1333. turnCompleted('completed'),
  1334. turnCompleted('completed', 'turn-other', 'thread-2'),
  1335. )
  1336. await nextTask()
  1337. const result = wire.runTurn(['task'], new AbortController().signal)
  1338. const turnStart = await child.peer.nextMethod('turn/start')
  1339. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1340. await nextTask()
  1341. child.peer.send(
  1342. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  1343. turnCompleted('completed', 'turn-2'),
  1344. agentMessage('answer', 'final_answer'),
  1345. turnCompleted('completed'),
  1346. )
  1347. await expect(result).resolves.toEqual({
  1348. output: [{ type: 'text', text: 'answer' }],
  1349. stopReason: 'completed',
  1350. })
  1351. wire.close()
  1352. })
  1353. it('rejects pending work on abort, EOF, and stream error', async () => {
  1354. {
  1355. const child = fakeChild()
  1356. const wire = defaultWire(child)
  1357. wire.start()
  1358. const controller = new AbortController()
  1359. controller.abort('pre-aborted')
  1360. await expect(wire.initialize(controller.signal))
  1361. .rejects.toThrow('app-server request aborted: pre-aborted')
  1362. wire.close()
  1363. }
  1364. {
  1365. const child = fakeChild()
  1366. const wire = defaultWire(child)
  1367. wire.start()
  1368. const controller = new AbortController()
  1369. const pending = wire.initialize(controller.signal)
  1370. await child.peer.nextMethod('initialize')
  1371. controller.abort(new Error('cancel initialize'))
  1372. await expect(pending).rejects.toThrow('cancel initialize')
  1373. wire.close()
  1374. }
  1375. {
  1376. const child = fakeChild()
  1377. const wire = defaultWire(child)
  1378. wire.start()
  1379. const pending = wire.initialize(new AbortController().signal)
  1380. await child.peer.nextMethod('initialize')
  1381. child.fromChild.end()
  1382. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  1383. wire.close()
  1384. }
  1385. {
  1386. const child = fakeChild()
  1387. const wire = defaultWire(child)
  1388. wire.start()
  1389. const pending = wire.initialize(new AbortController().signal)
  1390. await child.peer.nextMethod('initialize')
  1391. child.fromChild.emit('error', new Error('stdout broke'))
  1392. await expect(pending).rejects.toThrow('stdout broke')
  1393. wire.close()
  1394. }
  1395. {
  1396. const child = fakeChild()
  1397. const wire = defaultWire(child)
  1398. wire.start()
  1399. const pending = wire.initialize(new AbortController().signal)
  1400. await child.peer.nextMethod('initialize')
  1401. child.toChild.emit('error', new Error('stdin broke'))
  1402. await expect(pending).rejects.toThrow('stdin broke')
  1403. wire.close()
  1404. child.toChild.emit('error', new Error('late stdin close'))
  1405. }
  1406. })
  1407. })
  1408. describe('run lifecycle and quiescence', () => {
  1409. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  1410. const child = fakeChild()
  1411. const spawn = vi.fn(() => child.handle)
  1412. const starting = startCodexRun(
  1413. request([{ type: 'text', text: 'task' }]),
  1414. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  1415. )
  1416. let published = false
  1417. void starting.then(() => { published = true })
  1418. const initialize = await child.peer.nextMethod('initialize')
  1419. expect(published).toBe(false)
  1420. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  1421. await child.peer.nextMethod('initialized')
  1422. const threadStart = await child.peer.nextMethod('thread/start')
  1423. expect(published).toBe(false)
  1424. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1425. const run = await starting
  1426. expect(spawn).toHaveBeenCalledWith({
  1427. argv: codexAppServerArgv(),
  1428. cwd: process.cwd(),
  1429. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  1430. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  1431. env: { OPENAI_API_KEY: 'fake' },
  1432. })
  1433. expect(run.localAgent).toBeUndefined()
  1434. const turnStart = await child.peer.nextMethod('turn/start')
  1435. child.peer.send(
  1436. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  1437. agentMessage('answer', 'final_answer'),
  1438. turnCompleted('completed'),
  1439. )
  1440. await expect(run.result).resolves.toEqual({
  1441. output: [{ type: 'text', text: 'answer' }],
  1442. stopReason: 'completed',
  1443. })
  1444. const disposal = run.dispose()
  1445. expect(run.dispose()).toBe(disposal)
  1446. await disposal
  1447. await nextTask()
  1448. expect(child.terminate).toHaveBeenCalledTimes(1)
  1449. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1450. })
  1451. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  1452. const controller = new AbortController()
  1453. const { child, run, turnStart } = await publishRun(
  1454. fakeChild(),
  1455. controller.signal,
  1456. )
  1457. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1458. await nextTask()
  1459. controller.abort(new Error('stop'))
  1460. await expect(run.result).resolves.toEqual({
  1461. output: [],
  1462. stopReason: 'aborted',
  1463. })
  1464. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  1465. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1466. })
  1467. await run.dispose()
  1468. })
  1469. it('reports turn-start failures and omits captured facts after success', async () => {
  1470. {
  1471. const { child, run, turnStart } = await publishRun()
  1472. child.peer.respond(turnStart, { turn: { id: '' } })
  1473. await expect(run.result).resolves.toEqual({
  1474. output: [],
  1475. diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'),
  1476. stopReason: 'error',
  1477. })
  1478. await run.dispose()
  1479. }
  1480. {
  1481. const { child, run, turnStart } = await publishRun()
  1482. child.peer.send({
  1483. id: 'successful-approval',
  1484. method: 'item/commandExecution/requestApproval',
  1485. params: {
  1486. threadId: 'thread-1',
  1487. turnId: 'turn-1',
  1488. availableDecisions: ['cancel'],
  1489. },
  1490. })
  1491. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1492. await child.peer.nextResponse('successful-approval')
  1493. child.peer.send(
  1494. agentMessage('answer', 'final_answer'),
  1495. turnCompleted('completed'),
  1496. )
  1497. await expect(run.result).resolves.toEqual({
  1498. output: [{ type: 'text', text: 'answer' }],
  1499. stopReason: 'completed',
  1500. })
  1501. await run.dispose()
  1502. }
  1503. })
  1504. it('preserves representative terminal categories, HTTP status, and mapping', async () => {
  1505. const scenarios = [
  1506. ['contextWindowExceeded', 'limit', 'max-tokens', undefined],
  1507. ['sessionBudgetExceeded', 'limit', 'error', undefined],
  1508. ['unauthorized', 'access-policy', 'error', undefined],
  1509. ['internalServerError', 'service', 'error', undefined],
  1510. [{ httpConnectionFailed: { httpStatusCode: 503 } }, 'transport', 'error', 503],
  1511. [{ activeTurnNotSteerable: { turnKind: 'review' } }, 'product-error', 'error', undefined],
  1512. ['futureError', 'unknown', 'error', undefined],
  1513. ] as const
  1514. for (const [codexErrorInfo, category, stopReason, httpStatus] of scenarios) {
  1515. const { child, run, turnStart } = await publishRun()
  1516. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1517. child.peer.send(
  1518. agentMessage('partial answer', null),
  1519. turnCompleted('failed', 'turn-1', 'thread-1', {
  1520. message: 'SECRET_TOKEN in /private/secret.txt',
  1521. codexErrorInfo,
  1522. }),
  1523. )
  1524. const result = await run.result
  1525. expect(result).toEqual({
  1526. output: [{ type: 'text', text: 'partial answer' }],
  1527. diagnostic: expectedFailureDiagnostic('turn', category, {
  1528. ...(httpStatus === undefined ? {} : { httpStatus }),
  1529. }),
  1530. stopReason,
  1531. })
  1532. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1533. expect(result.diagnostic).not.toContain('/private/secret.txt')
  1534. expect(result.diagnostic).not.toContain('turnKind')
  1535. await run.dispose()
  1536. }
  1537. })
  1538. it('includes a structured permission fact in a max-token result', async () => {
  1539. const { child, run, turnStart } = await publishRun()
  1540. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1541. child.peer.send({
  1542. id: 'approval-before-limit',
  1543. method: 'item/commandExecution/requestApproval',
  1544. params: {
  1545. threadId: 'thread-1',
  1546. turnId: 'turn-1',
  1547. availableDecisions: ['cancel'],
  1548. },
  1549. })
  1550. await child.peer.nextResponse('approval-before-limit')
  1551. child.peer.send(
  1552. agentMessage('partial answer', null),
  1553. turnCompleted('failed', 'turn-1', 'thread-1', {
  1554. codexErrorInfo: 'contextWindowExceeded',
  1555. }),
  1556. )
  1557. child.settle({ exitCode: 17, signal: null })
  1558. await expect(run.result).resolves.toEqual({
  1559. output: [{ type: 'text', text: 'partial answer' }],
  1560. diagnostic: `${expectedFailureDiagnostic('turn', 'limit', { outcome: { exitCode: 17, signal: null } })}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1561. stopReason: 'max-tokens',
  1562. })
  1563. await run.dispose()
  1564. })
  1565. it('flattens child exit and protocol failures after publication', async () => {
  1566. const errors: string[] = []
  1567. const outcomes: SubprocessOutcome[] = [
  1568. { exitCode: 9, signal: null },
  1569. { exitCode: null, signal: 'SIGABRT' },
  1570. { exitCode: null, signal: null },
  1571. ]
  1572. for (const outcome of outcomes) {
  1573. const child = fakeChild({ exitOnTerminate: false })
  1574. const { run } = await publishRun(child, undefined, {
  1575. onError: (error) => { errors.push(error.message) },
  1576. })
  1577. child.settle(outcome)
  1578. await expect(run.result).resolves.toEqual({
  1579. output: [],
  1580. diagnostic: expectedFailureDiagnostic('process', 'process', {
  1581. outcome,
  1582. }),
  1583. stopReason: 'error',
  1584. })
  1585. expect(errors.at(-1)).toBe(
  1586. `subagent-codex: ${expectedFailureDiagnostic('process', 'process', { outcome })}`,
  1587. )
  1588. await run.dispose().catch(() => {})
  1589. }
  1590. {
  1591. const outcome = { exitCode: 17, signal: null } as const
  1592. const child = fakeChild({ exitOnTerminate: false })
  1593. const { run, turnStart } = await publishRun(child, undefined, {
  1594. disposeGraceMs: 0.5,
  1595. })
  1596. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1597. vi.spyOn(child.handle, 'waitForExit').mockImplementationOnce(async (signal?: AbortSignal) => {
  1598. expect(signal).toBeDefined()
  1599. child.settle(outcome)
  1600. return true
  1601. })
  1602. child.fromChild.emit('end')
  1603. await expect(run.result).resolves.toEqual({
  1604. output: [],
  1605. diagnostic: expectedFailureDiagnostic('process', 'process', {
  1606. outcome,
  1607. }),
  1608. stopReason: 'error',
  1609. })
  1610. await run.dispose().catch(() => {})
  1611. }
  1612. {
  1613. const child = fakeChild({ exitOnTerminate: false })
  1614. const { run, turnStart } = await publishRun(child)
  1615. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1616. setImmediate(() => {
  1617. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1618. codexErrorInfo: 'other',
  1619. }))
  1620. })
  1621. child.settle({ exitCode: 17, signal: null })
  1622. await expect(run.result).resolves.toEqual({
  1623. output: [],
  1624. diagnostic: expectedFailureDiagnostic('turn', 'product-error', {
  1625. outcome: { exitCode: 17, signal: null },
  1626. }),
  1627. stopReason: 'error',
  1628. })
  1629. await run.dispose().catch(() => {})
  1630. }
  1631. {
  1632. const child = fakeChild({ exitOnTerminate: false })
  1633. const { run, turnStart } = await publishRun(child)
  1634. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1635. child.peer.send(
  1636. agentMessage('answer', 'final_answer'),
  1637. turnCompleted('completed'),
  1638. )
  1639. child.fromChild.end()
  1640. child.settle({ exitCode: 17, signal: null })
  1641. await expect(run.result).resolves.toEqual({
  1642. output: [{ type: 'text', text: 'answer' }],
  1643. stopReason: 'completed',
  1644. })
  1645. await run.dispose().catch(() => {})
  1646. }
  1647. {
  1648. const child = fakeChild()
  1649. const { run, turnStart } = await publishRun(child, undefined, {
  1650. disposeGraceMs: 10,
  1651. onError: () => { throw new Error('diagnostic sink') },
  1652. })
  1653. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1654. child.fromChild.end()
  1655. await expect(run.result).resolves.toEqual({
  1656. output: [],
  1657. diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
  1658. stopReason: 'error',
  1659. })
  1660. await run.dispose()
  1661. }
  1662. {
  1663. const child = fakeChild()
  1664. const { run, turnStart } = await publishRun(child)
  1665. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1666. child.stderr.emit('error', new Error('stderr broke'))
  1667. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1668. await expect(run.result).resolves.toEqual({
  1669. output: [{ type: 'text', text: 'answer' }],
  1670. stopReason: 'completed',
  1671. })
  1672. await run.dispose()
  1673. expect(child.stderr.listenerCount('error')).toBe(0)
  1674. }
  1675. })
  1676. it('attaches a safe permission diagnostic when a published run fails', async () => {
  1677. const { child, run, turnStart } = await publishRun()
  1678. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1679. await nextTask()
  1680. child.peer.send({
  1681. id: 'approval-diagnostic',
  1682. method: 'item/commandExecution/requestApproval',
  1683. params: {
  1684. threadId: 'thread-1',
  1685. turnId: 'turn-1',
  1686. availableDecisions: ['cancel'],
  1687. command: 'cat /private/secret.txt',
  1688. },
  1689. })
  1690. expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
  1691. result: { decision: 'cancel' },
  1692. })
  1693. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1694. message: 'SECRET_TOKEN in /private/secret.txt',
  1695. codexErrorInfo: 'other',
  1696. }))
  1697. await expect(run.result).resolves.toEqual({
  1698. output: [],
  1699. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1700. stopReason: 'error',
  1701. })
  1702. await run.dispose()
  1703. })
  1704. it('drains queued stderr to the Host without classifying it', async () => {
  1705. hostStderrWrite.capture = true
  1706. hostStderrWrite.chunks.length = 0
  1707. const { child, run, turnStart } = await publishRun()
  1708. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1709. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1710. message: 'fixture terminal failure',
  1711. codexErrorInfo: 'badRequest',
  1712. }))
  1713. setImmediate(() => {
  1714. child.stderr.write('approval policy is Never; reject command')
  1715. })
  1716. await expect(run.result).resolves.toEqual({
  1717. output: [],
  1718. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1719. stopReason: 'error',
  1720. })
  1721. expect(Buffer.concat(hostStderrWrite.chunks).toString())
  1722. .toContain('approval policy is Never; reject command')
  1723. await run.dispose()
  1724. hostStderrWrite.capture = false
  1725. })
  1726. it('forwards stderr without copying or classifying it', async () => {
  1727. const child = fakeChild()
  1728. hostStderrWrite.capture = true
  1729. hostStderrWrite.chunks.length = 0
  1730. const { run, turnStart } = await publishRun(child)
  1731. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1732. child.stderr.write('SECRET_TOKEN approval policy is Ne')
  1733. child.stderr.write('ver; reject command — /private/secret.txt')
  1734. child.stderr.emit('data', 'string stderr suffix')
  1735. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1736. message: 'fixture terminal failure',
  1737. codexErrorInfo: 'badRequest',
  1738. }))
  1739. await expect(run.result).resolves.toEqual({
  1740. output: [],
  1741. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1742. stopReason: 'error',
  1743. })
  1744. expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
  1745. expect(hostStderrWrite.chunks).toHaveLength(3)
  1746. await run.dispose()
  1747. expect(child.stderr.listenerCount('data')).toBe(0)
  1748. hostStderrWrite.capture = false
  1749. })
  1750. it('contains host stderr write failures without changing run settlement', async () => {
  1751. const child = fakeChild()
  1752. hostStderrWrite.capture = true
  1753. hostStderrWrite.failNext = true
  1754. const { run, turnStart } = await publishRun(child)
  1755. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1756. child.stderr.write('approval policy is Never; reject command')
  1757. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1758. message: 'fixture terminal failure',
  1759. codexErrorInfo: 'badRequest',
  1760. }))
  1761. await expect(run.result).resolves.toEqual({
  1762. output: [],
  1763. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1764. stopReason: 'error',
  1765. })
  1766. await run.dispose()
  1767. hostStderrWrite.capture = false
  1768. })
  1769. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  1770. const controller = new AbortController()
  1771. controller.abort()
  1772. const spawn = vi.fn()
  1773. await expect(startCodexRun(
  1774. request(undefined, controller.signal),
  1775. {
  1776. cwd: process.cwd(),
  1777. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1778. env: {},
  1779. disposeGraceMs: 10,
  1780. spawn,
  1781. },
  1782. )).rejects.toThrow('aborted before app-server startup')
  1783. expect(spawn).not.toHaveBeenCalled()
  1784. const spawnFailure = startCodexRun(request(), {
  1785. cwd: process.cwd(),
  1786. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1787. env: {},
  1788. disposeGraceMs: 10,
  1789. spawn: () => { throw new Error('SECRET_TOKEN spawn failure') },
  1790. })
  1791. await expect(spawnFailure)
  1792. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1793. await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN')
  1794. const asyncSpawnFailureChild = fakeChild({
  1795. doneError: new Error('SECRET_TOKEN async spawn failure'),
  1796. })
  1797. const asyncSpawnFailure = startCodexRun(
  1798. request(),
  1799. runSpec(asyncSpawnFailureChild),
  1800. )
  1801. await expect(asyncSpawnFailure)
  1802. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1803. await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN')
  1804. expect(asyncSpawnFailureChild.terminate).toHaveBeenCalledOnce()
  1805. expect(asyncSpawnFailureChild.waitForExit).toHaveBeenCalledOnce()
  1806. const child = fakeChild()
  1807. const starting = startCodexRun(request(), runSpec(child))
  1808. const initialize = await child.peer.nextMethod('initialize')
  1809. child.peer.respond(initialize, null)
  1810. await expect(starting)
  1811. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1812. await expect(starting).rejects.not.toThrow('invalid initialize response')
  1813. expect(child.terminate).toHaveBeenCalledTimes(1)
  1814. const cleanupFailureChild = fakeChild({
  1815. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  1816. })
  1817. const cleanupFailure = startCodexRun(
  1818. request(),
  1819. runSpec(cleanupFailureChild),
  1820. )
  1821. const cleanupFailureInitialize = await cleanupFailureChild.peer
  1822. .nextMethod('initialize')
  1823. cleanupFailureChild.peer.respond(cleanupFailureInitialize, null)
  1824. const cleanupError: unknown = await cleanupFailure.then(
  1825. () => undefined,
  1826. (error: unknown) => error,
  1827. )
  1828. expect(cleanupError).toBeInstanceOf(AggregateError)
  1829. expect(String(cleanupError)).toContain(
  1830. expectedFailureDiagnostic('initialize', 'unknown'),
  1831. )
  1832. expect(String(cleanupError)).toContain(expectedFailureDiagnostic(
  1833. 'teardown',
  1834. 'unknown',
  1835. { outcome: { exitCode: 0, signal: null } },
  1836. ))
  1837. expect(String(cleanupError)).not.toContain('SECRET_TOKEN')
  1838. const cleanupRaceAbort = new AbortController()
  1839. const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
  1840. const cleanupRace = startCodexRun(
  1841. request(undefined, cleanupRaceAbort.signal),
  1842. runSpec(cleanupRaceChild),
  1843. )
  1844. const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize')
  1845. cleanupRaceChild.peer.respond(cleanupRaceInitialize, null)
  1846. await nextTask()
  1847. cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
  1848. cleanupRaceChild.settle()
  1849. await expect(cleanupRace)
  1850. .rejects.toThrow('aborted before run publication')
  1851. const threadChild = fakeChild()
  1852. const threadStarting = startCodexRun(request(), runSpec(threadChild))
  1853. const threadInitialize = await threadChild.peer.nextMethod('initialize')
  1854. threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.153.4' })
  1855. await threadChild.peer.nextMethod('initialized')
  1856. const invalidThread = await threadChild.peer.nextMethod('thread/start')
  1857. threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } })
  1858. await expect(threadStarting)
  1859. .rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown'))
  1860. await expect(threadStarting).rejects.not.toThrow('thread/start thread id')
  1861. const exitedThreadChild = fakeChild({ exitOnTerminate: false })
  1862. const exitedThreadStarting = startCodexRun(
  1863. request(),
  1864. runSpec(exitedThreadChild),
  1865. )
  1866. const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize')
  1867. exitedThreadChild.peer.respond(exitedThreadInitialize, {
  1868. userAgent: 'codex-cli 0.153.4',
  1869. })
  1870. await exitedThreadChild.peer.nextMethod('initialized')
  1871. await exitedThreadChild.peer.nextMethod('thread/start')
  1872. exitedThreadChild.settle({ exitCode: null, signal: 'SIGABRT' })
  1873. await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic(
  1874. 'thread-start',
  1875. 'unknown',
  1876. { outcome: { exitCode: null, signal: 'SIGABRT' } },
  1877. ))
  1878. const eofBeforeCloseChild = fakeChild({ exitOnTerminate: false })
  1879. const eofBeforeCloseStarting = startCodexRun(
  1880. request(),
  1881. runSpec(eofBeforeCloseChild),
  1882. )
  1883. const eofBeforeCloseInitialize = await eofBeforeCloseChild.peer
  1884. .nextMethod('initialize')
  1885. eofBeforeCloseChild.peer.respond(eofBeforeCloseInitialize, {
  1886. userAgent: 'codex-cli 0.153.4',
  1887. })
  1888. await eofBeforeCloseChild.peer.nextMethod('initialized')
  1889. await eofBeforeCloseChild.peer.nextMethod('thread/start')
  1890. eofBeforeCloseChild.fromChild.emit('end')
  1891. setImmediate(() => {
  1892. eofBeforeCloseChild.settle({ exitCode: 23, signal: null })
  1893. })
  1894. await expect(eofBeforeCloseStarting).rejects.toThrow(
  1895. expectedFailureDiagnostic('thread-start', 'unknown', {
  1896. outcome: { exitCode: 23, signal: null },
  1897. }),
  1898. )
  1899. const stderrChild = fakeChild()
  1900. const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
  1901. const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
  1902. stderrChild.stderr.emit('error', new Error('startup stderr broke'))
  1903. stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.153.4' })
  1904. await stderrChild.peer.nextMethod('initialized')
  1905. const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
  1906. stderrChild.peer.respond(stderrThreadStart, {
  1907. thread: { id: 'thread-1', ephemeral: true },
  1908. })
  1909. const stderrRun = await stderrStarting
  1910. const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
  1911. stderrChild.peer.send(
  1912. { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
  1913. agentMessage('answer', 'final_answer'),
  1914. turnCompleted('completed'),
  1915. )
  1916. await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
  1917. await stderrRun.dispose()
  1918. expect(stderrChild.stderr.listenerCount('error')).toBe(0)
  1919. })
  1920. it('rolls back an abort that wins immediately after thread creation', async () => {
  1921. const controller = new AbortController()
  1922. const child = fakeChild()
  1923. const starting = startCodexRun(
  1924. request(undefined, controller.signal),
  1925. runSpec(child),
  1926. )
  1927. const initialize = await child.peer.nextMethod('initialize')
  1928. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  1929. await child.peer.nextMethod('initialized')
  1930. const threadStart = await child.peer.nextMethod('thread/start')
  1931. expect(threadStart.params).toEqual({
  1932. cwd: process.cwd(),
  1933. ephemeral: true,
  1934. approvalPolicy: 'never',
  1935. })
  1936. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1937. controller.abort('startup race')
  1938. await expect(starting).rejects.toThrow('aborted before run publication')
  1939. expect(child.terminate).toHaveBeenCalledTimes(1)
  1940. })
  1941. it('keeps overlapping runs isolated', async () => {
  1942. const initialStderrListeners = {
  1943. error: process.stderr.listenerCount('error'),
  1944. unpipe: process.stderr.listenerCount('unpipe'),
  1945. close: process.stderr.listenerCount('close'),
  1946. finish: process.stderr.listenerCount('finish'),
  1947. }
  1948. const runs = await Promise.all(
  1949. Array.from({ length: 6 }, () => publishRun(fakeChild())),
  1950. )
  1951. expect({
  1952. error: process.stderr.listenerCount('error'),
  1953. unpipe: process.stderr.listenerCount('unpipe'),
  1954. close: process.stderr.listenerCount('close'),
  1955. finish: process.stderr.listenerCount('finish'),
  1956. }).toEqual(initialStderrListeners)
  1957. for (const [index, entry] of runs.entries()) {
  1958. const id = `turn-${index + 1}`
  1959. entry.child.peer.send(
  1960. { id: entry.turnStart.id, result: { turn: { id } } },
  1961. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  1962. turnCompleted('completed', id),
  1963. )
  1964. }
  1965. const results = await Promise.all(runs.map(entry => entry.run.result))
  1966. expect(results.map(result => result.output)).toEqual(
  1967. Array.from({ length: 6 }, (_, index) => [
  1968. { type: 'text', text: `answer-${index + 1}` },
  1969. ]),
  1970. )
  1971. expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
  1972. await Promise.all(runs.map(entry => entry.run.dispose()))
  1973. })
  1974. it('isolates permission modes and diagnostics across overlapping runs', async () => {
  1975. const first = await publishRun(fakeChild(), undefined, {
  1976. permissionMode: 'never',
  1977. })
  1978. const second = await publishRun(fakeChild(), undefined, {
  1979. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  1980. })
  1981. first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
  1982. second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
  1983. await nextTask()
  1984. first.child.peer.send({
  1985. id: 'never-approval',
  1986. method: 'item/commandExecution/requestApproval',
  1987. params: {
  1988. threadId: 'thread-1',
  1989. turnId: 'turn-never',
  1990. availableDecisions: ['cancel'],
  1991. },
  1992. })
  1993. second.child.peer.send({
  1994. id: 'bypass-elicitation',
  1995. method: 'mcpServer/elicitation/request',
  1996. params: { threadId: 'thread-1', turnId: null },
  1997. })
  1998. await Promise.all([
  1999. first.child.peer.nextResponse('never-approval'),
  2000. second.child.peer.nextResponse('bypass-elicitation'),
  2001. ])
  2002. first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
  2003. message: 'first failure',
  2004. codexErrorInfo: 'other',
  2005. }))
  2006. second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
  2007. message: 'second failure',
  2008. codexErrorInfo: 'other',
  2009. }))
  2010. await expect(first.run.result).resolves.toEqual({
  2011. output: [],
  2012. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  2013. stopReason: 'error',
  2014. })
  2015. await expect(second.run.result).resolves.toEqual({
  2016. output: [],
  2017. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`,
  2018. stopReason: 'error',
  2019. })
  2020. await Promise.all([first.run.dispose(), second.run.dispose()])
  2021. })
  2022. it('uses the registered provider config and logs flattened errors', async () => {
  2023. const ctx = new Context()
  2024. await ctx.plugin(SessionProjectionRegistry)
  2025. await ctx.plugin(SubagentRuntime)
  2026. await ctx.plugin(LocalSubprocessRuntime)
  2027. const child = fakeChild()
  2028. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  2029. const warnings: string[] = []
  2030. ctx.logger.warn = ((message: unknown) => {
  2031. warnings.push(String(message))
  2032. }) as typeof ctx.logger.warn
  2033. await ctx.plugin(codex, {
  2034. providerName: 'codex-diagnostic',
  2035. model: 'codex-diagnostic-model',
  2036. env: { OPENAI_API_KEY: 'fake' },
  2037. permissionMode: 'approve-for-me',
  2038. disposeGraceMs: 25,
  2039. })
  2040. const invalidCwdParent = {
  2041. id: 'parent-with-invalid-cwd',
  2042. session: { header: { cwd: 'relative/SECRET_TOKEN' } },
  2043. } as unknown as Agent
  2044. const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', {
  2045. prompt: [{ type: 'text', text: 'task' }],
  2046. parent: invalidCwdParent,
  2047. signal: new AbortController().signal,
  2048. }).then(
  2049. () => undefined,
  2050. (error: unknown) => error,
  2051. )
  2052. expect(invalidCwdError).toBeInstanceOf(Error)
  2053. if (!(invalidCwdError instanceof Error)) {
  2054. throw new Error('expected safe invalid-cwd failure')
  2055. }
  2056. expect(invalidCwdError.message).toContain(
  2057. expectedFailureDiagnostic('initialize', 'unknown'),
  2058. )
  2059. expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN')
  2060. expect(invalidCwdError.cause).toBeInstanceOf(Error)
  2061. expect((invalidCwdError.cause as Error).message)
  2062. .toContain('relative/SECRET_TOKEN')
  2063. expect(spawn).not.toHaveBeenCalled()
  2064. const invalidCwdAbort = new AbortController()
  2065. invalidCwdAbort.abort(new Error('cancel invalid cwd startup'))
  2066. await expect(ctx.subagents.start('codex-diagnostic', {
  2067. prompt: [{ type: 'text', text: 'task' }],
  2068. parent: invalidCwdParent,
  2069. signal: invalidCwdAbort.signal,
  2070. })).rejects.toThrow('aborted before app-server startup')
  2071. expect(spawn).not.toHaveBeenCalled()
  2072. const starting = ctx.subagents.start('codex-diagnostic', {
  2073. prompt: [{ type: 'text', text: 'task' }],
  2074. parent: fakeParent,
  2075. signal: new AbortController().signal,
  2076. })
  2077. const initialize = await child.peer.nextMethod('initialize')
  2078. child.peer.respond(initialize, { userAgent: 'codex-cli 0.153.4' })
  2079. await child.peer.nextMethod('initialized')
  2080. const threadStart = await child.peer.nextMethod('thread/start')
  2081. expect(threadStart.params).toEqual({
  2082. cwd: process.cwd(),
  2083. ephemeral: true,
  2084. model: 'codex-diagnostic-model',
  2085. approvalPolicy: 'on-request',
  2086. approvalsReviewer: 'auto_review',
  2087. sandbox: 'workspace-write',
  2088. })
  2089. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  2090. const run = await starting
  2091. const turnStart = await child.peer.nextMethod('turn/start')
  2092. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  2093. await nextTask()
  2094. child.peer.send({
  2095. id: 'provider-approval',
  2096. method: 'item/commandExecution/requestApproval',
  2097. params: {
  2098. threadId: 'thread-1',
  2099. turnId: 'turn-1',
  2100. availableDecisions: ['cancel'],
  2101. command: 'cat /private/secret.txt',
  2102. },
  2103. })
  2104. await child.peer.nextResponse('provider-approval')
  2105. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  2106. message: 'SECRET_TOKEN in /private/secret.txt',
  2107. codexErrorInfo: 'other',
  2108. }))
  2109. await expect(run.result).resolves.toEqual({
  2110. output: [],
  2111. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  2112. stopReason: 'error',
  2113. })
  2114. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  2115. env: { OPENAI_API_KEY: 'fake' },
  2116. graceMs: 25,
  2117. cwd: process.cwd(),
  2118. }))
  2119. expect(warnings).toEqual([
  2120. expect.stringContaining(
  2121. `subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'product-error')}`,
  2122. ),
  2123. ])
  2124. expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
  2125. expect(warnings.join('\n')).not.toContain('/private/secret.txt')
  2126. await run.dispose()
  2127. await ctx.fiber.dispose()
  2128. })
  2129. })
  2130. describe('disposeCodexChild', () => {
  2131. it('closes stdin, terminates, and waits for the managed range', async () => {
  2132. const child = fakeChild()
  2133. const wire = defaultWire(child)
  2134. const end = vi.spyOn(child.toChild, 'end')
  2135. await disposeCodexChild(wire, child.handle)
  2136. expect(end).toHaveBeenCalled()
  2137. expect(child.terminate).toHaveBeenCalledTimes(1)
  2138. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  2139. expect(child.waitForExit).toHaveBeenCalledWith()
  2140. })
  2141. it('does not finish disposal before the managed range is empty', async () => {
  2142. const child = fakeChild({ exitOnTerminate: false })
  2143. const wire = defaultWire(child)
  2144. let disposed = false
  2145. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  2146. disposed = true
  2147. })
  2148. await new Promise<void>((resolve) => { setImmediate(resolve) })
  2149. expect(disposed).toBe(false)
  2150. child.settle()
  2151. await disposal
  2152. expect(disposed).toBe(true)
  2153. })
  2154. it('contains a concurrently closed stdin error', async () => {
  2155. const child = fakeChild()
  2156. const wire = defaultWire(child)
  2157. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  2158. throw new Error('already closed')
  2159. })
  2160. await expect(disposeCodexChild(wire, child.handle))
  2161. .resolves.toBeUndefined()
  2162. })
  2163. it('still runs idempotent cleanup when target startup rejects', async () => {
  2164. const child = fakeChild({
  2165. doneError: new Error('spawn failed'),
  2166. })
  2167. const wire = defaultWire(child)
  2168. await expect(disposeCodexChild(wire, child.handle))
  2169. .resolves.toBeUndefined()
  2170. expect(child.terminate).toHaveBeenCalledOnce()
  2171. expect(child.waitForExit).toHaveBeenCalledOnce()
  2172. })
  2173. it('reports range-wait failure with safe teardown facts', async () => {
  2174. const child = fakeChild({
  2175. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  2176. })
  2177. const wire = defaultWire(child)
  2178. const disposal = disposeCodexChild(wire, child.handle)
  2179. await expect(disposal).rejects.toThrow(expectedFailureDiagnostic(
  2180. 'teardown',
  2181. 'unknown',
  2182. { outcome: { exitCode: 0, signal: null } },
  2183. ))
  2184. await expect(disposal).rejects.not.toThrow('SECRET_TOKEN')
  2185. })
  2186. it('does not wait for a pending process outcome after tree observation fails', async () => {
  2187. const child = fakeChild({
  2188. exitOnTerminate: false,
  2189. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  2190. })
  2191. const wire = defaultWire(child)
  2192. let disposalError: unknown
  2193. const disposal = disposeCodexChild(wire, child.handle).catch(
  2194. (error: unknown) => { disposalError = error },
  2195. )
  2196. await nextTask()
  2197. expect(disposalError).toBeInstanceOf(Error)
  2198. expect(String(disposalError)).toContain(
  2199. expectedFailureDiagnostic('teardown', 'unknown'),
  2200. )
  2201. expect(String(disposalError)).not.toContain('SECRET_TOKEN')
  2202. child.settle()
  2203. await disposal
  2204. })
  2205. })