subagent-codex.spec.ts 71 KB

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