wire.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. /**
  2. * Minimal Codex app-server 0.147.0 protocol adapter. The shared JSON-RPC
  3. * transport owns framing and request correlation; this module owns only the
  4. * product methods, current thread/turn association, unattended approval
  5. * responses, and terminal-answer selection.
  6. *
  7. * @module @deepseek-ai/dsh-subagent-codex/wire
  8. */
  9. import type { Readable, Writable } from 'node:stream'
  10. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  11. import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
  12. import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
  13. import type { CodexPermissionMode } from './run.ts'
  14. type JsonObject = Record<string, unknown>
  15. const THREAD_PERMISSION_PARAMS: Readonly<Record<CodexPermissionMode, JsonObject>> = {
  16. never: { approvalPolicy: 'never' },
  17. 'approve-for-me': {
  18. approvalPolicy: 'on-request',
  19. approvalsReviewer: 'auto_review',
  20. sandbox: 'workspace-write',
  21. },
  22. 'dangerously-bypass-approvals-and-sandbox': {
  23. approvalPolicy: 'never',
  24. sandbox: 'danger-full-access',
  25. },
  26. }
  27. const STDERR_PERMISSION_SIGNATURES = [
  28. {
  29. text: 'approval policy is Never; reject command',
  30. request: 'command execution',
  31. decision: 'denied',
  32. reason: 'Codex rejected an escalation because the selected policy never asks for approval',
  33. },
  34. {
  35. text: 'recorded sandbox violation:',
  36. request: 'sandbox execution',
  37. decision: 'failed',
  38. reason: 'Codex reported a sandbox violation',
  39. },
  40. ] as const
  41. const STDERR_SIGNATURE_TAIL_CHARS = Math.max(
  42. ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length),
  43. ) - 1
  44. function stderrSignatureTail(value: string): string {
  45. for (
  46. let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length)
  47. ; length > 0
  48. ; length -= 1
  49. ) {
  50. const tail = value.slice(-length)
  51. if (STDERR_PERMISSION_SIGNATURES.some(signature =>
  52. tail.length < signature.text.length && signature.text.startsWith(tail))) {
  53. return tail
  54. }
  55. }
  56. return ''
  57. }
  58. function object(value: unknown, label: string): JsonObject {
  59. if (value === null || typeof value !== 'object' || Array.isArray(value)) {
  60. throw new Error(`subagent-codex: app-server returned invalid ${label}`)
  61. }
  62. return value as JsonObject
  63. }
  64. function string(value: unknown, label: string): string {
  65. if (typeof value !== 'string' || value.length === 0) {
  66. throw new Error(`subagent-codex: app-server returned invalid ${label}`)
  67. }
  68. return value
  69. }
  70. function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
  71. const available = params.availableDecisions
  72. if (available === undefined || available === null) return 'decline'
  73. if (Array.isArray(available)) {
  74. if (available.includes('cancel')) return 'cancel'
  75. if (available.includes('decline')) return 'decline'
  76. }
  77. throw new Error('subagent-codex: app-server offered no unattended approval decision')
  78. }
  79. function isContextWindowExceeded(turn: JsonObject): boolean {
  80. if (turn.status !== 'failed') return false
  81. const error = turn.error
  82. return error !== null
  83. && typeof error === 'object'
  84. && !Array.isArray(error)
  85. && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
  86. }
  87. function isSandboxFailure(turn: JsonObject): boolean {
  88. if (turn.status !== 'failed') return false
  89. const error = turn.error
  90. return error !== null
  91. && typeof error === 'object'
  92. && !Array.isArray(error)
  93. && (error as JsonObject).codexErrorInfo === 'sandboxError'
  94. }
  95. function unattendedDiagnostic(
  96. mode: CodexPermissionMode,
  97. request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution',
  98. decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed',
  99. reason: string,
  100. ): string {
  101. return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
  102. }
  103. function thrown(value: unknown): Error {
  104. /* v8 ignore next -- typed protocol and stream failures reject with Error. */
  105. return value instanceof Error ? value : new Error(String(value))
  106. }
  107. function abortError(signal: AbortSignal): Error {
  108. return signal.reason instanceof Error
  109. ? signal.reason
  110. : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`)
  111. }
  112. async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
  113. if (signal.aborted) {
  114. void pending.catch(() => {})
  115. throw abortError(signal)
  116. }
  117. let rejectAbort!: (error: Error) => void
  118. const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
  119. const onAbort = (): void => { rejectAbort(abortError(signal)) }
  120. signal.addEventListener('abort', onAbort, { once: true })
  121. try {
  122. return await Promise.race([pending, aborted])
  123. } finally {
  124. signal.removeEventListener('abort', onAbort)
  125. }
  126. }
  127. /**
  128. * One app-server connection and its single ephemeral thread/turn.
  129. *
  130. * The class deliberately exposes no generic request surface. Supporting
  131. * another product method must first become part of the provider contract.
  132. */
  133. export class CodexAppServerWire {
  134. private readonly transport: JsonRpcLineTransport
  135. private readonly fatal = Promise.withResolvers<never>()
  136. private threadId: string | undefined
  137. private turnId: string | undefined
  138. private pendingTurnId: string | undefined
  139. private turnCompleted: PromiseWithResolvers<{
  140. readonly params: JsonObject
  141. readonly order: number
  142. }> | undefined
  143. private readonly earlyTurnNotifications: Array<{
  144. readonly method: string
  145. readonly params: JsonObject
  146. readonly order: number
  147. }> = []
  148. private lastFinalAnswer: string | undefined
  149. private lastUnphasedAnswer: string | undefined
  150. private diagnostic: string | undefined
  151. private diagnosticOrder = 0
  152. private observationOrder = 0
  153. private pendingDiagnostic: {
  154. readonly order: number
  155. readonly request: Parameters<typeof unattendedDiagnostic>[1]
  156. readonly decision: Parameters<typeof unattendedDiagnostic>[2]
  157. readonly reason: string
  158. } | undefined
  159. private stderrTail = ''
  160. private closed = false
  161. constructor(
  162. private readonly input: Readable,
  163. output: Writable,
  164. private readonly permissionMode: CodexPermissionMode,
  165. ) {
  166. this.transport = new JsonRpcLineTransport(input, output)
  167. // Fatal protocol state can arrive after the current guarded operation has
  168. // already settled. Keep the shared rejection observed without inserting
  169. // another promise-adoption hop into active races.
  170. void this.fatal.promise.catch(() => {})
  171. this.transport.onRequest((method, params) => this.handleServerRequest(method, params))
  172. this.transport.onNotification((method, params) => {
  173. try {
  174. this.handleNotification(method, params)
  175. } catch (error: unknown) {
  176. this.fail(thrown(error))
  177. }
  178. })
  179. this.input.on('error', this.onInputError)
  180. this.input.on('end', this.onInputEnd)
  181. // Pipe errors can race protocol closure and process teardown. Retain both
  182. // error listeners for the lifetime of their per-run streams so no late
  183. // EPIPE or read failure becomes an unhandled EventEmitter error.
  184. output.on('error', this.onOutputError)
  185. }
  186. /** Start reading app-server frames. */
  187. start(): void {
  188. this.transport.start()
  189. }
  190. /**
  191. * Perform the required app-server initialize/initialized handshake.
  192. * @param signal - unpublished-start cancellation.
  193. */
  194. async initialize(signal: AbortSignal): Promise<void> {
  195. object(await this.guarded(this.transport.request('initialize', {
  196. clientInfo: {
  197. name: 'deepseek-harness',
  198. title: 'DeepSeek Harness',
  199. version: '0.0.1',
  200. },
  201. capabilities: {
  202. experimentalApi: false,
  203. requestAttestation: false,
  204. },
  205. }, signal), signal), 'initialize response')
  206. this.transport.notify('initialized')
  207. await this.guarded(this.transport.flush(), signal)
  208. }
  209. /**
  210. * Create the run's private ephemeral thread and retain its identity.
  211. * @param cwd - parent Session workspace.
  212. * @param signal - unpublished-start cancellation.
  213. */
  214. async startThread(cwd: string, signal: AbortSignal): Promise<void> {
  215. const response = object(await this.guarded(this.transport.request('thread/start', {
  216. cwd,
  217. ephemeral: true,
  218. ...THREAD_PERMISSION_PARAMS[this.permissionMode],
  219. }, signal), signal), 'thread/start response')
  220. const thread = object(response.thread, 'thread/start thread')
  221. const id = string(thread.id, 'thread/start thread id')
  222. if (thread.ephemeral !== true) {
  223. throw new Error('subagent-codex: app-server did not create an ephemeral thread')
  224. }
  225. this.threadId = id
  226. }
  227. /**
  228. * Submit the one text-only task and wait for this thread/turn's authoritative
  229. * terminal notification.
  230. * @param texts - already validated task text blocks.
  231. * @param signal - local cancellation for the published run.
  232. * @returns the shared subagent result.
  233. */
  234. async runTurn(
  235. texts: readonly string[],
  236. signal: AbortSignal,
  237. ): Promise<SubagentResult> {
  238. const completion = Promise.withResolvers<{
  239. readonly params: JsonObject
  240. readonly order: number
  241. }>()
  242. this.turnCompleted = completion
  243. const threadId = this.threadId as string
  244. const response = object(await this.guarded(this.transport.request('turn/start', {
  245. threadId,
  246. input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
  247. }, signal), signal), 'turn/start response')
  248. const turn = object(response.turn, 'turn/start turn')
  249. this.commitTurnId(string(turn.id, 'turn/start turn id'))
  250. const completed = await this.guarded(completion.promise, signal)
  251. const terminal = object(completed.params.turn, 'turn/completed turn')
  252. const status = terminal.status
  253. if (isContextWindowExceeded(terminal)) {
  254. return { output: this.collectOutput(), stopReason: 'max-tokens' }
  255. }
  256. if (status !== 'completed') {
  257. const sandboxFailure = isSandboxFailure(terminal)
  258. if (sandboxFailure) {
  259. this.recordDiagnostic(
  260. 'sandbox execution',
  261. 'failed',
  262. 'Codex reported a sandbox failure',
  263. completed.order,
  264. )
  265. }
  266. const detail = status === 'failed'
  267. ? sandboxFailure
  268. ? ': sandboxError'
  269. : ': error'
  270. : ''
  271. throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
  272. }
  273. const output = this.collectOutput()
  274. if (output.length === 0) {
  275. throw new Error('subagent-codex: Codex completed without a final answer')
  276. }
  277. return { output, stopReason: 'completed' }
  278. }
  279. /**
  280. * Best-effort remote cancellation. Local settlement and process teardown
  281. * remain authoritative when the child no longer accepts protocol requests.
  282. */
  283. interrupt(): void {
  284. if (this.threadId === undefined || this.turnId === undefined || this.closed) return
  285. void this.transport.request('turn/interrupt', {
  286. threadId: this.threadId,
  287. turnId: this.turnId,
  288. }).catch(() => {})
  289. }
  290. /**
  291. * The best non-commentary answer observed so far, preserving exact bytes.
  292. * @returns the selected final or nullable-phase text block, if any.
  293. */
  294. collectOutput(): ContentBlock[] {
  295. const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer
  296. return selected !== undefined && selected.trim().length > 0
  297. ? [{ type: 'text', text: selected }]
  298. : []
  299. }
  300. /**
  301. * The latest safe unattended permission fact observed for this run.
  302. * @returns provider-authored diagnostic text, when one was observed.
  303. */
  304. collectDiagnostic(): string | undefined {
  305. return this.diagnostic
  306. }
  307. /**
  308. * Observe product stderr while retaining only enough tail to recognize fixed
  309. * permission signatures. The raw text is never copied into the diagnostic.
  310. * @param chunk - one decoded stderr chunk already forwarded to the host.
  311. */
  312. observeStderr(chunk: string): void {
  313. const observed = `${this.stderrTail}${chunk}`
  314. let latestIndex = -1
  315. let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined
  316. for (const signature of STDERR_PERMISSION_SIGNATURES) {
  317. const index = observed.lastIndexOf(signature.text)
  318. if (index > latestIndex) {
  319. latestIndex = index
  320. latest = signature
  321. }
  322. }
  323. if (latest !== undefined) {
  324. this.recordDiagnostic(latest.request, latest.decision, latest.reason)
  325. }
  326. this.stderrTail = stderrSignatureTail(observed)
  327. }
  328. /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
  329. close(): void {
  330. if (this.closed) return
  331. this.closed = true
  332. this.input.off('end', this.onInputEnd)
  333. this.transport.close()
  334. }
  335. private async guarded<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
  336. const withFatal = Promise.race([this.fatal.promise, pending])
  337. return raceAbort(withFatal, signal)
  338. }
  339. private fail(error: Error): void {
  340. this.fatal.reject(error)
  341. }
  342. private readonly onInputError = (error: Error): void => {
  343. this.fail(error)
  344. }
  345. private readonly onOutputError = (error: Error): void => {
  346. this.fail(error)
  347. }
  348. private readonly onInputEnd = (): void => {
  349. this.fail(new Error('subagent-codex: app-server protocol stream closed'))
  350. }
  351. private observePendingTurnId(id: string): void {
  352. if (this.turnCompleted === undefined) {
  353. throw new Error('subagent-codex: app-server referenced a turn before turn/start')
  354. }
  355. if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
  356. throw new Error('subagent-codex: app-server referenced conflicting turns')
  357. }
  358. this.pendingTurnId = id
  359. }
  360. private commitTurnId(id: string): void {
  361. if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
  362. throw new Error('subagent-codex: turn/start response did not match the active turn')
  363. }
  364. this.turnId = id
  365. const pendingDiagnostic = this.pendingDiagnostic
  366. this.pendingDiagnostic = undefined
  367. if (pendingDiagnostic !== undefined) {
  368. this.recordDiagnostic(
  369. pendingDiagnostic.request,
  370. pendingDiagnostic.decision,
  371. pendingDiagnostic.reason,
  372. pendingDiagnostic.order,
  373. )
  374. }
  375. const notifications = this.earlyTurnNotifications.splice(0)
  376. for (const notification of notifications) {
  377. this.handleNotification(
  378. notification.method,
  379. notification.params,
  380. notification.order,
  381. )
  382. }
  383. }
  384. /**
  385. * Validate the request's thread and turn association.
  386. * @returns `true` when the matching turn is still provisional, so the caller
  387. * defers its diagnostic until `commitTurnId()`.
  388. */
  389. private validateRunIds(
  390. params: JsonObject,
  391. nullableTurn = false,
  392. ): boolean {
  393. if (params.threadId !== this.threadId) {
  394. throw new Error('subagent-codex: app-server request referenced another thread')
  395. }
  396. if (nullableTurn && params.turnId === null) return false
  397. const id = string(params.turnId, 'server request turn id')
  398. if (this.turnId === undefined) {
  399. this.observePendingTurnId(id)
  400. return true
  401. }
  402. if (id !== this.turnId) {
  403. throw new Error('subagent-codex: app-server request referenced another turn')
  404. }
  405. return false
  406. }
  407. private recordRequestDiagnostic(
  408. provisional: boolean,
  409. request: Parameters<typeof unattendedDiagnostic>[1],
  410. decision: Parameters<typeof unattendedDiagnostic>[2],
  411. reason: string,
  412. ): void {
  413. const order = this.nextObservationOrder()
  414. if (provisional) {
  415. this.pendingDiagnostic = {
  416. order,
  417. request,
  418. decision,
  419. reason,
  420. }
  421. return
  422. }
  423. this.recordDiagnostic(request, decision, reason, order)
  424. }
  425. private recordDiagnostic(
  426. request: Parameters<typeof unattendedDiagnostic>[1],
  427. decision: Parameters<typeof unattendedDiagnostic>[2],
  428. reason: string,
  429. order = this.nextObservationOrder(),
  430. ): void {
  431. if (order < this.diagnosticOrder) return
  432. this.diagnosticOrder = order
  433. this.diagnostic = unattendedDiagnostic(
  434. this.permissionMode,
  435. request,
  436. decision,
  437. reason,
  438. )
  439. }
  440. private nextObservationOrder(): number {
  441. this.observationOrder += 1
  442. return this.observationOrder
  443. }
  444. private recordDeclinedItem(item: JsonObject, order?: number): boolean {
  445. if (item.type === 'commandExecution' && item.status === 'declined') {
  446. this.recordDiagnostic(
  447. 'command execution',
  448. 'declined',
  449. 'Codex declined the command under the selected permission mode',
  450. order,
  451. )
  452. return true
  453. }
  454. if (item.type === 'fileChange' && item.status === 'declined') {
  455. this.recordDiagnostic(
  456. 'file change',
  457. 'declined',
  458. 'Codex declined the file change under the selected permission mode',
  459. order,
  460. )
  461. return true
  462. }
  463. return false
  464. }
  465. private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
  466. try {
  467. switch (method) {
  468. case 'item/commandExecution/requestApproval':
  469. {
  470. const provisional = this.validateRunIds(params)
  471. const decision = unattendedDecision(params)
  472. this.recordRequestDiagnostic(
  473. provisional,
  474. 'command approval',
  475. decision === 'cancel' ? 'cancelled' : 'declined',
  476. 'the provider does not grant interactive approval',
  477. )
  478. return Promise.resolve({ decision })
  479. }
  480. case 'item/fileChange/requestApproval':
  481. {
  482. const provisional = this.validateRunIds(params)
  483. const decision = unattendedDecision(params)
  484. this.recordRequestDiagnostic(
  485. provisional,
  486. 'file approval',
  487. decision === 'cancel' ? 'cancelled' : 'declined',
  488. 'the provider does not grant interactive approval',
  489. )
  490. return Promise.resolve({ decision })
  491. }
  492. case 'item/permissions/requestApproval':
  493. this.recordRequestDiagnostic(
  494. this.validateRunIds(params),
  495. 'permission grant',
  496. 'denied',
  497. 'the provider grants no additional turn permissions',
  498. )
  499. return Promise.resolve({ permissions: {}, scope: 'turn' })
  500. case 'item/tool/requestUserInput':
  501. this.recordRequestDiagnostic(
  502. this.validateRunIds(params),
  503. 'user input',
  504. 'empty response',
  505. 'the provider does not collect interactive answers',
  506. )
  507. return Promise.resolve({ answers: {} })
  508. case 'mcpServer/elicitation/request':
  509. this.recordRequestDiagnostic(
  510. this.validateRunIds(params, true),
  511. 'MCP elicitation',
  512. 'declined',
  513. 'the provider does not collect interactive MCP input',
  514. )
  515. return Promise.resolve({ action: 'decline', content: null, _meta: null })
  516. default:
  517. throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
  518. }
  519. } catch (error: unknown) {
  520. const normalized = thrown(error)
  521. this.fail(normalized)
  522. return Promise.reject(normalized)
  523. }
  524. }
  525. private handleNotification(
  526. method: string,
  527. params: JsonObject,
  528. order?: number,
  529. ): void {
  530. if (method === 'turn/started') {
  531. const threadId = string(params.threadId, 'turn/started thread id')
  532. if (threadId !== this.threadId) return
  533. const turn = object(params.turn, 'turn/started turn')
  534. if (this.turnCompleted !== undefined && this.turnId === undefined) {
  535. this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
  536. }
  537. return
  538. }
  539. if (method === 'item/completed') {
  540. const threadId = string(params.threadId, 'item/completed thread id')
  541. if (threadId !== this.threadId) return
  542. const id = string(params.turnId, 'item/completed turn id')
  543. if (this.turnId === undefined) {
  544. if (this.turnCompleted !== undefined) {
  545. this.observePendingTurnId(id)
  546. this.earlyTurnNotifications.push({
  547. method,
  548. params,
  549. order: this.nextObservationOrder(),
  550. })
  551. }
  552. return
  553. }
  554. if (id !== this.turnId) return
  555. const item = object(params.item, 'item/completed item')
  556. if (this.recordDeclinedItem(item, order)) return
  557. if (item.type !== 'agentMessage') return
  558. const text = typeof item.text === 'string'
  559. ? item.text
  560. : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
  561. if (item.phase === 'final_answer') {
  562. this.lastFinalAnswer = text
  563. } else if (item.phase === null) {
  564. this.lastUnphasedAnswer = text
  565. } else if (item.phase !== 'commentary') {
  566. throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
  567. }
  568. return
  569. }
  570. if (method !== 'turn/completed') return
  571. const threadId = string(params.threadId, 'turn/completed thread id')
  572. if (threadId !== this.threadId) return
  573. const turn = object(params.turn, 'turn/completed turn')
  574. const id = string(turn.id, 'turn/completed turn id')
  575. const turnCompleted = this.turnCompleted
  576. if (turnCompleted === undefined) return
  577. if (this.turnId === undefined) {
  578. this.observePendingTurnId(id)
  579. this.earlyTurnNotifications.push({
  580. method,
  581. params,
  582. order: this.nextObservationOrder(),
  583. })
  584. return
  585. }
  586. if (id !== this.turnId) return
  587. if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
  588. throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
  589. }
  590. turnCompleted.resolve({
  591. params,
  592. order: order ?? this.nextObservationOrder(),
  593. })
  594. }
  595. }