subagent-codex.spec.ts 86 KB

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