subagent-codex.spec.ts 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834
  1. import { PassThrough } from 'node:stream'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  7. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  8. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  9. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  10. import type {
  11. SubprocessHandle,
  12. SubprocessOutcome,
  13. SubprocessSpawnSpec,
  14. } from '@deepseek-ai/dsh-subprocess'
  15. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  16. import * as codex from '../src/index.ts'
  17. import * as invariant from '../src/invariant.ts'
  18. import {
  19. CODEX_PERMISSION_MODES,
  20. DEFAULT_CODEX_PERMISSION_MODE,
  21. codexAppServerArgv,
  22. DEFAULT_DISPOSE_GRACE_MS,
  23. disposeCodexChild,
  24. startCodexRun,
  25. textTask,
  26. type CodexRunSpec,
  27. } from '../src/run.ts'
  28. import { CodexAppServerWire } from '../src/wire.ts'
  29. const { hostStderrWrite } = vi.hoisted(() => ({
  30. hostStderrWrite: {
  31. capture: false,
  32. failNext: false,
  33. chunks: [] as Buffer[],
  34. },
  35. }))
  36. vi.mock('node:fs', async (importOriginal) => {
  37. const actual = await importOriginal<typeof import('node:fs')>()
  38. return {
  39. ...actual,
  40. writeFileSync(
  41. fd: number,
  42. value: string | Uint8Array,
  43. ): void {
  44. if (fd === 2 && hostStderrWrite.capture) {
  45. if (hostStderrWrite.failNext) {
  46. hostStderrWrite.failNext = false
  47. throw Object.assign(new Error('host stderr broke'), { code: 'EIO' })
  48. }
  49. const bytes = typeof value === 'string'
  50. ? Buffer.from(value)
  51. : Buffer.from(value.buffer, value.byteOffset, value.byteLength)
  52. hostStderrWrite.chunks.push(bytes)
  53. return
  54. }
  55. actual.writeFileSync(fd, value)
  56. },
  57. }
  58. })
  59. type JsonObject = Record<string, unknown>
  60. const fakeParent = {
  61. id: 'parent',
  62. session: { header: { cwd: process.cwd() } },
  63. } as unknown as Agent
  64. function request(
  65. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  66. signal = new AbortController().signal,
  67. ) {
  68. return { prompt, parent: fakeParent, signal }
  69. }
  70. async function nextTask(): Promise<void> {
  71. await new Promise<void>((resolve) => { setImmediate(resolve) })
  72. }
  73. class ProtocolPeer {
  74. private buffer = ''
  75. private readonly frames: JsonObject[] = []
  76. private readonly wakeups = new Set<() => void>()
  77. constructor(
  78. input: PassThrough,
  79. private readonly output: PassThrough,
  80. ) {
  81. input.on('data', (chunk: Buffer | string) => {
  82. this.buffer += chunk.toString()
  83. for (;;) {
  84. const newline = this.buffer.indexOf('\n')
  85. if (newline < 0) break
  86. const line = this.buffer.slice(0, newline)
  87. this.buffer = this.buffer.slice(newline + 1)
  88. if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject)
  89. }
  90. for (const wake of this.wakeups) wake()
  91. this.wakeups.clear()
  92. })
  93. }
  94. async next(predicate: (frame: JsonObject) => boolean): Promise<JsonObject> {
  95. for (;;) {
  96. const index = this.frames.findIndex(predicate)
  97. if (index >= 0) return this.frames.splice(index, 1)[0]!
  98. await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
  99. }
  100. }
  101. nextMethod(method: string): Promise<JsonObject> {
  102. return this.next(frame => frame.method === method)
  103. }
  104. nextResponse(id: unknown): Promise<JsonObject> {
  105. return this.next(frame => frame.id === id && frame.method === undefined)
  106. }
  107. send(...frames: readonly JsonObject[]): void {
  108. this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
  109. }
  110. respond(requestFrame: JsonObject, result: unknown): void {
  111. this.send({ id: requestFrame.id, result })
  112. }
  113. }
  114. interface FakeChildOptions {
  115. readonly pid?: number
  116. readonly exitOnTerminate?: boolean
  117. readonly doneError?: Error
  118. }
  119. interface FakeChild {
  120. readonly handle: SubprocessHandle
  121. readonly peer: ProtocolPeer
  122. readonly fromChild: PassThrough
  123. readonly toChild: PassThrough
  124. readonly stderr: PassThrough
  125. readonly settle: (outcome?: SubprocessOutcome) => void
  126. readonly fail: (error: Error) => void
  127. readonly terminate: () => void
  128. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  129. }
  130. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  131. const fromChild = new PassThrough()
  132. const toChild = new PassThrough()
  133. const stderr = new PassThrough()
  134. const peer = new ProtocolPeer(toChild, fromChild)
  135. let exited = false
  136. let resolveDone!: (outcome: SubprocessOutcome) => void
  137. let rejectDone!: (error: Error) => void
  138. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  139. resolveDone = resolve
  140. rejectDone = reject
  141. })
  142. const settle = (
  143. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  144. ): void => {
  145. if (exited) return
  146. exited = true
  147. resolveDone(outcome)
  148. }
  149. const fail = (error: Error): void => {
  150. if (exited) return
  151. exited = true
  152. rejectDone(error)
  153. }
  154. if (options.doneError !== undefined) fail(options.doneError)
  155. const terminate = vi.fn(() => {
  156. if (options.exitOnTerminate !== false) settle()
  157. })
  158. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  159. if (exited) return true
  160. if (signal === undefined) {
  161. await done.catch(() => {})
  162. return true
  163. }
  164. return await new Promise<boolean>((resolve) => {
  165. const onAbort = (): void => { resolve(false) }
  166. signal.addEventListener('abort', onAbort, { once: true })
  167. void done.then(
  168. () => {
  169. signal.removeEventListener('abort', onAbort)
  170. resolve(true)
  171. },
  172. () => {
  173. signal.removeEventListener('abort', onAbort)
  174. resolve(true)
  175. },
  176. )
  177. })
  178. })
  179. const handle: SubprocessHandle = {
  180. pid: options.pid ?? 1234,
  181. stdin: toChild,
  182. stdout: fromChild,
  183. stderr,
  184. collected: {},
  185. done,
  186. terminate,
  187. waitForExit,
  188. }
  189. return {
  190. handle,
  191. peer,
  192. fromChild,
  193. toChild,
  194. stderr,
  195. settle,
  196. fail,
  197. terminate,
  198. waitForExit,
  199. }
  200. }
  201. function defaultWire(child: FakeChild): CodexAppServerWire {
  202. return new CodexAppServerWire(
  203. child.handle.stdout!,
  204. child.handle.stdin!,
  205. DEFAULT_CODEX_PERMISSION_MODE,
  206. )
  207. }
  208. function runSpec(
  209. child: FakeChild,
  210. overrides: Partial<CodexRunSpec> = {},
  211. ): CodexRunSpec {
  212. return {
  213. cwd: process.cwd(),
  214. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  215. env: {},
  216. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  217. spawn: () => child.handle,
  218. ...overrides,
  219. }
  220. }
  221. async function initializeWire(): Promise<{
  222. readonly child: FakeChild
  223. readonly wire: CodexAppServerWire
  224. }> {
  225. const child = fakeChild()
  226. const wire = defaultWire(child)
  227. wire.start()
  228. const initializing = wire.initialize(new AbortController().signal)
  229. const initialize = await child.peer.nextMethod('initialize')
  230. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  231. await initializing
  232. expect(await child.peer.nextMethod('initialized')).toEqual({
  233. jsonrpc: '2.0',
  234. method: 'initialized',
  235. })
  236. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  237. const threadStart = await child.peer.nextMethod('thread/start')
  238. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  239. await starting
  240. return { child, wire }
  241. }
  242. async function publishRun(
  243. child = fakeChild(),
  244. signal = new AbortController().signal,
  245. specOverrides: Partial<CodexRunSpec> = {},
  246. ) {
  247. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  248. const initialize = await child.peer.nextMethod('initialize')
  249. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  250. await child.peer.nextMethod('initialized')
  251. const threadStart = await child.peer.nextMethod('thread/start')
  252. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  253. const run = await starting
  254. const turnStart = await child.peer.nextMethod('turn/start')
  255. return { child, run, turnStart }
  256. }
  257. function agentMessage(
  258. text: unknown,
  259. phase: unknown,
  260. turnId = 'turn-1',
  261. threadId = 'thread-1',
  262. ): JsonObject {
  263. return {
  264. method: 'item/completed',
  265. params: {
  266. threadId,
  267. turnId,
  268. item: { type: 'agentMessage', text, phase },
  269. },
  270. }
  271. }
  272. function turnCompleted(
  273. status: unknown,
  274. turnId = 'turn-1',
  275. threadId = 'thread-1',
  276. error: unknown = null,
  277. ): JsonObject {
  278. return {
  279. method: 'turn/completed',
  280. params: {
  281. threadId,
  282. turn: { id: turnId, status, error },
  283. },
  284. }
  285. }
  286. describe('task admission and package contracts', () => {
  287. it('keeps the app-server command fixed on POSIX and Windows', () => {
  288. expect(codexAppServerArgv('linux')).toEqual([
  289. 'codex', 'app-server', '--stdio',
  290. ])
  291. expect(codexAppServerArgv('win32')).toEqual([
  292. 'cmd.exe',
  293. '/d',
  294. '/s',
  295. '/c',
  296. 'codex',
  297. 'app-server',
  298. '--stdio',
  299. ])
  300. })
  301. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  302. expect(textTask([
  303. { type: 'text', text: 'one' },
  304. { type: 'text', text: 'two' },
  305. ])).toEqual(['one', 'two'])
  306. expect(() => textTask([])).toThrow('only text blocks')
  307. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  308. .toThrow('only text blocks')
  309. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  310. .toThrow('must not be empty')
  311. })
  312. it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
  313. const ctx = new Context()
  314. await ctx.plugin(SubagentRuntime)
  315. await ctx.plugin(LocalSubprocessRuntime)
  316. const fiber = await ctx.plugin(codex, {})
  317. const provider = ctx.subagents.getProvider('codex')!
  318. expect(provider).toMatchObject({
  319. name: 'codex',
  320. capabilities: {
  321. outputSchema: false,
  322. depthLimit: false,
  323. toolFilter: false,
  324. persona: false,
  325. },
  326. inheritsParentContext: false,
  327. })
  328. expect(ctx.subagents.list()).toEqual(['codex'])
  329. await fiber.dispose()
  330. expect(ctx.subagents.list()).toEqual([])
  331. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  332. await expect(ctx.plugin(codex, { disposeGraceMs }))
  333. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  334. }
  335. await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
  336. .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  337. await ctx.fiber.dispose()
  338. })
  339. it('keeps named instances, runs, and HMR ownership isolated', async () => {
  340. const ctx = new Context()
  341. await ctx.plugin(SubagentRuntime)
  342. await ctx.plugin(LocalSubprocessRuntime)
  343. const safeChild = fakeChild()
  344. const bypassChild = fakeChild()
  345. const spawnSpecs: SubprocessSpawnSpec[] = []
  346. vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => {
  347. spawnSpecs.push(spec)
  348. return spec.env?.DSH_CODEX_INSTANCE === 'safe'
  349. ? safeChild.handle
  350. : bypassChild.handle
  351. })
  352. const added: string[] = []
  353. const started: string[] = []
  354. const ended: string[] = []
  355. const removed: string[] = []
  356. ctx.on('subagent/provider-added', provider => void added.push(provider.name))
  357. ctx.on('subagent/start', info => void started.push(info.provider))
  358. ctx.on('subagent/end', info => void ended.push(info.provider))
  359. ctx.on('subagent/provider-removed', providerName => void removed.push(providerName))
  360. const safeFiber = await ctx.plugin(codex, {
  361. providerName: 'codex-safe',
  362. env: { DSH_CODEX_INSTANCE: 'safe' },
  363. permissionMode: 'never',
  364. disposeGraceMs: 11,
  365. })
  366. const bypassFiber = await ctx.plugin(codex, {
  367. providerName: 'codex-bypass',
  368. env: { DSH_CODEX_INSTANCE: 'bypass' },
  369. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  370. disposeGraceMs: 29,
  371. })
  372. expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass'])
  373. expect(added).toEqual(['codex-safe', 'codex-bypass'])
  374. const safeController = new AbortController()
  375. const safeStarting = ctx.subagents.start(
  376. 'codex-safe',
  377. request(undefined, safeController.signal),
  378. )
  379. const bypassStarting = ctx.subagents.start('codex-bypass', request())
  380. for (const child of [safeChild, bypassChild]) {
  381. const initialize = await child.peer.nextMethod('initialize')
  382. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  383. await child.peer.nextMethod('initialized')
  384. const threadStart = await child.peer.nextMethod('thread/start')
  385. child.peer.respond(threadStart, {
  386. thread: { id: 'thread-1', ephemeral: true },
  387. })
  388. }
  389. const [safeRun, bypassRun] = await Promise.all([
  390. safeStarting,
  391. bypassStarting,
  392. ])
  393. await safeFiber.dispose()
  394. expect(ctx.subagents.list()).toEqual(['codex-bypass'])
  395. expect(removed).toEqual(['codex-safe'])
  396. await expect(ctx.subagents.start('codex-safe', request()))
  397. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  398. const safeTurn = await safeChild.peer.nextMethod('turn/start')
  399. const bypassTurn = await bypassChild.peer.nextMethod('turn/start')
  400. safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } })
  401. bypassChild.peer.send(
  402. { id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } },
  403. agentMessage('bypass answer', 'final_answer', 'turn-bypass'),
  404. turnCompleted('completed', 'turn-bypass'),
  405. )
  406. await expect(bypassRun.result).resolves.toEqual({
  407. output: [{ type: 'text', text: 'bypass answer' }],
  408. stopReason: 'completed',
  409. })
  410. safeController.abort(new Error('stop only the safe instance'))
  411. await expect(safeRun.result).resolves.toEqual({
  412. output: [],
  413. stopReason: 'aborted',
  414. })
  415. expect(spawnSpecs.map(spec => ({
  416. instance: spec.env?.DSH_CODEX_INSTANCE,
  417. graceMs: spec.graceMs,
  418. }))).toEqual([
  419. { instance: 'safe', graceMs: 11 },
  420. { instance: 'bypass', graceMs: 29 },
  421. ])
  422. await Promise.all([safeRun.dispose(), bypassRun.dispose()])
  423. expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe'])
  424. expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe'])
  425. expect(safeChild.terminate).toHaveBeenCalledOnce()
  426. expect(bypassChild.terminate).toHaveBeenCalledOnce()
  427. await bypassFiber.dispose()
  428. expect(removed).toEqual(['codex-safe', 'codex-bypass'])
  429. await ctx.fiber.dispose()
  430. })
  431. it('rejects duplicate provider names without replacing the first instance', async () => {
  432. const ctx = new Context()
  433. await ctx.plugin(SubagentRuntime)
  434. await ctx.plugin(LocalSubprocessRuntime)
  435. const firstFiber = await ctx.plugin(codex, {
  436. providerName: 'codex-duplicate',
  437. })
  438. const first = ctx.subagents.getProvider('codex-duplicate')
  439. await expect(ctx.plugin(codex, {
  440. providerName: 'codex-duplicate',
  441. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  442. })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
  443. expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first)
  444. expect(ctx.subagents.list()).toEqual(['codex-duplicate'])
  445. await firstFiber.dispose()
  446. await ctx.fiber.dispose()
  447. })
  448. it('accepts only the three fixed non-interactive permission modes', () => {
  449. expect(codex.Config({}).providerName).toBe('codex')
  450. expect(codex.Config({ providerName: 'codex-safe' }).providerName)
  451. .toBe('codex-safe')
  452. expect(() => codex.Config({ providerName: '' })).toThrow()
  453. expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
  454. for (const permissionMode of CODEX_PERMISSION_MODES) {
  455. expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
  456. }
  457. for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
  458. expect(() => codex.Config({ permissionMode } as never)).toThrow()
  459. }
  460. })
  461. it('resolves the safe permission default when apply is called directly', async () => {
  462. const ctx = new Context()
  463. await ctx.plugin(SubagentRuntime)
  464. await ctx.plugin(LocalSubprocessRuntime)
  465. codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  466. expect(ctx.subagents.getProvider('codex')).toBeDefined()
  467. await ctx.fiber.dispose()
  468. })
  469. it.each([
  470. ['never', { approvalPolicy: 'never' }],
  471. ['approve-for-me', {
  472. approvalPolicy: 'on-request',
  473. approvalsReviewer: 'auto_review',
  474. sandbox: 'workspace-write',
  475. }],
  476. ['dangerously-bypass-approvals-and-sandbox', {
  477. approvalPolicy: 'never',
  478. sandbox: 'danger-full-access',
  479. }],
  480. ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
  481. const child = fakeChild()
  482. const wire = new CodexAppServerWire(
  483. child.handle.stdout!,
  484. child.handle.stdin!,
  485. permissionMode,
  486. )
  487. wire.start()
  488. const initializing = wire.initialize(new AbortController().signal)
  489. const initialize = await child.peer.nextMethod('initialize')
  490. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  491. await initializing
  492. await child.peer.nextMethod('initialized')
  493. const starting = wire.startThread('/workspace', new AbortController().signal)
  494. const threadStart = await child.peer.nextMethod('thread/start')
  495. expect(threadStart.params).toEqual({
  496. cwd: '/workspace',
  497. ephemeral: true,
  498. ...expected,
  499. })
  500. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  501. await starting
  502. wire.close()
  503. })
  504. it('requires a parent session cwd without suggesting unsupported config', async () => {
  505. const ctx = new Context()
  506. await ctx.plugin(SubagentRuntime)
  507. await ctx.plugin(LocalSubprocessRuntime)
  508. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  509. await ctx.plugin(codex, {})
  510. await expect(ctx.subagents.start('codex', {
  511. prompt: [{ type: 'text', text: 'task' }],
  512. parent: {
  513. id: 'parent-without-cwd',
  514. session: { header: {} },
  515. } as unknown as Agent,
  516. signal: new AbortController().signal,
  517. })).rejects.toThrow(
  518. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  519. )
  520. expect(spawn).not.toHaveBeenCalled()
  521. await ctx.fiber.dispose()
  522. })
  523. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  524. expect('default' in codex).toBe(false)
  525. expect(codex.name).toBe('subagent-codex')
  526. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  527. const loader = Object.create(Loader.prototype) as Loader
  528. expect(loader.unwrapExports(codex)).toBe(codex)
  529. const dispose = vi.fn()
  530. const register = vi.fn((
  531. _packageName: string,
  532. _installer: InvariantInstaller,
  533. ) => dispose)
  534. const ctx = { invariants: { register } } as unknown as Context
  535. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  536. expect(register).toHaveBeenCalledWith(
  537. '@deepseek-ai/dsh-subagent-codex',
  538. expect.any(Function),
  539. )
  540. const install = register.mock.calls[0]![1]
  541. await install(new Context(), (message) => { throw new Error(message) })
  542. expect(invariant.name).toBe('subagent-codex-invariant')
  543. expect(invariant.inject).toEqual(['invariants'])
  544. })
  545. })
  546. describe('CodexAppServerWire', () => {
  547. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  548. const child = fakeChild()
  549. const wire = defaultWire(child)
  550. expect(wire.collectOutput()).toEqual([])
  551. wire.start()
  552. const initializing = wire.initialize(new AbortController().signal)
  553. const initialize = await child.peer.nextMethod('initialize')
  554. expect(initialize.params).toEqual({
  555. clientInfo: {
  556. name: 'deepseek-harness',
  557. title: 'DeepSeek Harness',
  558. version: '0.0.1',
  559. },
  560. capabilities: {
  561. experimentalApi: false,
  562. requestAttestation: false,
  563. },
  564. })
  565. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  566. await initializing
  567. await child.peer.nextMethod('initialized')
  568. const starting = wire.startThread('/workspace', new AbortController().signal)
  569. const threadStart = await child.peer.nextMethod('thread/start')
  570. expect(threadStart.params).toEqual({
  571. cwd: '/workspace',
  572. ephemeral: true,
  573. approvalPolicy: 'never',
  574. })
  575. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  576. await starting
  577. const result = wire.runTurn(
  578. ['first', 'second'],
  579. new AbortController().signal,
  580. )
  581. const turnStart = await child.peer.nextMethod('turn/start')
  582. expect(turnStart.params).toEqual({
  583. threadId: 'thread-1',
  584. input: [
  585. { type: 'text', text: 'first', text_elements: [] },
  586. { type: 'text', text: 'second', text_elements: [] },
  587. ],
  588. })
  589. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  590. await nextTask()
  591. child.peer.send(
  592. {
  593. method: 'turn/started',
  594. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  595. },
  596. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  597. agentMessage('other turn', 'final_answer', 'turn-2'),
  598. {
  599. method: 'item/completed',
  600. params: {
  601. threadId: 'thread-1',
  602. turnId: 'turn-1',
  603. item: { type: 'reasoning', text: 'not output' },
  604. },
  605. },
  606. agentMessage('commentary', 'commentary'),
  607. agentMessage('unphased', null),
  608. agentMessage('first final', 'final_answer'),
  609. agentMessage('last final', 'final_answer'),
  610. turnCompleted('completed'),
  611. )
  612. await expect(result).resolves.toEqual({
  613. output: [{ type: 'text', text: 'last final' }],
  614. stopReason: 'completed',
  615. })
  616. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  617. wire.close()
  618. wire.close()
  619. })
  620. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  621. const { child, wire } = await initializeWire()
  622. const result = wire.runTurn(['task'], new AbortController().signal)
  623. const turnStart = await child.peer.nextMethod('turn/start')
  624. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  625. child.peer.send(
  626. agentMessage('first', null),
  627. agentMessage('fallback', null),
  628. turnCompleted('completed'),
  629. )
  630. await expect(result).resolves.toEqual({
  631. output: [{ type: 'text', text: 'fallback' }],
  632. stopReason: 'completed',
  633. })
  634. wire.close()
  635. })
  636. it('maps only an explicit context-window failure to max-tokens', async () => {
  637. const { child, wire } = await initializeWire()
  638. const result = wire.runTurn(['task'], new AbortController().signal)
  639. const turnStart = await child.peer.nextMethod('turn/start')
  640. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  641. child.peer.send(
  642. agentMessage('partial answer', null),
  643. turnCompleted('failed', 'turn-1', 'thread-1', {
  644. message: 'too much context',
  645. codexErrorInfo: 'contextWindowExceeded',
  646. }),
  647. )
  648. await expect(result).resolves.toEqual({
  649. output: [{ type: 'text', text: 'partial answer' }],
  650. stopReason: 'max-tokens',
  651. })
  652. wire.close()
  653. })
  654. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  655. {
  656. const child = fakeChild()
  657. const wire = defaultWire(child)
  658. wire.start()
  659. const pending = wire.initialize(new AbortController().signal)
  660. const frame = await child.peer.nextMethod('initialize')
  661. child.peer.respond(frame, null)
  662. await expect(pending).rejects.toThrow('invalid initialize response')
  663. wire.close()
  664. }
  665. {
  666. const child = fakeChild()
  667. const wire = defaultWire(child)
  668. wire.start()
  669. const pending = wire.startThread('/workspace', new AbortController().signal)
  670. const frame = await child.peer.nextMethod('thread/start')
  671. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  672. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  673. wire.close()
  674. }
  675. {
  676. const { child, wire } = await initializeWire()
  677. const pending = wire.runTurn(['task'], new AbortController().signal)
  678. const frame = await child.peer.nextMethod('turn/start')
  679. child.peer.respond(frame, { turn: { id: '' } })
  680. await expect(pending).rejects.toThrow('turn/start turn id')
  681. wire.close()
  682. }
  683. })
  684. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  685. const scenarios: Array<{
  686. readonly frames: JsonObject[]
  687. readonly message: string
  688. }> = [
  689. {
  690. frames: [turnCompleted('completed')],
  691. message: 'without a final answer',
  692. },
  693. {
  694. frames: [
  695. agentMessage('fallback', null),
  696. agentMessage(' \n ', 'final_answer'),
  697. turnCompleted('completed'),
  698. ],
  699. message: 'without a final answer',
  700. },
  701. {
  702. frames: [agentMessage(42, 'final_answer')],
  703. message: 'invalid agent message',
  704. },
  705. {
  706. frames: [agentMessage('answer', 'future_phase')],
  707. message: 'unknown agent message phase',
  708. },
  709. {
  710. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  711. message: 'status failed',
  712. },
  713. {
  714. frames: [turnCompleted('interrupted')],
  715. message: 'status interrupted',
  716. },
  717. {
  718. frames: [turnCompleted('inProgress')],
  719. message: 'invalid terminal turn status',
  720. },
  721. ]
  722. for (const scenario of scenarios) {
  723. const { child, wire } = await initializeWire()
  724. const result = wire.runTurn(['task'], new AbortController().signal)
  725. const turnStart = await child.peer.nextMethod('turn/start')
  726. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  727. child.peer.send(...scenario.frames)
  728. await expect(result).rejects.toThrow(scenario.message)
  729. wire.close()
  730. }
  731. })
  732. it('fails closed when terminal notification params are not an object', async () => {
  733. const { child, wire } = await initializeWire()
  734. const result = wire.runTurn(['task'], new AbortController().signal)
  735. const turnStart = await child.peer.nextMethod('turn/start')
  736. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  737. child.peer.send({ method: 'turn/completed', params: null })
  738. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  739. wire.close()
  740. })
  741. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  742. const { child, wire } = await initializeWire()
  743. const result = wire.runTurn(['task'], new AbortController().signal)
  744. const turnStart = await child.peer.nextMethod('turn/start')
  745. child.peer.send(
  746. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  747. { id: 'future-request', method: 'future/request', params: {} },
  748. agentMessage('early answer', 'final_answer'),
  749. turnCompleted('completed'),
  750. )
  751. await expect(result).rejects.toThrow('unsupported app-server request')
  752. wire.close()
  753. })
  754. it('answers all five unattended request classes without granting authority', async () => {
  755. const { child, wire } = await initializeWire()
  756. const result = wire.runTurn(['task'], new AbortController().signal)
  757. const turnStart = await child.peer.nextMethod('turn/start')
  758. child.peer.send({
  759. id: 'command',
  760. method: 'item/commandExecution/requestApproval',
  761. params: {
  762. threadId: 'thread-1',
  763. turnId: 'turn-1',
  764. availableDecisions: ['decline', 'cancel'],
  765. command: 'cat /private/secret.txt',
  766. },
  767. })
  768. expect(await child.peer.nextResponse('command')).toMatchObject({
  769. result: { decision: 'cancel' },
  770. })
  771. expect(wire.collectDiagnostic()).toBeUndefined()
  772. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  773. await nextTask()
  774. expect(wire.collectDiagnostic()).toBe(
  775. 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  776. )
  777. const requests = [
  778. {
  779. id: 'command-decline',
  780. method: 'item/commandExecution/requestApproval',
  781. params: {
  782. threadId: 'thread-1',
  783. turnId: 'turn-1',
  784. availableDecisions: ['decline'],
  785. },
  786. result: { decision: 'decline' },
  787. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
  788. },
  789. {
  790. id: 'file',
  791. method: 'item/fileChange/requestApproval',
  792. params: {
  793. threadId: 'thread-1',
  794. turnId: 'turn-1',
  795. availableDecisions: ['decline'],
  796. },
  797. result: { decision: 'decline' },
  798. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  799. },
  800. {
  801. id: 'file-cancel',
  802. method: 'item/fileChange/requestApproval',
  803. params: {
  804. threadId: 'thread-1',
  805. turnId: 'turn-1',
  806. availableDecisions: ['cancel'],
  807. },
  808. result: { decision: 'cancel' },
  809. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval',
  810. },
  811. {
  812. id: 'file-default',
  813. method: 'item/fileChange/requestApproval',
  814. params: { threadId: 'thread-1', turnId: 'turn-1' },
  815. result: { decision: 'decline' },
  816. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  817. },
  818. {
  819. id: 'permissions',
  820. method: 'item/permissions/requestApproval',
  821. params: { threadId: 'thread-1', turnId: 'turn-1' },
  822. result: { permissions: {}, scope: 'turn' },
  823. diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
  824. },
  825. {
  826. id: 'user-input',
  827. method: 'item/tool/requestUserInput',
  828. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  829. result: { answers: {} },
  830. diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
  831. },
  832. {
  833. id: 'mcp',
  834. method: 'mcpServer/elicitation/request',
  835. params: { threadId: 'thread-1', turnId: null },
  836. result: { action: 'decline', content: null, _meta: null },
  837. diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  838. },
  839. ] as const
  840. for (const serverRequest of requests) {
  841. child.peer.send(serverRequest)
  842. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  843. result: serverRequest.result,
  844. })
  845. expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
  846. }
  847. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  848. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  849. await expect(result).resolves.toEqual({
  850. output: [{ type: 'text', text: 'answer' }],
  851. stopReason: 'completed',
  852. })
  853. wire.close()
  854. })
  855. it('records only a safe diagnostic for an explicit sandbox failure', async () => {
  856. const { child, wire } = await initializeWire()
  857. const result = wire.runTurn(['task'], new AbortController().signal)
  858. const turnStart = await child.peer.nextMethod('turn/start')
  859. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  860. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  861. message: 'failed at /private/secret.txt with SECRET_TOKEN',
  862. additionalDetails: 'raw command payload',
  863. codexErrorInfo: 'sandboxError',
  864. }))
  865. await expect(result).rejects.toThrow('status failed')
  866. expect(wire.collectDiagnostic()).toBe(
  867. 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
  868. )
  869. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  870. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  871. wire.close()
  872. })
  873. it('records declined command and file items without retaining their payloads', async () => {
  874. const { child, wire } = await initializeWire()
  875. const result = wire.runTurn(['task'], new AbortController().signal)
  876. const turnStart = await child.peer.nextMethod('turn/start')
  877. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  878. child.peer.send({
  879. method: 'item/completed',
  880. params: {
  881. threadId: 'thread-1',
  882. turnId: 'turn-1',
  883. item: {
  884. type: 'commandExecution',
  885. status: 'declined',
  886. command: 'cat /private/secret.txt',
  887. },
  888. },
  889. })
  890. await nextTask()
  891. expect(wire.collectDiagnostic()).toBe(
  892. 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
  893. )
  894. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  895. child.peer.send(
  896. {
  897. method: 'item/completed',
  898. params: {
  899. threadId: 'thread-1',
  900. turnId: 'turn-1',
  901. item: {
  902. type: 'fileChange',
  903. status: 'declined',
  904. patch: 'SECRET_TOKEN in /private/secret.txt',
  905. },
  906. },
  907. },
  908. turnCompleted('failed', 'turn-1', 'thread-1', {
  909. message: 'SECRET_TOKEN in /private/secret.txt',
  910. codexErrorInfo: 'other',
  911. }),
  912. )
  913. await expect(result).rejects.toThrow('status failed')
  914. expect(wire.collectDiagnostic()).toBe(
  915. 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode',
  916. )
  917. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  918. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  919. wire.close()
  920. })
  921. it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => {
  922. const first = fakeChild()
  923. const largeWire = new CodexAppServerWire(
  924. first.handle.stdout!,
  925. first.handle.stdin!,
  926. 'never',
  927. )
  928. largeWire.observeStderr(
  929. `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`,
  930. )
  931. expect(largeWire.collectDiagnostic()).toBe(
  932. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  933. )
  934. expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  935. const second = fakeChild()
  936. const splitWire = new CodexAppServerWire(
  937. second.handle.stdout!,
  938. second.handle.stdin!,
  939. 'never',
  940. )
  941. splitWire.observeStderr('SECRET_TOKEN approval policy is Ne')
  942. splitWire.observeStderr('ver; reject command — /private/secret.txt')
  943. expect(splitWire.collectDiagnostic()).toBe(
  944. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  945. )
  946. expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  947. expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  948. const third = fakeChild()
  949. const orderedWire = new CodexAppServerWire(
  950. third.handle.stdout!,
  951. third.handle.stdin!,
  952. 'dangerously-bypass-approvals-and-sandbox',
  953. )
  954. orderedWire.observeStderr(
  955. 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt',
  956. )
  957. expect(orderedWire.collectDiagnostic()).toBe(
  958. 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation',
  959. )
  960. expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  961. })
  962. it('does not reapply an old stderr signature after a newer request diagnostic', async () => {
  963. const { child, wire } = await initializeWire()
  964. wire.observeStderr('recorded sandbox violation:')
  965. const result = wire.runTurn(['task'], new AbortController().signal)
  966. const turnStart = await child.peer.nextMethod('turn/start')
  967. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  968. await nextTask()
  969. child.peer.send({
  970. id: 'file-approval',
  971. method: 'item/fileChange/requestApproval',
  972. params: {
  973. threadId: 'thread-1',
  974. turnId: 'turn-1',
  975. availableDecisions: ['decline'],
  976. },
  977. })
  978. await child.peer.nextResponse('file-approval')
  979. expect(wire.collectDiagnostic()).toContain('request: file approval')
  980. wire.observeStderr('later benign stderr')
  981. expect(wire.collectDiagnostic()).toContain('request: file approval')
  982. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  983. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  984. wire.close()
  985. })
  986. it('keeps a newer request diagnostic after replaying an older early item', async () => {
  987. const { child, wire } = await initializeWire()
  988. const result = wire.runTurn(['task'], new AbortController().signal)
  989. const turnStart = await child.peer.nextMethod('turn/start')
  990. child.peer.send({
  991. method: 'item/completed',
  992. params: {
  993. threadId: 'thread-1',
  994. turnId: 'turn-1',
  995. item: { type: 'fileChange', status: 'declined' },
  996. },
  997. })
  998. await nextTask()
  999. child.peer.send({
  1000. id: 'newer-command-request',
  1001. method: 'item/commandExecution/requestApproval',
  1002. params: {
  1003. threadId: 'thread-1',
  1004. turnId: 'turn-1',
  1005. availableDecisions: ['cancel'],
  1006. },
  1007. })
  1008. await child.peer.nextResponse('newer-command-request')
  1009. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1010. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1011. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1012. expect(wire.collectDiagnostic()).toContain('request: command approval')
  1013. wire.close()
  1014. })
  1015. it('keeps a newer stderr fact after replaying an older early terminal', async () => {
  1016. hostStderrWrite.capture = true
  1017. hostStderrWrite.chunks.length = 0
  1018. const { child, wire } = await initializeWire()
  1019. const result = wire.runTurn(['task'], new AbortController().signal)
  1020. const turnStart = await child.peer.nextMethod('turn/start')
  1021. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1022. message: 'sandbox failure',
  1023. codexErrorInfo: 'sandboxError',
  1024. }))
  1025. await nextTask()
  1026. wire.observeStderr('approval policy is Never; reject command')
  1027. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1028. await expect(result).rejects.toThrow('sandboxError')
  1029. expect(wire.collectDiagnostic()).toContain('request: command execution')
  1030. wire.close()
  1031. hostStderrWrite.capture = false
  1032. })
  1033. it('fails the run on unknown requests or wrong request association', async () => {
  1034. for (const serverRequest of [
  1035. {
  1036. id: 'unknown',
  1037. method: 'future/request',
  1038. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1039. },
  1040. {
  1041. id: 'approval',
  1042. method: 'item/commandExecution/requestApproval',
  1043. params: {
  1044. threadId: 'thread-1',
  1045. turnId: 'turn-1',
  1046. availableDecisions: ['accept'],
  1047. },
  1048. },
  1049. {
  1050. id: 'malformed-approval',
  1051. method: 'item/fileChange/requestApproval',
  1052. params: {
  1053. threadId: 'thread-1',
  1054. turnId: 'turn-1',
  1055. availableDecisions: 'decline',
  1056. },
  1057. },
  1058. {
  1059. id: 'thread',
  1060. method: 'item/fileChange/requestApproval',
  1061. params: { threadId: 'thread-2', turnId: 'turn-1' },
  1062. },
  1063. {
  1064. id: 'turn',
  1065. method: 'item/fileChange/requestApproval',
  1066. params: { threadId: 'thread-1', turnId: 'turn-2' },
  1067. },
  1068. ]) {
  1069. const { child, wire } = await initializeWire()
  1070. const result = wire.runTurn(['task'], new AbortController().signal)
  1071. const turnStart = await child.peer.nextMethod('turn/start')
  1072. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1073. await nextTask()
  1074. child.peer.send(serverRequest)
  1075. const response = await child.peer.nextResponse(serverRequest.id)
  1076. expect(response.error).toMatchObject({ code: -32603 })
  1077. await expect(result).rejects.toThrow()
  1078. wire.close()
  1079. }
  1080. })
  1081. it('rejects conflicting early turn identities before accepting output', async () => {
  1082. const { child, wire } = await initializeWire()
  1083. const result = wire.runTurn(['task'], new AbortController().signal)
  1084. const turnStart = await child.peer.nextMethod('turn/start')
  1085. child.peer.send({
  1086. method: 'turn/started',
  1087. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  1088. })
  1089. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1090. await expect(result).rejects.toThrow('did not match the active turn')
  1091. wire.close()
  1092. })
  1093. it('does not retain a diagnostic from a mismatched early item', async () => {
  1094. const { child, wire } = await initializeWire()
  1095. const result = wire.runTurn(['task'], new AbortController().signal)
  1096. const turnStart = await child.peer.nextMethod('turn/start')
  1097. child.peer.send({
  1098. method: 'item/completed',
  1099. params: {
  1100. threadId: 'thread-1',
  1101. turnId: 'turn-early',
  1102. item: { type: 'fileChange', status: 'declined' },
  1103. },
  1104. })
  1105. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1106. await expect(result).rejects.toThrow('did not match the active turn')
  1107. expect(wire.collectDiagnostic()).toBeUndefined()
  1108. wire.close()
  1109. })
  1110. it('does not retain a diagnostic from a mismatched provisional request', async () => {
  1111. const { child, wire } = await initializeWire()
  1112. const result = wire.runTurn(['task'], new AbortController().signal)
  1113. const turnStart = await child.peer.nextMethod('turn/start')
  1114. child.peer.send({
  1115. id: 'provisional-approval',
  1116. method: 'item/commandExecution/requestApproval',
  1117. params: {
  1118. threadId: 'thread-1',
  1119. turnId: 'turn-early',
  1120. availableDecisions: ['cancel'],
  1121. },
  1122. })
  1123. await child.peer.nextResponse('provisional-approval')
  1124. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1125. await expect(result).rejects.toThrow('did not match the active turn')
  1126. expect(wire.collectDiagnostic()).toBeUndefined()
  1127. wire.close()
  1128. })
  1129. it('rejects conflicting early notifications and requests before turn/start', async () => {
  1130. {
  1131. const { child, wire } = await initializeWire()
  1132. child.peer.send({
  1133. id: 'too-early',
  1134. method: 'item/fileChange/requestApproval',
  1135. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1136. })
  1137. const response = await child.peer.nextResponse('too-early')
  1138. expect(response.error).toMatchObject({ code: -32603 })
  1139. wire.close()
  1140. }
  1141. {
  1142. const { child, wire } = await initializeWire()
  1143. const result = wire.runTurn(['task'], new AbortController().signal)
  1144. await child.peer.nextMethod('turn/start')
  1145. child.peer.send(
  1146. {
  1147. method: 'turn/started',
  1148. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  1149. },
  1150. agentMessage('wrong', 'final_answer', 'turn-2'),
  1151. )
  1152. await expect(result).rejects.toThrow('conflicting turns')
  1153. wire.close()
  1154. }
  1155. })
  1156. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  1157. const { child, wire } = await initializeWire()
  1158. wire.interrupt()
  1159. const result = wire.runTurn(['task'], new AbortController().signal)
  1160. const turnStart = await child.peer.nextMethod('turn/start')
  1161. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1162. await nextTask()
  1163. wire.interrupt()
  1164. const interrupt = await child.peer.nextMethod('turn/interrupt')
  1165. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  1166. child.peer.send({
  1167. id: interrupt.id,
  1168. error: { code: -32000, message: 'already done' },
  1169. })
  1170. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1171. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1172. wire.close()
  1173. wire.interrupt()
  1174. })
  1175. it('ignores unrelated and out-of-window notifications', async () => {
  1176. const { child, wire } = await initializeWire()
  1177. child.peer.send(
  1178. {
  1179. method: 'turn/started',
  1180. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  1181. },
  1182. {
  1183. method: 'turn/started',
  1184. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  1185. },
  1186. agentMessage('before', 'final_answer'),
  1187. { method: 'future/notification', params: {} },
  1188. turnCompleted('completed'),
  1189. turnCompleted('completed', 'turn-other', 'thread-2'),
  1190. )
  1191. await nextTask()
  1192. const result = wire.runTurn(['task'], new AbortController().signal)
  1193. const turnStart = await child.peer.nextMethod('turn/start')
  1194. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1195. await nextTask()
  1196. child.peer.send(
  1197. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  1198. turnCompleted('completed', 'turn-2'),
  1199. agentMessage('answer', 'final_answer'),
  1200. turnCompleted('completed'),
  1201. )
  1202. await expect(result).resolves.toEqual({
  1203. output: [{ type: 'text', text: 'answer' }],
  1204. stopReason: 'completed',
  1205. })
  1206. wire.close()
  1207. })
  1208. it('rejects pending work on abort, EOF, and stream error', async () => {
  1209. {
  1210. const child = fakeChild()
  1211. const wire = defaultWire(child)
  1212. wire.start()
  1213. const controller = new AbortController()
  1214. controller.abort('pre-aborted')
  1215. await expect(wire.initialize(controller.signal))
  1216. .rejects.toThrow('app-server request aborted: pre-aborted')
  1217. wire.close()
  1218. }
  1219. {
  1220. const child = fakeChild()
  1221. const wire = defaultWire(child)
  1222. wire.start()
  1223. const controller = new AbortController()
  1224. const pending = wire.initialize(controller.signal)
  1225. await child.peer.nextMethod('initialize')
  1226. controller.abort(new Error('cancel initialize'))
  1227. await expect(pending).rejects.toThrow('cancel initialize')
  1228. wire.close()
  1229. }
  1230. {
  1231. const child = fakeChild()
  1232. const wire = defaultWire(child)
  1233. wire.start()
  1234. const pending = wire.initialize(new AbortController().signal)
  1235. await child.peer.nextMethod('initialize')
  1236. child.fromChild.end()
  1237. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  1238. wire.close()
  1239. }
  1240. {
  1241. const child = fakeChild()
  1242. const wire = defaultWire(child)
  1243. wire.start()
  1244. const pending = wire.initialize(new AbortController().signal)
  1245. await child.peer.nextMethod('initialize')
  1246. child.fromChild.emit('error', new Error('stdout broke'))
  1247. await expect(pending).rejects.toThrow('stdout broke')
  1248. wire.close()
  1249. }
  1250. {
  1251. const child = fakeChild()
  1252. const wire = defaultWire(child)
  1253. wire.start()
  1254. const pending = wire.initialize(new AbortController().signal)
  1255. await child.peer.nextMethod('initialize')
  1256. child.toChild.emit('error', new Error('stdin broke'))
  1257. await expect(pending).rejects.toThrow('stdin broke')
  1258. wire.close()
  1259. child.toChild.emit('error', new Error('late stdin close'))
  1260. }
  1261. })
  1262. })
  1263. describe('run lifecycle and quiescence', () => {
  1264. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  1265. const child = fakeChild()
  1266. const spawn = vi.fn(() => child.handle)
  1267. const starting = startCodexRun(
  1268. request([{ type: 'text', text: 'task' }]),
  1269. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  1270. )
  1271. let published = false
  1272. void starting.then(() => { published = true })
  1273. const initialize = await child.peer.nextMethod('initialize')
  1274. expect(published).toBe(false)
  1275. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1276. await child.peer.nextMethod('initialized')
  1277. const threadStart = await child.peer.nextMethod('thread/start')
  1278. expect(published).toBe(false)
  1279. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1280. const run = await starting
  1281. expect(spawn).toHaveBeenCalledWith({
  1282. argv: codexAppServerArgv(),
  1283. cwd: process.cwd(),
  1284. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  1285. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  1286. env: { OPENAI_API_KEY: 'fake' },
  1287. })
  1288. expect(run.localAgent).toBeUndefined()
  1289. const turnStart = await child.peer.nextMethod('turn/start')
  1290. child.peer.send(
  1291. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  1292. agentMessage('answer', 'final_answer'),
  1293. turnCompleted('completed'),
  1294. )
  1295. await expect(run.result).resolves.toEqual({
  1296. output: [{ type: 'text', text: 'answer' }],
  1297. stopReason: 'completed',
  1298. })
  1299. const disposal = run.dispose()
  1300. expect(run.dispose()).toBe(disposal)
  1301. await disposal
  1302. await nextTask()
  1303. expect(child.terminate).toHaveBeenCalledTimes(1)
  1304. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1305. })
  1306. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  1307. const controller = new AbortController()
  1308. const { child, run, turnStart } = await publishRun(
  1309. fakeChild(),
  1310. controller.signal,
  1311. )
  1312. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1313. await nextTask()
  1314. controller.abort(new Error('stop'))
  1315. await expect(run.result).resolves.toEqual({
  1316. output: [],
  1317. stopReason: 'aborted',
  1318. })
  1319. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  1320. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1321. })
  1322. await run.dispose()
  1323. })
  1324. it('flattens child exit and protocol failures after publication', async () => {
  1325. const errors: string[] = []
  1326. {
  1327. const child = fakeChild({ exitOnTerminate: false })
  1328. const { run } = await publishRun(child, undefined, {
  1329. onError: (error) => { errors.push(error.message) },
  1330. })
  1331. child.settle({ exitCode: 9, signal: null })
  1332. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  1333. expect(errors.at(-1)).toContain('code 9')
  1334. await run.dispose().catch(() => {})
  1335. }
  1336. {
  1337. const child = fakeChild()
  1338. const { run, turnStart } = await publishRun(child, undefined, {
  1339. onError: () => { throw new Error('diagnostic sink') },
  1340. })
  1341. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1342. child.fromChild.end()
  1343. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  1344. await run.dispose()
  1345. }
  1346. {
  1347. const child = fakeChild()
  1348. const { run, turnStart } = await publishRun(child)
  1349. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1350. child.stderr.emit('error', new Error('stderr broke'))
  1351. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1352. await expect(run.result).resolves.toEqual({
  1353. output: [{ type: 'text', text: 'answer' }],
  1354. stopReason: 'completed',
  1355. })
  1356. await run.dispose()
  1357. expect(child.stderr.listenerCount('error')).toBe(0)
  1358. }
  1359. })
  1360. it('attaches a safe permission diagnostic when a published run fails', async () => {
  1361. const { child, run, turnStart } = await publishRun()
  1362. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1363. await nextTask()
  1364. child.peer.send({
  1365. id: 'approval-diagnostic',
  1366. method: 'item/commandExecution/requestApproval',
  1367. params: {
  1368. threadId: 'thread-1',
  1369. turnId: 'turn-1',
  1370. availableDecisions: ['cancel'],
  1371. command: 'cat /private/secret.txt',
  1372. },
  1373. })
  1374. expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
  1375. result: { decision: 'cancel' },
  1376. })
  1377. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1378. message: 'SECRET_TOKEN in /private/secret.txt',
  1379. codexErrorInfo: 'other',
  1380. }))
  1381. await expect(run.result).resolves.toEqual({
  1382. output: [],
  1383. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1384. stopReason: 'error',
  1385. })
  1386. await run.dispose()
  1387. })
  1388. it('drains queued stderr before settling a failed published run', async () => {
  1389. hostStderrWrite.capture = true
  1390. hostStderrWrite.chunks.length = 0
  1391. const { child, run, turnStart } = await publishRun()
  1392. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1393. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1394. message: 'fixture terminal failure',
  1395. codexErrorInfo: 'badRequest',
  1396. }))
  1397. setImmediate(() => {
  1398. child.stderr.write('approval policy is Never; reject command')
  1399. })
  1400. await expect(run.result).resolves.toEqual({
  1401. output: [],
  1402. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1403. stopReason: 'error',
  1404. })
  1405. await run.dispose()
  1406. hostStderrWrite.capture = false
  1407. })
  1408. it('forwards stderr while extracting only a fixed safe permission signature', async () => {
  1409. const child = fakeChild()
  1410. hostStderrWrite.capture = true
  1411. hostStderrWrite.chunks.length = 0
  1412. const { run, turnStart } = await publishRun(child)
  1413. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1414. child.stderr.write('SECRET_TOKEN approval policy is Ne')
  1415. child.stderr.write('ver; reject command — /private/secret.txt')
  1416. child.stderr.emit('data', 'string stderr suffix')
  1417. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1418. message: 'fixture terminal failure',
  1419. codexErrorInfo: 'badRequest',
  1420. }))
  1421. await expect(run.result).resolves.toEqual({
  1422. output: [],
  1423. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1424. stopReason: 'error',
  1425. })
  1426. expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
  1427. expect(hostStderrWrite.chunks).toHaveLength(3)
  1428. await run.dispose()
  1429. expect(child.stderr.listenerCount('data')).toBe(0)
  1430. hostStderrWrite.capture = false
  1431. })
  1432. it('contains host stderr write failures without changing run settlement', async () => {
  1433. const child = fakeChild()
  1434. hostStderrWrite.capture = true
  1435. hostStderrWrite.failNext = true
  1436. const { run, turnStart } = await publishRun(child)
  1437. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1438. child.stderr.write('approval policy is Never; reject command')
  1439. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1440. message: 'fixture terminal failure',
  1441. codexErrorInfo: 'badRequest',
  1442. }))
  1443. await expect(run.result).resolves.toEqual({
  1444. output: [],
  1445. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1446. stopReason: 'error',
  1447. })
  1448. await run.dispose()
  1449. hostStderrWrite.capture = false
  1450. })
  1451. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  1452. const controller = new AbortController()
  1453. controller.abort()
  1454. const spawn = vi.fn()
  1455. await expect(startCodexRun(
  1456. request(undefined, controller.signal),
  1457. {
  1458. cwd: process.cwd(),
  1459. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1460. env: {},
  1461. disposeGraceMs: 10,
  1462. spawn,
  1463. },
  1464. )).rejects.toThrow('aborted before app-server startup')
  1465. expect(spawn).not.toHaveBeenCalled()
  1466. const child = fakeChild()
  1467. const starting = startCodexRun(request(), runSpec(child))
  1468. const initialize = await child.peer.nextMethod('initialize')
  1469. child.peer.respond(initialize, null)
  1470. await expect(starting).rejects.toThrow('invalid initialize response')
  1471. expect(child.terminate).toHaveBeenCalledTimes(1)
  1472. const stderrChild = fakeChild()
  1473. const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
  1474. const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
  1475. stderrChild.stderr.emit('error', new Error('startup stderr broke'))
  1476. stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' })
  1477. await stderrChild.peer.nextMethod('initialized')
  1478. const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
  1479. stderrChild.peer.respond(stderrThreadStart, {
  1480. thread: { id: 'thread-1', ephemeral: true },
  1481. })
  1482. const stderrRun = await stderrStarting
  1483. const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
  1484. stderrChild.peer.send(
  1485. { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
  1486. agentMessage('answer', 'final_answer'),
  1487. turnCompleted('completed'),
  1488. )
  1489. await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
  1490. await stderrRun.dispose()
  1491. expect(stderrChild.stderr.listenerCount('error')).toBe(0)
  1492. })
  1493. it('rolls back an abort that wins immediately after thread creation', async () => {
  1494. const controller = new AbortController()
  1495. const child = fakeChild()
  1496. const starting = startCodexRun(
  1497. request(undefined, controller.signal),
  1498. runSpec(child),
  1499. )
  1500. const initialize = await child.peer.nextMethod('initialize')
  1501. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1502. await child.peer.nextMethod('initialized')
  1503. const threadStart = await child.peer.nextMethod('thread/start')
  1504. expect(threadStart.params).toEqual({
  1505. cwd: process.cwd(),
  1506. ephemeral: true,
  1507. approvalPolicy: 'never',
  1508. })
  1509. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1510. controller.abort('startup race')
  1511. await expect(starting).rejects.toThrow('aborted before run publication')
  1512. expect(child.terminate).toHaveBeenCalledTimes(1)
  1513. })
  1514. it('rolls back a subprocess done rejection during startup', async () => {
  1515. const child = fakeChild({ doneError: new Error('spawn observer failed') })
  1516. const error: unknown = await startCodexRun(request(), runSpec(child)).then(
  1517. () => undefined,
  1518. (failure: unknown) => failure,
  1519. )
  1520. expect(error).toBeInstanceOf(AggregateError)
  1521. if (!(error instanceof AggregateError)) {
  1522. throw new Error('expected startup and rollback failures')
  1523. }
  1524. expect(error.errors).toEqual([
  1525. expect.objectContaining({ message: 'spawn observer failed' }),
  1526. expect.objectContaining({ message: 'spawn observer failed' }),
  1527. ])
  1528. expect(child.terminate).toHaveBeenCalledTimes(1)
  1529. })
  1530. it('keeps overlapping runs isolated', async () => {
  1531. const initialStderrListeners = {
  1532. error: process.stderr.listenerCount('error'),
  1533. unpipe: process.stderr.listenerCount('unpipe'),
  1534. close: process.stderr.listenerCount('close'),
  1535. finish: process.stderr.listenerCount('finish'),
  1536. }
  1537. const runs = await Promise.all(
  1538. Array.from({ length: 6 }, () => publishRun(fakeChild())),
  1539. )
  1540. expect({
  1541. error: process.stderr.listenerCount('error'),
  1542. unpipe: process.stderr.listenerCount('unpipe'),
  1543. close: process.stderr.listenerCount('close'),
  1544. finish: process.stderr.listenerCount('finish'),
  1545. }).toEqual(initialStderrListeners)
  1546. for (const [index, entry] of runs.entries()) {
  1547. const id = `turn-${index + 1}`
  1548. entry.child.peer.send(
  1549. { id: entry.turnStart.id, result: { turn: { id } } },
  1550. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  1551. turnCompleted('completed', id),
  1552. )
  1553. }
  1554. const results = await Promise.all(runs.map(entry => entry.run.result))
  1555. expect(results.map(result => result.output)).toEqual(
  1556. Array.from({ length: 6 }, (_, index) => [
  1557. { type: 'text', text: `answer-${index + 1}` },
  1558. ]),
  1559. )
  1560. expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
  1561. await Promise.all(runs.map(entry => entry.run.dispose()))
  1562. })
  1563. it('isolates permission modes and diagnostics across overlapping runs', async () => {
  1564. const first = await publishRun(fakeChild(), undefined, {
  1565. permissionMode: 'never',
  1566. })
  1567. const second = await publishRun(fakeChild(), undefined, {
  1568. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  1569. })
  1570. first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
  1571. second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
  1572. await nextTask()
  1573. first.child.peer.send({
  1574. id: 'never-approval',
  1575. method: 'item/commandExecution/requestApproval',
  1576. params: {
  1577. threadId: 'thread-1',
  1578. turnId: 'turn-never',
  1579. availableDecisions: ['cancel'],
  1580. },
  1581. })
  1582. second.child.peer.send({
  1583. id: 'bypass-elicitation',
  1584. method: 'mcpServer/elicitation/request',
  1585. params: { threadId: 'thread-1', turnId: null },
  1586. })
  1587. await Promise.all([
  1588. first.child.peer.nextResponse('never-approval'),
  1589. second.child.peer.nextResponse('bypass-elicitation'),
  1590. ])
  1591. first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
  1592. message: 'first failure',
  1593. codexErrorInfo: 'other',
  1594. }))
  1595. second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
  1596. message: 'second failure',
  1597. codexErrorInfo: 'other',
  1598. }))
  1599. await expect(first.run.result).resolves.toEqual({
  1600. output: [],
  1601. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1602. stopReason: 'error',
  1603. })
  1604. await expect(second.run.result).resolves.toEqual({
  1605. output: [],
  1606. diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  1607. stopReason: 'error',
  1608. })
  1609. await Promise.all([first.run.dispose(), second.run.dispose()])
  1610. })
  1611. it('uses the registered provider config and logs flattened errors', async () => {
  1612. const ctx = new Context()
  1613. await ctx.plugin(SubagentRuntime)
  1614. await ctx.plugin(LocalSubprocessRuntime)
  1615. const child = fakeChild()
  1616. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  1617. const warnings: string[] = []
  1618. ctx.logger.warn = ((message: unknown) => {
  1619. warnings.push(String(message))
  1620. }) as typeof ctx.logger.warn
  1621. await ctx.plugin(codex, {
  1622. providerName: 'codex-diagnostic',
  1623. env: { OPENAI_API_KEY: 'fake' },
  1624. permissionMode: 'approve-for-me',
  1625. disposeGraceMs: 25,
  1626. })
  1627. const starting = ctx.subagents.start('codex-diagnostic', {
  1628. prompt: [{ type: 'text', text: 'task' }],
  1629. parent: fakeParent,
  1630. signal: new AbortController().signal,
  1631. })
  1632. const initialize = await child.peer.nextMethod('initialize')
  1633. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1634. await child.peer.nextMethod('initialized')
  1635. const threadStart = await child.peer.nextMethod('thread/start')
  1636. expect(threadStart.params).toEqual({
  1637. cwd: process.cwd(),
  1638. ephemeral: true,
  1639. approvalPolicy: 'on-request',
  1640. approvalsReviewer: 'auto_review',
  1641. sandbox: 'workspace-write',
  1642. })
  1643. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1644. const run = await starting
  1645. const turnStart = await child.peer.nextMethod('turn/start')
  1646. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1647. await nextTask()
  1648. child.peer.send({
  1649. id: 'provider-approval',
  1650. method: 'item/commandExecution/requestApproval',
  1651. params: {
  1652. threadId: 'thread-1',
  1653. turnId: 'turn-1',
  1654. availableDecisions: ['cancel'],
  1655. command: 'cat /private/secret.txt',
  1656. },
  1657. })
  1658. await child.peer.nextResponse('provider-approval')
  1659. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1660. message: 'SECRET_TOKEN in /private/secret.txt',
  1661. codexErrorInfo: 'other',
  1662. }))
  1663. await expect(run.result).resolves.toEqual({
  1664. output: [],
  1665. diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1666. stopReason: 'error',
  1667. })
  1668. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  1669. argv: ['codex', 'app-server', '--stdio'],
  1670. env: { OPENAI_API_KEY: 'fake' },
  1671. graceMs: 25,
  1672. cwd: process.cwd(),
  1673. }))
  1674. expect(warnings).toEqual([
  1675. expect.stringContaining('subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
  1676. ])
  1677. expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
  1678. expect(warnings.join('\n')).not.toContain('/private/secret.txt')
  1679. await run.dispose()
  1680. await ctx.fiber.dispose()
  1681. })
  1682. })
  1683. describe('disposeCodexChild', () => {
  1684. it('closes stdin, terminates, and waits for the managed tree', async () => {
  1685. const child = fakeChild()
  1686. const wire = defaultWire(child)
  1687. const end = vi.spyOn(child.toChild, 'end')
  1688. await disposeCodexChild(wire, child.handle)
  1689. expect(end).toHaveBeenCalled()
  1690. expect(child.terminate).toHaveBeenCalledTimes(1)
  1691. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1692. expect(child.waitForExit).toHaveBeenCalledWith()
  1693. })
  1694. it('does not finish disposal before the managed tree exits', async () => {
  1695. const child = fakeChild({ exitOnTerminate: false })
  1696. const wire = defaultWire(child)
  1697. let disposed = false
  1698. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  1699. disposed = true
  1700. })
  1701. await new Promise<void>((resolve) => { setImmediate(resolve) })
  1702. expect(disposed).toBe(false)
  1703. child.settle()
  1704. await disposal
  1705. expect(disposed).toBe(true)
  1706. })
  1707. it('contains a concurrently closed stdin error', async () => {
  1708. const child = fakeChild()
  1709. const wire = defaultWire(child)
  1710. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  1711. throw new Error('already closed')
  1712. })
  1713. await expect(disposeCodexChild(wire, child.handle))
  1714. .resolves.toBeUndefined()
  1715. })
  1716. it('handles a spawn-level failure with no process tree', async () => {
  1717. const child = fakeChild({
  1718. pid: -1,
  1719. doneError: new Error('spawn failed'),
  1720. })
  1721. const wire = defaultWire(child)
  1722. await expect(disposeCodexChild(wire, child.handle))
  1723. .resolves.toBeUndefined()
  1724. expect(child.terminate).not.toHaveBeenCalled()
  1725. expect(child.waitForExit).not.toHaveBeenCalled()
  1726. })
  1727. it('reports direct-child observer failure and accepts absent stdin', async () => {
  1728. {
  1729. const child = fakeChild({
  1730. doneError: new Error('close observer failed'),
  1731. })
  1732. const wire = defaultWire(child)
  1733. await expect(disposeCodexChild(wire, child.handle))
  1734. .rejects.toThrow('close observer failed')
  1735. }
  1736. {
  1737. const child = fakeChild()
  1738. const handle = { ...child.handle, stdin: undefined }
  1739. const wire = defaultWire(child)
  1740. await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
  1741. }
  1742. })
  1743. })