1
0

subagent-codex.spec.ts 86 KB

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