subagent-codex.spec.ts 85 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324
  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.149.1'
  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.149.1' })
  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.149.1' })
  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. model: 'codex-safe-model',
  451. env: { DSH_CODEX_INSTANCE: 'safe' },
  452. permissionMode: 'never',
  453. disposeGraceMs: 11,
  454. })
  455. const bypassFiber = await ctx.plugin(codex, {
  456. providerName: 'codex-bypass',
  457. model: 'codex-bypass-model',
  458. env: { DSH_CODEX_INSTANCE: 'bypass' },
  459. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  460. disposeGraceMs: 29,
  461. })
  462. expect(ctx.subagents.list()).toEqual(['codex-safe', 'codex-bypass'])
  463. expect(added).toEqual(['codex-safe', 'codex-bypass'])
  464. const safeController = new AbortController()
  465. const safeStarting = ctx.subagents.start(
  466. 'codex-safe',
  467. request(undefined, safeController.signal),
  468. )
  469. const bypassStarting = ctx.subagents.start('codex-bypass', request())
  470. for (const [child, model] of [
  471. [safeChild, 'codex-safe-model'],
  472. [bypassChild, 'codex-bypass-model'],
  473. ] as const) {
  474. const initialize = await child.peer.nextMethod('initialize')
  475. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  476. await child.peer.nextMethod('initialized')
  477. const threadStart = await child.peer.nextMethod('thread/start')
  478. expect(threadStart.params).toMatchObject({ model })
  479. child.peer.respond(threadStart, {
  480. thread: { id: 'thread-1', ephemeral: true },
  481. })
  482. }
  483. const [safeRun, bypassRun] = await Promise.all([
  484. safeStarting,
  485. bypassStarting,
  486. ])
  487. await safeFiber.dispose()
  488. expect(ctx.subagents.list()).toEqual(['codex-bypass'])
  489. expect(removed).toEqual(['codex-safe'])
  490. await expect(ctx.subagents.start('codex-safe', request()))
  491. .rejects.toMatchObject({ code: 'NO_PROVIDER' })
  492. const safeTurn = await safeChild.peer.nextMethod('turn/start')
  493. const bypassTurn = await bypassChild.peer.nextMethod('turn/start')
  494. safeChild.peer.respond(safeTurn, { turn: { id: 'turn-safe' } })
  495. bypassChild.peer.send(
  496. { id: bypassTurn.id, result: { turn: { id: 'turn-bypass' } } },
  497. agentMessage('bypass answer', 'final_answer', 'turn-bypass'),
  498. turnCompleted('completed', 'turn-bypass'),
  499. )
  500. await expect(bypassRun.result).resolves.toEqual({
  501. output: [{ type: 'text', text: 'bypass answer' }],
  502. stopReason: 'completed',
  503. })
  504. safeController.abort(new Error('stop only the safe instance'))
  505. await expect(safeRun.result).resolves.toEqual({
  506. output: [],
  507. stopReason: 'aborted',
  508. })
  509. expect(spawnSpecs.map(spec => ({
  510. instance: spec.env?.DSH_CODEX_INSTANCE,
  511. graceMs: spec.graceMs,
  512. }))).toEqual([
  513. { instance: 'safe', graceMs: 11 },
  514. { instance: 'bypass', graceMs: 29 },
  515. ])
  516. await Promise.all([safeRun.dispose(), bypassRun.dispose()])
  517. expect([...started].sort()).toEqual(['codex-bypass', 'codex-safe'])
  518. expect([...ended].sort()).toEqual(['codex-bypass', 'codex-safe'])
  519. expect(safeChild.terminate).toHaveBeenCalledOnce()
  520. expect(bypassChild.terminate).toHaveBeenCalledOnce()
  521. await bypassFiber.dispose()
  522. expect(removed).toEqual(['codex-safe', 'codex-bypass'])
  523. await ctx.fiber.dispose()
  524. })
  525. it('rejects duplicate provider names without replacing the first instance', async () => {
  526. const ctx = new Context()
  527. await ctx.plugin(SubagentRuntime)
  528. await ctx.plugin(LocalSubprocessRuntime)
  529. const firstFiber = await ctx.plugin(codex, {
  530. providerName: 'codex-duplicate',
  531. })
  532. const first = ctx.subagents.getProvider('codex-duplicate')
  533. await expect(ctx.plugin(codex, {
  534. providerName: 'codex-duplicate',
  535. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  536. })).rejects.toMatchObject({ code: 'DUPLICATE_PROVIDER' })
  537. expect(ctx.subagents.getProvider('codex-duplicate')).toBe(first)
  538. expect(ctx.subagents.list()).toEqual(['codex-duplicate'])
  539. await firstFiber.dispose()
  540. await ctx.fiber.dispose()
  541. })
  542. it('accepts an optional non-empty model and the three fixed permission modes', () => {
  543. expect(codex.Config({}).providerName).toBe('codex')
  544. expect(codex.Config({}).model).toBeUndefined()
  545. expect(codex.Config({ providerName: 'codex-safe' }).providerName)
  546. .toBe('codex-safe')
  547. expect(() => codex.Config({ providerName: '' })).toThrow()
  548. expect(codex.Config({ model: 'gpt-codex' }).model).toBe('gpt-codex')
  549. expect(() => codex.Config({ model: '' })).toThrow()
  550. expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
  551. for (const permissionMode of CODEX_PERMISSION_MODES) {
  552. expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
  553. }
  554. for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
  555. expect(() => codex.Config({ permissionMode } as never)).toThrow()
  556. }
  557. })
  558. it('resolves the safe permission default when apply is called directly', async () => {
  559. const ctx = new Context()
  560. await ctx.plugin(SubagentRuntime)
  561. await ctx.plugin(LocalSubprocessRuntime)
  562. const child = fakeChild()
  563. vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  564. codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  565. expect(ctx.subagents.getProvider('codex')).toBeDefined()
  566. const starting = ctx.subagents.start('codex', request())
  567. const initialize = await child.peer.nextMethod('initialize')
  568. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  569. await child.peer.nextMethod('initialized')
  570. const threadStart = await child.peer.nextMethod('thread/start')
  571. expect(threadStart.params).not.toHaveProperty('model')
  572. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  573. const run = await starting
  574. const turnStart = await child.peer.nextMethod('turn/start')
  575. child.peer.send(
  576. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  577. agentMessage('native model answer', 'final_answer'),
  578. turnCompleted('completed'),
  579. )
  580. await expect(run.result).resolves.toEqual({
  581. output: [{ type: 'text', text: 'native model answer' }],
  582. stopReason: 'completed',
  583. })
  584. await run.dispose()
  585. await ctx.fiber.dispose()
  586. })
  587. it.each([
  588. ['never', { approvalPolicy: 'never' }],
  589. ['approve-for-me', {
  590. approvalPolicy: 'on-request',
  591. approvalsReviewer: 'auto_review',
  592. sandbox: 'workspace-write',
  593. }],
  594. ['dangerously-bypass-approvals-and-sandbox', {
  595. approvalPolicy: 'never',
  596. sandbox: 'danger-full-access',
  597. }],
  598. ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
  599. const child = fakeChild()
  600. const wire = new CodexAppServerWire(
  601. child.handle.stdout!,
  602. child.handle.stdin!,
  603. permissionMode,
  604. )
  605. wire.start()
  606. const initializing = wire.initialize(new AbortController().signal)
  607. const initialize = await child.peer.nextMethod('initialize')
  608. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  609. await initializing
  610. await child.peer.nextMethod('initialized')
  611. const starting = wire.startThread('/workspace', new AbortController().signal)
  612. const threadStart = await child.peer.nextMethod('thread/start')
  613. expect(threadStart.params).toEqual({
  614. cwd: '/workspace',
  615. ephemeral: true,
  616. ...expected,
  617. })
  618. expect(threadStart.params).not.toHaveProperty('model')
  619. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  620. await starting
  621. wire.close()
  622. })
  623. it('sends an explicit model on each ephemeral thread', async () => {
  624. const child = fakeChild()
  625. const wire = new CodexAppServerWire(
  626. child.handle.stdout!,
  627. child.handle.stdin!,
  628. 'never',
  629. 'codex-explicit-model',
  630. )
  631. wire.start()
  632. const initializing = wire.initialize(new AbortController().signal)
  633. const initialize = await child.peer.nextMethod('initialize')
  634. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  635. await initializing
  636. await child.peer.nextMethod('initialized')
  637. const starting = wire.startThread('/workspace', new AbortController().signal)
  638. const threadStart = await child.peer.nextMethod('thread/start')
  639. expect(threadStart.params).toEqual({
  640. cwd: '/workspace',
  641. ephemeral: true,
  642. model: 'codex-explicit-model',
  643. approvalPolicy: 'never',
  644. })
  645. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  646. await starting
  647. wire.close()
  648. })
  649. it('requires a parent session cwd without suggesting unsupported config', async () => {
  650. const ctx = new Context()
  651. await ctx.plugin(SubagentRuntime)
  652. await ctx.plugin(LocalSubprocessRuntime)
  653. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  654. await ctx.plugin(codex, {})
  655. await expect(ctx.subagents.start('codex', {
  656. prompt: [{ type: 'text', text: 'task' }],
  657. parent: {
  658. id: 'parent-without-cwd',
  659. session: { header: {} },
  660. } as unknown as Agent,
  661. signal: new AbortController().signal,
  662. })).rejects.toThrow(
  663. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  664. )
  665. expect(spawn).not.toHaveBeenCalled()
  666. await ctx.fiber.dispose()
  667. })
  668. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  669. expect('default' in codex).toBe(false)
  670. expect(codex.name).toBe('subagent-codex')
  671. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  672. const loader = Object.create(Loader.prototype) as Loader
  673. expect(loader.unwrapExports(codex)).toBe(codex)
  674. const dispose = vi.fn()
  675. const register = vi.fn((
  676. _packageName: string,
  677. _installer: InvariantInstaller,
  678. ) => dispose)
  679. const ctx = { invariants: { register } } as unknown as Context
  680. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  681. expect(register).toHaveBeenCalledWith(
  682. '@deepseek-ai/dsh-subagent-codex',
  683. expect.any(Function),
  684. )
  685. const install = register.mock.calls[0]![1]
  686. await install(new Context(), (message) => { throw new Error(message) })
  687. expect(invariant.name).toBe('subagent-codex-invariant')
  688. expect(invariant.inject).toEqual(['invariants'])
  689. })
  690. })
  691. describe('CodexAppServerWire', () => {
  692. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  693. const child = fakeChild()
  694. const wire = defaultWire(child)
  695. expect(wire.collectOutput()).toEqual([])
  696. wire.start()
  697. const initializing = wire.initialize(new AbortController().signal)
  698. const initialize = await child.peer.nextMethod('initialize')
  699. expect(initialize.params).toEqual({
  700. clientInfo: {
  701. name: 'deepseek-harness',
  702. title: 'DeepSeek Harness',
  703. version: '0.0.1',
  704. },
  705. capabilities: {
  706. experimentalApi: false,
  707. requestAttestation: false,
  708. },
  709. })
  710. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  711. await initializing
  712. await child.peer.nextMethod('initialized')
  713. const starting = wire.startThread('/workspace', new AbortController().signal)
  714. const threadStart = await child.peer.nextMethod('thread/start')
  715. expect(threadStart.params).toEqual({
  716. cwd: '/workspace',
  717. ephemeral: true,
  718. approvalPolicy: 'never',
  719. })
  720. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  721. await starting
  722. const result = wire.runTurn(
  723. ['first', 'second'],
  724. new AbortController().signal,
  725. )
  726. const turnStart = await child.peer.nextMethod('turn/start')
  727. expect(turnStart.params).toEqual({
  728. threadId: 'thread-1',
  729. input: [
  730. { type: 'text', text: 'first', text_elements: [] },
  731. { type: 'text', text: 'second', text_elements: [] },
  732. ],
  733. })
  734. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  735. await nextTask()
  736. child.peer.send(
  737. {
  738. method: 'turn/started',
  739. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  740. },
  741. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  742. agentMessage('other turn', 'final_answer', 'turn-2'),
  743. {
  744. method: 'item/completed',
  745. params: {
  746. threadId: 'thread-1',
  747. turnId: 'turn-1',
  748. item: { type: 'reasoning', text: 'not output' },
  749. },
  750. },
  751. agentMessage('commentary', 'commentary'),
  752. agentMessage('unphased', null),
  753. agentMessage('first final', 'final_answer'),
  754. agentMessage('last final', 'final_answer'),
  755. turnCompleted('completed'),
  756. )
  757. await expect(result).resolves.toEqual({
  758. output: [{ type: 'text', text: 'last final' }],
  759. stopReason: 'completed',
  760. })
  761. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  762. wire.close()
  763. wire.close()
  764. })
  765. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  766. const { child, wire } = await initializeWire()
  767. const result = wire.runTurn(['task'], new AbortController().signal)
  768. const turnStart = await child.peer.nextMethod('turn/start')
  769. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  770. child.peer.send(
  771. agentMessage('first', null),
  772. agentMessage('fallback', null),
  773. turnCompleted('completed'),
  774. )
  775. await expect(result).resolves.toEqual({
  776. output: [{ type: 'text', text: 'fallback' }],
  777. stopReason: 'completed',
  778. })
  779. wire.close()
  780. })
  781. it('groups representative string errors without changing stop reasons', async () => {
  782. const scenarios = [
  783. ['contextWindowExceeded', 'limit', 'max-tokens'],
  784. ['sessionBudgetExceeded', 'limit', 'error'],
  785. ['cyberPolicy', 'access-policy', 'error'],
  786. ['misalignmentPolicyViolation', 'access-policy', 'error'],
  787. ['serverOverloaded', 'service', 'error'],
  788. ['badRequest', 'product-error', 'error'],
  789. ['sandboxError', 'access-policy', 'error'],
  790. ] as const
  791. for (const [codexErrorInfo, category, stopReason] of scenarios) {
  792. const { child, wire } = await initializeWire()
  793. const result = wire.runTurn(['task'], new AbortController().signal)
  794. const turnStart = await child.peer.nextMethod('turn/start')
  795. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  796. child.peer.send(
  797. agentMessage('partial answer', null),
  798. turnCompleted('failed', 'turn-1', 'thread-1', {
  799. message: 'SECRET_TOKEN in /private/secret.txt',
  800. codexErrorInfo,
  801. }),
  802. )
  803. if (stopReason === 'max-tokens') {
  804. await expect(result).resolves.toEqual({
  805. output: [{ type: 'text', text: 'partial answer' }],
  806. stopReason: 'max-tokens',
  807. })
  808. } else {
  809. await expect(result).rejects.toThrow(`status failed: ${category}`)
  810. }
  811. expect(wire.collectFailure()).toEqual({
  812. stage: 'turn',
  813. category,
  814. })
  815. expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN')
  816. expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt')
  817. wire.close()
  818. }
  819. })
  820. it('groups object errors and retains only numeric HTTP status', async () => {
  821. const scenarios = [
  822. ['httpConnectionFailed', { httpStatusCode: 503 }, 'transport', 503],
  823. ['responseStreamDisconnected', {}, 'transport', undefined],
  824. ['responseTooManyFailedAttempts', { httpStatusCode: '503' }, 'transport', undefined],
  825. ['activeTurnNotSteerable', { turnKind: 'review' }, 'product-error', undefined],
  826. ] as const
  827. for (const [codexErrorInfo, detail, category, httpStatus] of scenarios) {
  828. const { child, wire } = await initializeWire()
  829. const result = wire.runTurn(['task'], new AbortController().signal)
  830. const turnStart = await child.peer.nextMethod('turn/start')
  831. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  832. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  833. message: 'SECRET_TOKEN in /private/secret.txt',
  834. codexErrorInfo: { [codexErrorInfo]: detail },
  835. }))
  836. await expect(result).rejects.toThrow(`status failed: ${category}`)
  837. expect(wire.collectFailure()).toEqual({
  838. stage: 'turn',
  839. category,
  840. ...(httpStatus === undefined ? {} : { httpStatus }),
  841. })
  842. expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind')
  843. wire.close()
  844. }
  845. })
  846. it('uses unknown for version-external or malformed error info', async () => {
  847. for (const codexErrorInfo of [
  848. 'futureError',
  849. { futureVariant: { message: 'SECRET_TOKEN' } },
  850. {
  851. httpConnectionFailed: { httpStatusCode: 503 },
  852. otherVariant: {},
  853. },
  854. { httpConnectionFailed: null },
  855. ]) {
  856. const { child, wire } = await initializeWire()
  857. const result = wire.runTurn(['task'], new AbortController().signal)
  858. const turnStart = await child.peer.nextMethod('turn/start')
  859. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  860. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  861. message: 'SECRET_TOKEN in /private/secret.txt',
  862. codexErrorInfo,
  863. }))
  864. await expect(result).rejects.toThrow('status failed: unknown')
  865. expect(wire.collectFailure()).toEqual({
  866. stage: 'turn',
  867. category: 'unknown',
  868. })
  869. wire.close()
  870. }
  871. })
  872. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  873. {
  874. const child = fakeChild()
  875. const wire = defaultWire(child)
  876. wire.start()
  877. const pending = wire.initialize(new AbortController().signal)
  878. const frame = await child.peer.nextMethod('initialize')
  879. child.peer.respond(frame, null)
  880. await expect(pending).rejects.toThrow('invalid initialize response')
  881. wire.close()
  882. }
  883. {
  884. const child = fakeChild()
  885. const wire = defaultWire(child)
  886. wire.start()
  887. const pending = wire.startThread('/workspace', new AbortController().signal)
  888. const frame = await child.peer.nextMethod('thread/start')
  889. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  890. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  891. wire.close()
  892. }
  893. {
  894. const { child, wire } = await initializeWire()
  895. const pending = wire.runTurn(['task'], new AbortController().signal)
  896. const frame = await child.peer.nextMethod('turn/start')
  897. child.peer.respond(frame, { turn: { id: '' } })
  898. await expect(pending).rejects.toThrow('turn/start turn id')
  899. expect(wire.collectFailure()).toEqual({
  900. stage: 'turn-start',
  901. category: 'unknown',
  902. })
  903. wire.close()
  904. }
  905. })
  906. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  907. const scenarios: Array<{
  908. readonly frames: JsonObject[]
  909. readonly message: string
  910. readonly category: 'invalid-result' | 'unknown'
  911. }> = [
  912. {
  913. frames: [turnCompleted('completed')],
  914. message: 'without a final answer',
  915. category: 'invalid-result',
  916. },
  917. {
  918. frames: [
  919. agentMessage('fallback', null),
  920. agentMessage(' \n ', 'final_answer'),
  921. turnCompleted('completed'),
  922. ],
  923. message: 'without a final answer',
  924. category: 'invalid-result',
  925. },
  926. {
  927. frames: [agentMessage(42, 'final_answer')],
  928. message: 'invalid agent message',
  929. category: 'unknown',
  930. },
  931. {
  932. frames: [agentMessage('answer', 'future_phase')],
  933. message: 'unknown agent message phase',
  934. category: 'unknown',
  935. },
  936. {
  937. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  938. message: 'status failed',
  939. category: 'unknown',
  940. },
  941. {
  942. frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')],
  943. message: 'status failed',
  944. category: 'unknown',
  945. },
  946. {
  947. frames: [turnCompleted('interrupted')],
  948. message: 'status interrupted',
  949. category: 'unknown',
  950. },
  951. {
  952. frames: [turnCompleted('inProgress')],
  953. message: 'invalid terminal turn status',
  954. category: 'unknown',
  955. },
  956. ]
  957. for (const scenario of scenarios) {
  958. const { child, wire } = await initializeWire()
  959. const result = wire.runTurn(['task'], new AbortController().signal)
  960. const turnStart = await child.peer.nextMethod('turn/start')
  961. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  962. await nextTask()
  963. child.peer.send(...scenario.frames)
  964. await expect(result).rejects.toThrow(scenario.message)
  965. expect(wire.collectFailure()).toEqual({
  966. stage: 'turn',
  967. category: scenario.category,
  968. })
  969. wire.close()
  970. }
  971. })
  972. it('fails closed when terminal notification params are not an object', async () => {
  973. const { child, wire } = await initializeWire()
  974. const result = wire.runTurn(['task'], new AbortController().signal)
  975. const turnStart = await child.peer.nextMethod('turn/start')
  976. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  977. child.peer.send({ method: 'turn/completed', params: null })
  978. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  979. wire.close()
  980. })
  981. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  982. const { child, wire } = await initializeWire()
  983. const result = wire.runTurn(['task'], new AbortController().signal)
  984. const turnStart = await child.peer.nextMethod('turn/start')
  985. child.peer.send(
  986. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  987. { id: 'future-request', method: 'future/request', params: {} },
  988. agentMessage('early answer', 'final_answer'),
  989. turnCompleted('completed'),
  990. )
  991. await expect(result).rejects.toThrow('unsupported app-server request')
  992. wire.close()
  993. })
  994. it('answers all five unattended request classes without granting authority', async () => {
  995. const { child, wire } = await initializeWire()
  996. const result = wire.runTurn(['task'], new AbortController().signal)
  997. const turnStart = await child.peer.nextMethod('turn/start')
  998. child.peer.send({
  999. id: 'command',
  1000. method: 'item/commandExecution/requestApproval',
  1001. params: {
  1002. threadId: 'thread-1',
  1003. turnId: 'turn-1',
  1004. availableDecisions: ['decline', 'cancel'],
  1005. command: 'cat /private/secret.txt',
  1006. },
  1007. })
  1008. expect(await child.peer.nextResponse('command')).toMatchObject({
  1009. result: { decision: 'cancel' },
  1010. })
  1011. expect(wire.collectDiagnostic()).toBeUndefined()
  1012. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1013. await nextTask()
  1014. expect(wire.collectDiagnostic()).toBe(
  1015. 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1016. )
  1017. const requests = [
  1018. {
  1019. id: 'command-decline',
  1020. method: 'item/commandExecution/requestApproval',
  1021. params: {
  1022. threadId: 'thread-1',
  1023. turnId: 'turn-1',
  1024. availableDecisions: ['decline'],
  1025. },
  1026. result: { decision: 'decline' },
  1027. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
  1028. },
  1029. {
  1030. id: 'file',
  1031. method: 'item/fileChange/requestApproval',
  1032. params: {
  1033. threadId: 'thread-1',
  1034. turnId: 'turn-1',
  1035. availableDecisions: ['decline'],
  1036. },
  1037. result: { decision: 'decline' },
  1038. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  1039. },
  1040. {
  1041. id: 'file-cancel',
  1042. method: 'item/fileChange/requestApproval',
  1043. params: {
  1044. threadId: 'thread-1',
  1045. turnId: 'turn-1',
  1046. availableDecisions: ['cancel'],
  1047. },
  1048. result: { decision: 'cancel' },
  1049. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval',
  1050. },
  1051. {
  1052. id: 'file-default',
  1053. method: 'item/fileChange/requestApproval',
  1054. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1055. result: { decision: 'decline' },
  1056. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  1057. },
  1058. {
  1059. id: 'permissions',
  1060. method: 'item/permissions/requestApproval',
  1061. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1062. result: { permissions: {}, scope: 'turn' },
  1063. diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
  1064. },
  1065. {
  1066. id: 'user-input',
  1067. method: 'item/tool/requestUserInput',
  1068. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  1069. result: { answers: {} },
  1070. diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
  1071. },
  1072. {
  1073. id: 'mcp',
  1074. method: 'mcpServer/elicitation/request',
  1075. params: { threadId: 'thread-1', turnId: null },
  1076. result: { action: 'decline', content: null, _meta: null },
  1077. diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  1078. },
  1079. ] as const
  1080. for (const serverRequest of requests) {
  1081. child.peer.send(serverRequest)
  1082. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  1083. result: serverRequest.result,
  1084. })
  1085. expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
  1086. }
  1087. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1088. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1089. await expect(result).resolves.toEqual({
  1090. output: [{ type: 'text', text: 'answer' }],
  1091. stopReason: 'completed',
  1092. })
  1093. wire.close()
  1094. })
  1095. it('records only a safe diagnostic for an explicit sandbox failure', async () => {
  1096. const { child, wire } = await initializeWire()
  1097. const result = wire.runTurn(['task'], new AbortController().signal)
  1098. const turnStart = await child.peer.nextMethod('turn/start')
  1099. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1100. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1101. message: 'failed at /private/secret.txt with SECRET_TOKEN',
  1102. additionalDetails: 'raw command payload',
  1103. codexErrorInfo: 'sandboxError',
  1104. }))
  1105. await expect(result).rejects.toThrow('status failed')
  1106. expect(wire.collectDiagnostic()).toBe(
  1107. 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
  1108. )
  1109. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  1110. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1111. wire.close()
  1112. })
  1113. it('records declined command and file items without retaining their payloads', async () => {
  1114. const { child, wire } = await initializeWire()
  1115. const result = wire.runTurn(['task'], new AbortController().signal)
  1116. const turnStart = await child.peer.nextMethod('turn/start')
  1117. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1118. child.peer.send({
  1119. method: 'item/completed',
  1120. params: {
  1121. threadId: 'thread-1',
  1122. turnId: 'turn-1',
  1123. item: {
  1124. type: 'commandExecution',
  1125. status: 'declined',
  1126. command: 'cat /private/secret.txt',
  1127. },
  1128. },
  1129. })
  1130. await nextTask()
  1131. expect(wire.collectDiagnostic()).toBe(
  1132. 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
  1133. )
  1134. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1135. child.peer.send(
  1136. {
  1137. method: 'item/completed',
  1138. params: {
  1139. threadId: 'thread-1',
  1140. turnId: 'turn-1',
  1141. item: {
  1142. type: 'fileChange',
  1143. status: 'declined',
  1144. patch: 'SECRET_TOKEN in /private/secret.txt',
  1145. },
  1146. },
  1147. },
  1148. turnCompleted('failed', 'turn-1', 'thread-1', {
  1149. message: 'SECRET_TOKEN in /private/secret.txt',
  1150. codexErrorInfo: 'other',
  1151. }),
  1152. )
  1153. await expect(result).rejects.toThrow('status failed')
  1154. expect(wire.collectDiagnostic()).toBe(
  1155. 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode',
  1156. )
  1157. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  1158. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  1159. wire.close()
  1160. })
  1161. it('keeps a newer request diagnostic after replaying an older early item', async () => {
  1162. const { child, wire } = await initializeWire()
  1163. const result = wire.runTurn(['task'], new AbortController().signal)
  1164. const turnStart = await child.peer.nextMethod('turn/start')
  1165. child.peer.send({
  1166. method: 'item/completed',
  1167. params: {
  1168. threadId: 'thread-1',
  1169. turnId: 'turn-1',
  1170. item: { type: 'fileChange', status: 'declined' },
  1171. },
  1172. })
  1173. await nextTask()
  1174. child.peer.send({
  1175. id: 'newer-command-request',
  1176. method: 'item/commandExecution/requestApproval',
  1177. params: {
  1178. threadId: 'thread-1',
  1179. turnId: 'turn-1',
  1180. availableDecisions: ['cancel'],
  1181. },
  1182. })
  1183. await child.peer.nextResponse('newer-command-request')
  1184. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1185. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1186. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1187. expect(wire.collectDiagnostic()).toContain('request: command approval')
  1188. wire.close()
  1189. })
  1190. it('fails the run on unknown requests or wrong request association', async () => {
  1191. for (const serverRequest of [
  1192. {
  1193. id: 'unknown',
  1194. method: 'future/request',
  1195. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1196. },
  1197. {
  1198. id: 'approval',
  1199. method: 'item/commandExecution/requestApproval',
  1200. params: {
  1201. threadId: 'thread-1',
  1202. turnId: 'turn-1',
  1203. availableDecisions: ['accept'],
  1204. },
  1205. },
  1206. {
  1207. id: 'malformed-approval',
  1208. method: 'item/fileChange/requestApproval',
  1209. params: {
  1210. threadId: 'thread-1',
  1211. turnId: 'turn-1',
  1212. availableDecisions: 'decline',
  1213. },
  1214. },
  1215. {
  1216. id: 'thread',
  1217. method: 'item/fileChange/requestApproval',
  1218. params: { threadId: 'thread-2', turnId: 'turn-1' },
  1219. },
  1220. {
  1221. id: 'turn',
  1222. method: 'item/fileChange/requestApproval',
  1223. params: { threadId: 'thread-1', turnId: 'turn-2' },
  1224. },
  1225. ]) {
  1226. const { child, wire } = await initializeWire()
  1227. const result = wire.runTurn(['task'], new AbortController().signal)
  1228. const turnStart = await child.peer.nextMethod('turn/start')
  1229. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1230. await nextTask()
  1231. child.peer.send(serverRequest)
  1232. const response = await child.peer.nextResponse(serverRequest.id)
  1233. expect(response.error).toMatchObject({ code: -32603 })
  1234. await expect(result).rejects.toThrow()
  1235. wire.close()
  1236. }
  1237. })
  1238. it('rejects conflicting early turn identities before accepting output', async () => {
  1239. const { child, wire } = await initializeWire()
  1240. const result = wire.runTurn(['task'], new AbortController().signal)
  1241. const turnStart = await child.peer.nextMethod('turn/start')
  1242. child.peer.send({
  1243. method: 'turn/started',
  1244. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  1245. })
  1246. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1247. await expect(result).rejects.toThrow('did not match the active turn')
  1248. wire.close()
  1249. })
  1250. it('does not retain a diagnostic from a mismatched early item', async () => {
  1251. const { child, wire } = await initializeWire()
  1252. const result = wire.runTurn(['task'], new AbortController().signal)
  1253. const turnStart = await child.peer.nextMethod('turn/start')
  1254. child.peer.send({
  1255. method: 'item/completed',
  1256. params: {
  1257. threadId: 'thread-1',
  1258. turnId: 'turn-early',
  1259. item: { type: 'fileChange', status: 'declined' },
  1260. },
  1261. })
  1262. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1263. await expect(result).rejects.toThrow('did not match the active turn')
  1264. expect(wire.collectDiagnostic()).toBeUndefined()
  1265. wire.close()
  1266. })
  1267. it('does not retain a diagnostic from a mismatched provisional request', async () => {
  1268. const { child, wire } = await initializeWire()
  1269. const result = wire.runTurn(['task'], new AbortController().signal)
  1270. const turnStart = await child.peer.nextMethod('turn/start')
  1271. child.peer.send({
  1272. id: 'provisional-approval',
  1273. method: 'item/commandExecution/requestApproval',
  1274. params: {
  1275. threadId: 'thread-1',
  1276. turnId: 'turn-early',
  1277. availableDecisions: ['cancel'],
  1278. },
  1279. })
  1280. await child.peer.nextResponse('provisional-approval')
  1281. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1282. await expect(result).rejects.toThrow('did not match the active turn')
  1283. expect(wire.collectDiagnostic()).toBeUndefined()
  1284. wire.close()
  1285. })
  1286. it('rejects conflicting early notifications and requests before turn/start', async () => {
  1287. {
  1288. const { child, wire } = await initializeWire()
  1289. child.peer.send({
  1290. id: 'too-early',
  1291. method: 'item/fileChange/requestApproval',
  1292. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1293. })
  1294. const response = await child.peer.nextResponse('too-early')
  1295. expect(response.error).toMatchObject({ code: -32603 })
  1296. wire.close()
  1297. }
  1298. {
  1299. const { child, wire } = await initializeWire()
  1300. const result = wire.runTurn(['task'], new AbortController().signal)
  1301. await child.peer.nextMethod('turn/start')
  1302. child.peer.send(
  1303. {
  1304. method: 'turn/started',
  1305. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  1306. },
  1307. agentMessage('wrong', 'final_answer', 'turn-2'),
  1308. )
  1309. await expect(result).rejects.toThrow('conflicting turns')
  1310. wire.close()
  1311. }
  1312. })
  1313. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  1314. const { child, wire } = await initializeWire()
  1315. wire.interrupt()
  1316. const result = wire.runTurn(['task'], new AbortController().signal)
  1317. const turnStart = await child.peer.nextMethod('turn/start')
  1318. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1319. await nextTask()
  1320. wire.interrupt()
  1321. const interrupt = await child.peer.nextMethod('turn/interrupt')
  1322. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  1323. child.peer.send({
  1324. id: interrupt.id,
  1325. error: { code: -32000, message: 'already done' },
  1326. })
  1327. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1328. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1329. wire.close()
  1330. wire.interrupt()
  1331. })
  1332. it('ignores unrelated and out-of-window notifications', async () => {
  1333. const { child, wire } = await initializeWire()
  1334. child.peer.send(
  1335. {
  1336. method: 'turn/started',
  1337. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  1338. },
  1339. {
  1340. method: 'turn/started',
  1341. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  1342. },
  1343. agentMessage('before', 'final_answer'),
  1344. { method: 'future/notification', params: {} },
  1345. turnCompleted('completed'),
  1346. turnCompleted('completed', 'turn-other', 'thread-2'),
  1347. )
  1348. await nextTask()
  1349. const result = wire.runTurn(['task'], new AbortController().signal)
  1350. const turnStart = await child.peer.nextMethod('turn/start')
  1351. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1352. await nextTask()
  1353. child.peer.send(
  1354. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  1355. turnCompleted('completed', 'turn-2'),
  1356. agentMessage('answer', 'final_answer'),
  1357. turnCompleted('completed'),
  1358. )
  1359. await expect(result).resolves.toEqual({
  1360. output: [{ type: 'text', text: 'answer' }],
  1361. stopReason: 'completed',
  1362. })
  1363. wire.close()
  1364. })
  1365. it('rejects pending work on abort, EOF, and stream error', async () => {
  1366. {
  1367. const child = fakeChild()
  1368. const wire = defaultWire(child)
  1369. wire.start()
  1370. const controller = new AbortController()
  1371. controller.abort('pre-aborted')
  1372. await expect(wire.initialize(controller.signal))
  1373. .rejects.toThrow('app-server request aborted: pre-aborted')
  1374. wire.close()
  1375. }
  1376. {
  1377. const child = fakeChild()
  1378. const wire = defaultWire(child)
  1379. wire.start()
  1380. const controller = new AbortController()
  1381. const pending = wire.initialize(controller.signal)
  1382. await child.peer.nextMethod('initialize')
  1383. controller.abort(new Error('cancel initialize'))
  1384. await expect(pending).rejects.toThrow('cancel initialize')
  1385. wire.close()
  1386. }
  1387. {
  1388. const child = fakeChild()
  1389. const wire = defaultWire(child)
  1390. wire.start()
  1391. const pending = wire.initialize(new AbortController().signal)
  1392. await child.peer.nextMethod('initialize')
  1393. child.fromChild.end()
  1394. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  1395. wire.close()
  1396. }
  1397. {
  1398. const child = fakeChild()
  1399. const wire = defaultWire(child)
  1400. wire.start()
  1401. const pending = wire.initialize(new AbortController().signal)
  1402. await child.peer.nextMethod('initialize')
  1403. child.fromChild.emit('error', new Error('stdout broke'))
  1404. await expect(pending).rejects.toThrow('stdout broke')
  1405. wire.close()
  1406. }
  1407. {
  1408. const child = fakeChild()
  1409. const wire = defaultWire(child)
  1410. wire.start()
  1411. const pending = wire.initialize(new AbortController().signal)
  1412. await child.peer.nextMethod('initialize')
  1413. child.toChild.emit('error', new Error('stdin broke'))
  1414. await expect(pending).rejects.toThrow('stdin broke')
  1415. wire.close()
  1416. child.toChild.emit('error', new Error('late stdin close'))
  1417. }
  1418. })
  1419. })
  1420. describe('run lifecycle and quiescence', () => {
  1421. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  1422. const child = fakeChild()
  1423. const spawn = vi.fn(() => child.handle)
  1424. const starting = startCodexRun(
  1425. request([{ type: 'text', text: 'task' }]),
  1426. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  1427. )
  1428. let published = false
  1429. void starting.then(() => { published = true })
  1430. const initialize = await child.peer.nextMethod('initialize')
  1431. expect(published).toBe(false)
  1432. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  1433. await child.peer.nextMethod('initialized')
  1434. const threadStart = await child.peer.nextMethod('thread/start')
  1435. expect(published).toBe(false)
  1436. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1437. const run = await starting
  1438. expect(spawn).toHaveBeenCalledWith({
  1439. argv: codexAppServerArgv(),
  1440. cwd: process.cwd(),
  1441. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  1442. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  1443. env: { OPENAI_API_KEY: 'fake' },
  1444. })
  1445. expect(run.localAgent).toBeUndefined()
  1446. const turnStart = await child.peer.nextMethod('turn/start')
  1447. child.peer.send(
  1448. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  1449. agentMessage('answer', 'final_answer'),
  1450. turnCompleted('completed'),
  1451. )
  1452. await expect(run.result).resolves.toEqual({
  1453. output: [{ type: 'text', text: 'answer' }],
  1454. stopReason: 'completed',
  1455. })
  1456. const disposal = run.dispose()
  1457. expect(run.dispose()).toBe(disposal)
  1458. await disposal
  1459. await nextTask()
  1460. expect(child.terminate).toHaveBeenCalledTimes(1)
  1461. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1462. })
  1463. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  1464. const controller = new AbortController()
  1465. const { child, run, turnStart } = await publishRun(
  1466. fakeChild(),
  1467. controller.signal,
  1468. )
  1469. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1470. await nextTask()
  1471. controller.abort(new Error('stop'))
  1472. await expect(run.result).resolves.toEqual({
  1473. output: [],
  1474. stopReason: 'aborted',
  1475. })
  1476. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  1477. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1478. })
  1479. await run.dispose()
  1480. })
  1481. it('reports turn-start failures and omits captured facts after success', async () => {
  1482. {
  1483. const { child, run, turnStart } = await publishRun()
  1484. child.peer.respond(turnStart, { turn: { id: '' } })
  1485. await expect(run.result).resolves.toEqual({
  1486. output: [],
  1487. diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'),
  1488. stopReason: 'error',
  1489. })
  1490. await run.dispose()
  1491. }
  1492. {
  1493. const { child, run, turnStart } = await publishRun()
  1494. child.peer.send({
  1495. id: 'successful-approval',
  1496. method: 'item/commandExecution/requestApproval',
  1497. params: {
  1498. threadId: 'thread-1',
  1499. turnId: 'turn-1',
  1500. availableDecisions: ['cancel'],
  1501. },
  1502. })
  1503. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1504. await child.peer.nextResponse('successful-approval')
  1505. child.peer.send(
  1506. agentMessage('answer', 'final_answer'),
  1507. turnCompleted('completed'),
  1508. )
  1509. await expect(run.result).resolves.toEqual({
  1510. output: [{ type: 'text', text: 'answer' }],
  1511. stopReason: 'completed',
  1512. })
  1513. await run.dispose()
  1514. }
  1515. })
  1516. it('preserves representative terminal categories, HTTP status, and mapping', async () => {
  1517. const scenarios = [
  1518. ['contextWindowExceeded', 'limit', 'max-tokens', undefined],
  1519. ['sessionBudgetExceeded', 'limit', 'error', undefined],
  1520. ['unauthorized', 'access-policy', 'error', undefined],
  1521. ['internalServerError', 'service', 'error', undefined],
  1522. [{ httpConnectionFailed: { httpStatusCode: 503 } }, 'transport', 'error', 503],
  1523. [{ activeTurnNotSteerable: { turnKind: 'review' } }, 'product-error', 'error', undefined],
  1524. ['futureError', 'unknown', 'error', undefined],
  1525. ] as const
  1526. for (const [codexErrorInfo, category, stopReason, httpStatus] of scenarios) {
  1527. const { child, run, turnStart } = await publishRun()
  1528. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1529. child.peer.send(
  1530. agentMessage('partial answer', null),
  1531. turnCompleted('failed', 'turn-1', 'thread-1', {
  1532. message: 'SECRET_TOKEN in /private/secret.txt',
  1533. codexErrorInfo,
  1534. }),
  1535. )
  1536. const result = await run.result
  1537. expect(result).toEqual({
  1538. output: [{ type: 'text', text: 'partial answer' }],
  1539. diagnostic: expectedFailureDiagnostic('turn', category, {
  1540. ...(httpStatus === undefined ? {} : { httpStatus }),
  1541. }),
  1542. stopReason,
  1543. })
  1544. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1545. expect(result.diagnostic).not.toContain('/private/secret.txt')
  1546. expect(result.diagnostic).not.toContain('turnKind')
  1547. await run.dispose()
  1548. }
  1549. })
  1550. it('includes a structured permission fact in a max-token result', async () => {
  1551. const { child, run, turnStart } = await publishRun()
  1552. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1553. child.peer.send({
  1554. id: 'approval-before-limit',
  1555. method: 'item/commandExecution/requestApproval',
  1556. params: {
  1557. threadId: 'thread-1',
  1558. turnId: 'turn-1',
  1559. availableDecisions: ['cancel'],
  1560. },
  1561. })
  1562. await child.peer.nextResponse('approval-before-limit')
  1563. child.peer.send(
  1564. agentMessage('partial answer', null),
  1565. turnCompleted('failed', 'turn-1', 'thread-1', {
  1566. codexErrorInfo: 'contextWindowExceeded',
  1567. }),
  1568. )
  1569. child.settle({ exitCode: 17, signal: null })
  1570. await expect(run.result).resolves.toEqual({
  1571. output: [{ type: 'text', text: 'partial answer' }],
  1572. diagnostic: `${expectedFailureDiagnostic('turn', 'limit', { outcome: { exitCode: 17, signal: null } })}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1573. stopReason: 'max-tokens',
  1574. })
  1575. await run.dispose()
  1576. })
  1577. it('flattens child exit and protocol failures after publication', async () => {
  1578. const errors: string[] = []
  1579. const outcomes: SubprocessOutcome[] = [
  1580. { exitCode: 9, signal: null },
  1581. { exitCode: null, signal: 'SIGABRT' },
  1582. { exitCode: null, signal: null },
  1583. ]
  1584. for (const outcome of outcomes) {
  1585. const child = fakeChild({ exitOnTerminate: false })
  1586. const { run } = await publishRun(child, undefined, {
  1587. onError: (error) => { errors.push(error.message) },
  1588. })
  1589. child.settle(outcome)
  1590. await expect(run.result).resolves.toEqual({
  1591. output: [],
  1592. diagnostic: expectedFailureDiagnostic('process', 'process', {
  1593. outcome,
  1594. }),
  1595. stopReason: 'error',
  1596. })
  1597. expect(errors.at(-1)).toBe(
  1598. `subagent-codex: ${expectedFailureDiagnostic('process', 'process', { outcome })}`,
  1599. )
  1600. await run.dispose().catch(() => {})
  1601. }
  1602. {
  1603. const outcome = { exitCode: 17, signal: null } as const
  1604. const child = fakeChild({ exitOnTerminate: false })
  1605. const { run, turnStart } = await publishRun(child, undefined, {
  1606. disposeGraceMs: 0.5,
  1607. })
  1608. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1609. vi.spyOn(child.handle, 'waitForExit').mockImplementationOnce(async (signal?: AbortSignal) => {
  1610. expect(signal).toBeDefined()
  1611. child.settle(outcome)
  1612. return true
  1613. })
  1614. child.fromChild.emit('end')
  1615. await expect(run.result).resolves.toEqual({
  1616. output: [],
  1617. diagnostic: expectedFailureDiagnostic('process', 'process', {
  1618. outcome,
  1619. }),
  1620. stopReason: 'error',
  1621. })
  1622. await run.dispose().catch(() => {})
  1623. }
  1624. {
  1625. const child = fakeChild({ exitOnTerminate: false })
  1626. const { run, turnStart } = await publishRun(child)
  1627. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1628. setImmediate(() => {
  1629. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1630. codexErrorInfo: 'other',
  1631. }))
  1632. })
  1633. child.settle({ exitCode: 17, signal: null })
  1634. await expect(run.result).resolves.toEqual({
  1635. output: [],
  1636. diagnostic: expectedFailureDiagnostic('turn', 'product-error', {
  1637. outcome: { exitCode: 17, signal: null },
  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. child.peer.send(
  1648. agentMessage('answer', 'final_answer'),
  1649. turnCompleted('completed'),
  1650. )
  1651. child.fromChild.end()
  1652. child.settle({ exitCode: 17, signal: null })
  1653. await expect(run.result).resolves.toEqual({
  1654. output: [{ type: 'text', text: 'answer' }],
  1655. stopReason: 'completed',
  1656. })
  1657. await run.dispose().catch(() => {})
  1658. }
  1659. {
  1660. const child = fakeChild()
  1661. const { run, turnStart } = await publishRun(child, undefined, {
  1662. disposeGraceMs: 10,
  1663. onError: () => { throw new Error('diagnostic sink') },
  1664. })
  1665. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1666. child.fromChild.end()
  1667. await expect(run.result).resolves.toEqual({
  1668. output: [],
  1669. diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
  1670. stopReason: 'error',
  1671. })
  1672. await run.dispose()
  1673. }
  1674. {
  1675. const child = fakeChild()
  1676. const { run, turnStart } = await publishRun(child)
  1677. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1678. child.stderr.emit('error', new Error('stderr broke'))
  1679. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1680. await expect(run.result).resolves.toEqual({
  1681. output: [{ type: 'text', text: 'answer' }],
  1682. stopReason: 'completed',
  1683. })
  1684. await run.dispose()
  1685. expect(child.stderr.listenerCount('error')).toBe(0)
  1686. }
  1687. })
  1688. it('attaches a safe permission diagnostic when a published run fails', async () => {
  1689. const { child, run, turnStart } = await publishRun()
  1690. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1691. await nextTask()
  1692. child.peer.send({
  1693. id: 'approval-diagnostic',
  1694. method: 'item/commandExecution/requestApproval',
  1695. params: {
  1696. threadId: 'thread-1',
  1697. turnId: 'turn-1',
  1698. availableDecisions: ['cancel'],
  1699. command: 'cat /private/secret.txt',
  1700. },
  1701. })
  1702. expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
  1703. result: { decision: 'cancel' },
  1704. })
  1705. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1706. message: 'SECRET_TOKEN in /private/secret.txt',
  1707. codexErrorInfo: 'other',
  1708. }))
  1709. await expect(run.result).resolves.toEqual({
  1710. output: [],
  1711. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1712. stopReason: 'error',
  1713. })
  1714. await run.dispose()
  1715. })
  1716. it('drains queued stderr to the Host without classifying it', async () => {
  1717. hostStderrWrite.capture = true
  1718. hostStderrWrite.chunks.length = 0
  1719. const { child, run, turnStart } = await publishRun()
  1720. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1721. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1722. message: 'fixture terminal failure',
  1723. codexErrorInfo: 'badRequest',
  1724. }))
  1725. setImmediate(() => {
  1726. child.stderr.write('approval policy is Never; reject command')
  1727. })
  1728. await expect(run.result).resolves.toEqual({
  1729. output: [],
  1730. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1731. stopReason: 'error',
  1732. })
  1733. expect(Buffer.concat(hostStderrWrite.chunks).toString())
  1734. .toContain('approval policy is Never; reject command')
  1735. await run.dispose()
  1736. hostStderrWrite.capture = false
  1737. })
  1738. it('forwards stderr without copying or classifying it', async () => {
  1739. const child = fakeChild()
  1740. hostStderrWrite.capture = true
  1741. hostStderrWrite.chunks.length = 0
  1742. const { run, turnStart } = await publishRun(child)
  1743. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1744. child.stderr.write('SECRET_TOKEN approval policy is Ne')
  1745. child.stderr.write('ver; reject command — /private/secret.txt')
  1746. child.stderr.emit('data', 'string stderr suffix')
  1747. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1748. message: 'fixture terminal failure',
  1749. codexErrorInfo: 'badRequest',
  1750. }))
  1751. await expect(run.result).resolves.toEqual({
  1752. output: [],
  1753. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1754. stopReason: 'error',
  1755. })
  1756. expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
  1757. expect(hostStderrWrite.chunks).toHaveLength(3)
  1758. await run.dispose()
  1759. expect(child.stderr.listenerCount('data')).toBe(0)
  1760. hostStderrWrite.capture = false
  1761. })
  1762. it('contains host stderr write failures without changing run settlement', async () => {
  1763. const child = fakeChild()
  1764. hostStderrWrite.capture = true
  1765. hostStderrWrite.failNext = true
  1766. const { run, turnStart } = await publishRun(child)
  1767. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1768. child.stderr.write('approval policy is Never; reject command')
  1769. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1770. message: 'fixture terminal failure',
  1771. codexErrorInfo: 'badRequest',
  1772. }))
  1773. await expect(run.result).resolves.toEqual({
  1774. output: [],
  1775. diagnostic: expectedFailureDiagnostic('turn', 'product-error'),
  1776. stopReason: 'error',
  1777. })
  1778. await run.dispose()
  1779. hostStderrWrite.capture = false
  1780. })
  1781. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  1782. const controller = new AbortController()
  1783. controller.abort()
  1784. const spawn = vi.fn()
  1785. await expect(startCodexRun(
  1786. request(undefined, controller.signal),
  1787. {
  1788. cwd: process.cwd(),
  1789. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1790. env: {},
  1791. disposeGraceMs: 10,
  1792. spawn,
  1793. },
  1794. )).rejects.toThrow('aborted before app-server startup')
  1795. expect(spawn).not.toHaveBeenCalled()
  1796. const spawnFailure = startCodexRun(request(), {
  1797. cwd: process.cwd(),
  1798. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1799. env: {},
  1800. disposeGraceMs: 10,
  1801. spawn: () => { throw new Error('SECRET_TOKEN spawn failure') },
  1802. })
  1803. await expect(spawnFailure)
  1804. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1805. await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN')
  1806. const asyncSpawnFailureChild = fakeChild({
  1807. pid: -1,
  1808. doneError: new Error('SECRET_TOKEN async spawn failure'),
  1809. })
  1810. const asyncSpawnFailure = startCodexRun(
  1811. request(),
  1812. runSpec(asyncSpawnFailureChild),
  1813. )
  1814. await expect(asyncSpawnFailure)
  1815. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1816. await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN')
  1817. expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled()
  1818. const child = fakeChild()
  1819. const starting = startCodexRun(request(), runSpec(child))
  1820. const initialize = await child.peer.nextMethod('initialize')
  1821. child.peer.respond(initialize, null)
  1822. await expect(starting)
  1823. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1824. await expect(starting).rejects.not.toThrow('invalid initialize response')
  1825. expect(child.terminate).toHaveBeenCalledTimes(1)
  1826. const cleanupFailureChild = fakeChild({
  1827. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  1828. })
  1829. const cleanupFailure = startCodexRun(
  1830. request(),
  1831. runSpec(cleanupFailureChild),
  1832. )
  1833. const cleanupFailureInitialize = await cleanupFailureChild.peer
  1834. .nextMethod('initialize')
  1835. cleanupFailureChild.peer.respond(cleanupFailureInitialize, null)
  1836. const cleanupError: unknown = await cleanupFailure.then(
  1837. () => undefined,
  1838. (error: unknown) => error,
  1839. )
  1840. expect(cleanupError).toBeInstanceOf(AggregateError)
  1841. expect(String(cleanupError)).toContain(
  1842. expectedFailureDiagnostic('initialize', 'unknown'),
  1843. )
  1844. expect(String(cleanupError)).toContain(expectedFailureDiagnostic(
  1845. 'teardown',
  1846. 'unknown',
  1847. { outcome: { exitCode: 0, signal: null } },
  1848. ))
  1849. expect(String(cleanupError)).not.toContain('SECRET_TOKEN')
  1850. const cleanupRaceAbort = new AbortController()
  1851. const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
  1852. const cleanupRace = startCodexRun(
  1853. request(undefined, cleanupRaceAbort.signal),
  1854. runSpec(cleanupRaceChild),
  1855. )
  1856. const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize')
  1857. cleanupRaceChild.peer.respond(cleanupRaceInitialize, null)
  1858. await nextTask()
  1859. cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
  1860. cleanupRaceChild.settle()
  1861. await expect(cleanupRace)
  1862. .rejects.toThrow('aborted before run publication')
  1863. const threadChild = fakeChild()
  1864. const threadStarting = startCodexRun(request(), runSpec(threadChild))
  1865. const threadInitialize = await threadChild.peer.nextMethod('initialize')
  1866. threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.149.1' })
  1867. await threadChild.peer.nextMethod('initialized')
  1868. const invalidThread = await threadChild.peer.nextMethod('thread/start')
  1869. threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } })
  1870. await expect(threadStarting)
  1871. .rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown'))
  1872. await expect(threadStarting).rejects.not.toThrow('thread/start thread id')
  1873. const exitedThreadChild = fakeChild({ exitOnTerminate: false })
  1874. const exitedThreadStarting = startCodexRun(
  1875. request(),
  1876. runSpec(exitedThreadChild),
  1877. )
  1878. const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize')
  1879. exitedThreadChild.peer.respond(exitedThreadInitialize, {
  1880. userAgent: 'codex-cli 0.149.1',
  1881. })
  1882. await exitedThreadChild.peer.nextMethod('initialized')
  1883. await exitedThreadChild.peer.nextMethod('thread/start')
  1884. exitedThreadChild.settle({ exitCode: null, signal: 'SIGABRT' })
  1885. await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic(
  1886. 'thread-start',
  1887. 'unknown',
  1888. { outcome: { exitCode: null, signal: 'SIGABRT' } },
  1889. ))
  1890. const eofBeforeCloseChild = fakeChild({ exitOnTerminate: false })
  1891. const eofBeforeCloseStarting = startCodexRun(
  1892. request(),
  1893. runSpec(eofBeforeCloseChild),
  1894. )
  1895. const eofBeforeCloseInitialize = await eofBeforeCloseChild.peer
  1896. .nextMethod('initialize')
  1897. eofBeforeCloseChild.peer.respond(eofBeforeCloseInitialize, {
  1898. userAgent: 'codex-cli 0.149.1',
  1899. })
  1900. await eofBeforeCloseChild.peer.nextMethod('initialized')
  1901. await eofBeforeCloseChild.peer.nextMethod('thread/start')
  1902. eofBeforeCloseChild.fromChild.emit('end')
  1903. setImmediate(() => {
  1904. eofBeforeCloseChild.settle({ exitCode: 23, signal: null })
  1905. })
  1906. await expect(eofBeforeCloseStarting).rejects.toThrow(
  1907. expectedFailureDiagnostic('thread-start', 'unknown', {
  1908. outcome: { exitCode: 23, signal: null },
  1909. }),
  1910. )
  1911. const stderrChild = fakeChild()
  1912. const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
  1913. const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
  1914. stderrChild.stderr.emit('error', new Error('startup stderr broke'))
  1915. stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.149.1' })
  1916. await stderrChild.peer.nextMethod('initialized')
  1917. const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
  1918. stderrChild.peer.respond(stderrThreadStart, {
  1919. thread: { id: 'thread-1', ephemeral: true },
  1920. })
  1921. const stderrRun = await stderrStarting
  1922. const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
  1923. stderrChild.peer.send(
  1924. { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
  1925. agentMessage('answer', 'final_answer'),
  1926. turnCompleted('completed'),
  1927. )
  1928. await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
  1929. await stderrRun.dispose()
  1930. expect(stderrChild.stderr.listenerCount('error')).toBe(0)
  1931. })
  1932. it('rolls back an abort that wins immediately after thread creation', async () => {
  1933. const controller = new AbortController()
  1934. const child = fakeChild()
  1935. const starting = startCodexRun(
  1936. request(undefined, controller.signal),
  1937. runSpec(child),
  1938. )
  1939. const initialize = await child.peer.nextMethod('initialize')
  1940. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  1941. await child.peer.nextMethod('initialized')
  1942. const threadStart = await child.peer.nextMethod('thread/start')
  1943. expect(threadStart.params).toEqual({
  1944. cwd: process.cwd(),
  1945. ephemeral: true,
  1946. approvalPolicy: 'never',
  1947. })
  1948. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1949. controller.abort('startup race')
  1950. await expect(starting).rejects.toThrow('aborted before run publication')
  1951. expect(child.terminate).toHaveBeenCalledTimes(1)
  1952. })
  1953. it('keeps overlapping runs isolated', async () => {
  1954. const initialStderrListeners = {
  1955. error: process.stderr.listenerCount('error'),
  1956. unpipe: process.stderr.listenerCount('unpipe'),
  1957. close: process.stderr.listenerCount('close'),
  1958. finish: process.stderr.listenerCount('finish'),
  1959. }
  1960. const runs = await Promise.all(
  1961. Array.from({ length: 6 }, () => publishRun(fakeChild())),
  1962. )
  1963. expect({
  1964. error: process.stderr.listenerCount('error'),
  1965. unpipe: process.stderr.listenerCount('unpipe'),
  1966. close: process.stderr.listenerCount('close'),
  1967. finish: process.stderr.listenerCount('finish'),
  1968. }).toEqual(initialStderrListeners)
  1969. for (const [index, entry] of runs.entries()) {
  1970. const id = `turn-${index + 1}`
  1971. entry.child.peer.send(
  1972. { id: entry.turnStart.id, result: { turn: { id } } },
  1973. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  1974. turnCompleted('completed', id),
  1975. )
  1976. }
  1977. const results = await Promise.all(runs.map(entry => entry.run.result))
  1978. expect(results.map(result => result.output)).toEqual(
  1979. Array.from({ length: 6 }, (_, index) => [
  1980. { type: 'text', text: `answer-${index + 1}` },
  1981. ]),
  1982. )
  1983. expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
  1984. await Promise.all(runs.map(entry => entry.run.dispose()))
  1985. })
  1986. it('isolates permission modes and diagnostics across overlapping runs', async () => {
  1987. const first = await publishRun(fakeChild(), undefined, {
  1988. permissionMode: 'never',
  1989. })
  1990. const second = await publishRun(fakeChild(), undefined, {
  1991. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  1992. })
  1993. first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
  1994. second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
  1995. await nextTask()
  1996. first.child.peer.send({
  1997. id: 'never-approval',
  1998. method: 'item/commandExecution/requestApproval',
  1999. params: {
  2000. threadId: 'thread-1',
  2001. turnId: 'turn-never',
  2002. availableDecisions: ['cancel'],
  2003. },
  2004. })
  2005. second.child.peer.send({
  2006. id: 'bypass-elicitation',
  2007. method: 'mcpServer/elicitation/request',
  2008. params: { threadId: 'thread-1', turnId: null },
  2009. })
  2010. await Promise.all([
  2011. first.child.peer.nextResponse('never-approval'),
  2012. second.child.peer.nextResponse('bypass-elicitation'),
  2013. ])
  2014. first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
  2015. message: 'first failure',
  2016. codexErrorInfo: 'other',
  2017. }))
  2018. second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
  2019. message: 'second failure',
  2020. codexErrorInfo: 'other',
  2021. }))
  2022. await expect(first.run.result).resolves.toEqual({
  2023. output: [],
  2024. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  2025. stopReason: 'error',
  2026. })
  2027. await expect(second.run.result).resolves.toEqual({
  2028. output: [],
  2029. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`,
  2030. stopReason: 'error',
  2031. })
  2032. await Promise.all([first.run.dispose(), second.run.dispose()])
  2033. })
  2034. it('uses the registered provider config and logs flattened errors', async () => {
  2035. const ctx = new Context()
  2036. await ctx.plugin(SubagentRuntime)
  2037. await ctx.plugin(LocalSubprocessRuntime)
  2038. const child = fakeChild()
  2039. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  2040. const warnings: string[] = []
  2041. ctx.logger.warn = ((message: unknown) => {
  2042. warnings.push(String(message))
  2043. }) as typeof ctx.logger.warn
  2044. await ctx.plugin(codex, {
  2045. providerName: 'codex-diagnostic',
  2046. model: 'codex-diagnostic-model',
  2047. env: { OPENAI_API_KEY: 'fake' },
  2048. permissionMode: 'approve-for-me',
  2049. disposeGraceMs: 25,
  2050. })
  2051. const invalidCwdParent = {
  2052. id: 'parent-with-invalid-cwd',
  2053. session: { header: { cwd: 'relative/SECRET_TOKEN' } },
  2054. } as unknown as Agent
  2055. const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', {
  2056. prompt: [{ type: 'text', text: 'task' }],
  2057. parent: invalidCwdParent,
  2058. signal: new AbortController().signal,
  2059. }).then(
  2060. () => undefined,
  2061. (error: unknown) => error,
  2062. )
  2063. expect(invalidCwdError).toBeInstanceOf(Error)
  2064. if (!(invalidCwdError instanceof Error)) {
  2065. throw new Error('expected safe invalid-cwd failure')
  2066. }
  2067. expect(invalidCwdError.message).toContain(
  2068. expectedFailureDiagnostic('initialize', 'unknown'),
  2069. )
  2070. expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN')
  2071. expect(invalidCwdError.cause).toBeInstanceOf(Error)
  2072. expect((invalidCwdError.cause as Error).message)
  2073. .toContain('relative/SECRET_TOKEN')
  2074. expect(spawn).not.toHaveBeenCalled()
  2075. const invalidCwdAbort = new AbortController()
  2076. invalidCwdAbort.abort(new Error('cancel invalid cwd startup'))
  2077. await expect(ctx.subagents.start('codex-diagnostic', {
  2078. prompt: [{ type: 'text', text: 'task' }],
  2079. parent: invalidCwdParent,
  2080. signal: invalidCwdAbort.signal,
  2081. })).rejects.toThrow('aborted before app-server startup')
  2082. expect(spawn).not.toHaveBeenCalled()
  2083. const starting = ctx.subagents.start('codex-diagnostic', {
  2084. prompt: [{ type: 'text', text: 'task' }],
  2085. parent: fakeParent,
  2086. signal: new AbortController().signal,
  2087. })
  2088. const initialize = await child.peer.nextMethod('initialize')
  2089. child.peer.respond(initialize, { userAgent: 'codex-cli 0.149.1' })
  2090. await child.peer.nextMethod('initialized')
  2091. const threadStart = await child.peer.nextMethod('thread/start')
  2092. expect(threadStart.params).toEqual({
  2093. cwd: process.cwd(),
  2094. ephemeral: true,
  2095. model: 'codex-diagnostic-model',
  2096. approvalPolicy: 'on-request',
  2097. approvalsReviewer: 'auto_review',
  2098. sandbox: 'workspace-write',
  2099. })
  2100. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  2101. const run = await starting
  2102. const turnStart = await child.peer.nextMethod('turn/start')
  2103. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  2104. await nextTask()
  2105. child.peer.send({
  2106. id: 'provider-approval',
  2107. method: 'item/commandExecution/requestApproval',
  2108. params: {
  2109. threadId: 'thread-1',
  2110. turnId: 'turn-1',
  2111. availableDecisions: ['cancel'],
  2112. command: 'cat /private/secret.txt',
  2113. },
  2114. })
  2115. await child.peer.nextResponse('provider-approval')
  2116. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  2117. message: 'SECRET_TOKEN in /private/secret.txt',
  2118. codexErrorInfo: 'other',
  2119. }))
  2120. await expect(run.result).resolves.toEqual({
  2121. output: [],
  2122. diagnostic: `${expectedFailureDiagnostic('turn', 'product-error')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  2123. stopReason: 'error',
  2124. })
  2125. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  2126. env: { OPENAI_API_KEY: 'fake' },
  2127. graceMs: 25,
  2128. cwd: process.cwd(),
  2129. }))
  2130. expect(warnings).toEqual([
  2131. expect.stringContaining(
  2132. `subagent-codex "codex-diagnostic": child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'product-error')}`,
  2133. ),
  2134. ])
  2135. expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
  2136. expect(warnings.join('\n')).not.toContain('/private/secret.txt')
  2137. await run.dispose()
  2138. await ctx.fiber.dispose()
  2139. })
  2140. })
  2141. describe('disposeCodexChild', () => {
  2142. it('closes stdin, terminates, and waits for the managed tree', async () => {
  2143. const child = fakeChild()
  2144. const wire = defaultWire(child)
  2145. const end = vi.spyOn(child.toChild, 'end')
  2146. await disposeCodexChild(wire, child.handle)
  2147. expect(end).toHaveBeenCalled()
  2148. expect(child.terminate).toHaveBeenCalledTimes(1)
  2149. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  2150. expect(child.waitForExit).toHaveBeenCalledWith()
  2151. })
  2152. it('does not finish disposal before the managed tree exits', async () => {
  2153. const child = fakeChild({ exitOnTerminate: false })
  2154. const wire = defaultWire(child)
  2155. let disposed = false
  2156. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  2157. disposed = true
  2158. })
  2159. await new Promise<void>((resolve) => { setImmediate(resolve) })
  2160. expect(disposed).toBe(false)
  2161. child.settle()
  2162. await disposal
  2163. expect(disposed).toBe(true)
  2164. })
  2165. it('contains a concurrently closed stdin error', async () => {
  2166. const child = fakeChild()
  2167. const wire = defaultWire(child)
  2168. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  2169. throw new Error('already closed')
  2170. })
  2171. await expect(disposeCodexChild(wire, child.handle))
  2172. .resolves.toBeUndefined()
  2173. })
  2174. it('handles a spawn-level failure with no process tree', async () => {
  2175. const child = fakeChild({
  2176. pid: -1,
  2177. doneError: new Error('spawn failed'),
  2178. })
  2179. const wire = defaultWire(child)
  2180. await expect(disposeCodexChild(wire, child.handle))
  2181. .resolves.toBeUndefined()
  2182. expect(child.terminate).not.toHaveBeenCalled()
  2183. expect(child.waitForExit).not.toHaveBeenCalled()
  2184. })
  2185. it('reports tree-wait failure with safe teardown facts', async () => {
  2186. const child = fakeChild({
  2187. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  2188. })
  2189. const wire = defaultWire(child)
  2190. const disposal = disposeCodexChild(wire, child.handle)
  2191. await expect(disposal).rejects.toThrow(expectedFailureDiagnostic(
  2192. 'teardown',
  2193. 'unknown',
  2194. { outcome: { exitCode: 0, signal: null } },
  2195. ))
  2196. await expect(disposal).rejects.not.toThrow('SECRET_TOKEN')
  2197. })
  2198. it('does not wait for a pending process outcome after tree observation fails', async () => {
  2199. const child = fakeChild({
  2200. exitOnTerminate: false,
  2201. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  2202. })
  2203. const wire = defaultWire(child)
  2204. let disposalError: unknown
  2205. const disposal = disposeCodexChild(wire, child.handle).catch(
  2206. (error: unknown) => { disposalError = error },
  2207. )
  2208. await nextTask()
  2209. expect(disposalError).toBeInstanceOf(Error)
  2210. expect(String(disposalError)).toContain(
  2211. expectedFailureDiagnostic('teardown', 'unknown'),
  2212. )
  2213. expect(String(disposalError)).not.toContain('SECRET_TOKEN')
  2214. child.settle()
  2215. await disposal
  2216. })
  2217. })