| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- /**
- * Minimal Codex app-server 0.147.0 protocol adapter. The shared JSON-RPC
- * transport owns framing and request correlation; this module owns only the
- * product methods, current thread/turn association, unattended approval
- * responses, and terminal-answer selection.
- *
- * @module @deepseek-ai/dsh-subagent-codex/wire
- */
- import type { Readable, Writable } from 'node:stream'
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
- import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
- import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
- import type { CodexPermissionMode } from './run.ts'
- type JsonObject = Record<string, unknown>
- const THREAD_PERMISSION_PARAMS: Readonly<Record<CodexPermissionMode, JsonObject>> = {
- never: { approvalPolicy: 'never' },
- 'approve-for-me': {
- approvalPolicy: 'on-request',
- approvalsReviewer: 'auto_review',
- sandbox: 'workspace-write',
- },
- 'dangerously-bypass-approvals-and-sandbox': {
- approvalPolicy: 'never',
- sandbox: 'danger-full-access',
- },
- }
- const STDERR_PERMISSION_SIGNATURES = [
- {
- text: 'approval policy is Never; reject command',
- request: 'command execution',
- decision: 'denied',
- reason: 'Codex rejected an escalation because the selected policy never asks for approval',
- },
- {
- text: 'recorded sandbox violation:',
- request: 'sandbox execution',
- decision: 'failed',
- reason: 'Codex reported a sandbox violation',
- },
- ] as const
- const STDERR_SIGNATURE_TAIL_CHARS = Math.max(
- ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length),
- ) - 1
- function stderrSignatureTail(value: string): string {
- for (
- let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length)
- ; length > 0
- ; length -= 1
- ) {
- const tail = value.slice(-length)
- if (STDERR_PERMISSION_SIGNATURES.some(signature =>
- tail.length < signature.text.length && signature.text.startsWith(tail))) {
- return tail
- }
- }
- return ''
- }
- function object(value: unknown, label: string): JsonObject {
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
- throw new Error(`subagent-codex: app-server returned invalid ${label}`)
- }
- return value as JsonObject
- }
- function string(value: unknown, label: string): string {
- if (typeof value !== 'string' || value.length === 0) {
- throw new Error(`subagent-codex: app-server returned invalid ${label}`)
- }
- return value
- }
- function unattendedDecision(params: JsonObject): 'cancel' | 'decline' {
- const available = params.availableDecisions
- if (available === undefined || available === null) return 'decline'
- if (Array.isArray(available)) {
- if (available.includes('cancel')) return 'cancel'
- if (available.includes('decline')) return 'decline'
- }
- throw new Error('subagent-codex: app-server offered no unattended approval decision')
- }
- function isContextWindowExceeded(turn: JsonObject): boolean {
- if (turn.status !== 'failed') return false
- const error = turn.error
- return error !== null
- && typeof error === 'object'
- && !Array.isArray(error)
- && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded'
- }
- function isSandboxFailure(turn: JsonObject): boolean {
- if (turn.status !== 'failed') return false
- const error = turn.error
- return error !== null
- && typeof error === 'object'
- && !Array.isArray(error)
- && (error as JsonObject).codexErrorInfo === 'sandboxError'
- }
- function unattendedDiagnostic(
- mode: CodexPermissionMode,
- request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution',
- decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed',
- reason: string,
- ): string {
- return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}`
- }
- function thrown(value: unknown): Error {
- /* v8 ignore next -- typed protocol and stream failures reject with Error. */
- return value instanceof Error ? value : new Error(String(value))
- }
- function abortError(signal: AbortSignal): Error {
- return signal.reason instanceof Error
- ? signal.reason
- : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`)
- }
- async function raceAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
- if (signal.aborted) {
- void pending.catch(() => {})
- throw abortError(signal)
- }
- let rejectAbort!: (error: Error) => void
- const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
- const onAbort = (): void => { rejectAbort(abortError(signal)) }
- signal.addEventListener('abort', onAbort, { once: true })
- try {
- return await Promise.race([pending, aborted])
- } finally {
- signal.removeEventListener('abort', onAbort)
- }
- }
- /**
- * One app-server connection and its single ephemeral thread/turn.
- *
- * The class deliberately exposes no generic request surface. Supporting
- * another product method must first become part of the provider contract.
- */
- export class CodexAppServerWire {
- private readonly transport: JsonRpcLineTransport
- private readonly fatal = Promise.withResolvers<never>()
- private threadId: string | undefined
- private turnId: string | undefined
- private pendingTurnId: string | undefined
- private turnCompleted: PromiseWithResolvers<{
- readonly params: JsonObject
- readonly order: number
- }> | undefined
- private readonly earlyTurnNotifications: Array<{
- readonly method: string
- readonly params: JsonObject
- readonly order: number
- }> = []
- private lastFinalAnswer: string | undefined
- private lastUnphasedAnswer: string | undefined
- private diagnostic: string | undefined
- private diagnosticOrder = 0
- private observationOrder = 0
- private pendingDiagnostic: {
- readonly order: number
- readonly request: Parameters<typeof unattendedDiagnostic>[1]
- readonly decision: Parameters<typeof unattendedDiagnostic>[2]
- readonly reason: string
- } | undefined
- private stderrTail = ''
- private closed = false
- constructor(
- private readonly input: Readable,
- output: Writable,
- private readonly permissionMode: CodexPermissionMode,
- ) {
- this.transport = new JsonRpcLineTransport(input, output)
- // Fatal protocol state can arrive after the current guarded operation has
- // already settled. Keep the shared rejection observed without inserting
- // another promise-adoption hop into active races.
- void this.fatal.promise.catch(() => {})
- this.transport.onRequest((method, params) => this.handleServerRequest(method, params))
- this.transport.onNotification((method, params) => {
- try {
- this.handleNotification(method, params)
- } catch (error: unknown) {
- this.fail(thrown(error))
- }
- })
- this.input.on('error', this.onInputError)
- this.input.on('end', this.onInputEnd)
- // Pipe errors can race protocol closure and process teardown. Retain both
- // error listeners for the lifetime of their per-run streams so no late
- // EPIPE or read failure becomes an unhandled EventEmitter error.
- output.on('error', this.onOutputError)
- }
- /** Start reading app-server frames. */
- start(): void {
- this.transport.start()
- }
- /**
- * Perform the required app-server initialize/initialized handshake.
- * @param signal - unpublished-start cancellation.
- */
- async initialize(signal: AbortSignal): Promise<void> {
- object(await this.guarded(this.transport.request('initialize', {
- clientInfo: {
- name: 'deepseek-harness',
- title: 'DeepSeek Harness',
- version: '0.0.1',
- },
- capabilities: {
- experimentalApi: false,
- requestAttestation: false,
- },
- }, signal), signal), 'initialize response')
- this.transport.notify('initialized')
- await this.guarded(this.transport.flush(), signal)
- }
- /**
- * Create the run's private ephemeral thread and retain its identity.
- * @param cwd - parent Session workspace.
- * @param signal - unpublished-start cancellation.
- */
- async startThread(cwd: string, signal: AbortSignal): Promise<void> {
- const response = object(await this.guarded(this.transport.request('thread/start', {
- cwd,
- ephemeral: true,
- ...THREAD_PERMISSION_PARAMS[this.permissionMode],
- }, signal), signal), 'thread/start response')
- const thread = object(response.thread, 'thread/start thread')
- const id = string(thread.id, 'thread/start thread id')
- if (thread.ephemeral !== true) {
- throw new Error('subagent-codex: app-server did not create an ephemeral thread')
- }
- this.threadId = id
- }
- /**
- * Submit the one text-only task and wait for this thread/turn's authoritative
- * terminal notification.
- * @param texts - already validated task text blocks.
- * @param signal - local cancellation for the published run.
- * @returns the shared subagent result.
- */
- async runTurn(
- texts: readonly string[],
- signal: AbortSignal,
- ): Promise<SubagentResult> {
- const completion = Promise.withResolvers<{
- readonly params: JsonObject
- readonly order: number
- }>()
- this.turnCompleted = completion
- const threadId = this.threadId as string
- const response = object(await this.guarded(this.transport.request('turn/start', {
- threadId,
- input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
- }, signal), signal), 'turn/start response')
- const turn = object(response.turn, 'turn/start turn')
- this.commitTurnId(string(turn.id, 'turn/start turn id'))
- const completed = await this.guarded(completion.promise, signal)
- const terminal = object(completed.params.turn, 'turn/completed turn')
- const status = terminal.status
- if (isContextWindowExceeded(terminal)) {
- return { output: this.collectOutput(), stopReason: 'max-tokens' }
- }
- if (status !== 'completed') {
- const sandboxFailure = isSandboxFailure(terminal)
- if (sandboxFailure) {
- this.recordDiagnostic(
- 'sandbox execution',
- 'failed',
- 'Codex reported a sandbox failure',
- completed.order,
- )
- }
- const detail = status === 'failed'
- ? sandboxFailure
- ? ': sandboxError'
- : ': error'
- : ''
- throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`)
- }
- const output = this.collectOutput()
- if (output.length === 0) {
- throw new Error('subagent-codex: Codex completed without a final answer')
- }
- return { output, stopReason: 'completed' }
- }
- /**
- * Best-effort remote cancellation. Local settlement and process teardown
- * remain authoritative when the child no longer accepts protocol requests.
- */
- interrupt(): void {
- if (this.threadId === undefined || this.turnId === undefined || this.closed) return
- void this.transport.request('turn/interrupt', {
- threadId: this.threadId,
- turnId: this.turnId,
- }).catch(() => {})
- }
- /**
- * The best non-commentary answer observed so far, preserving exact bytes.
- * @returns the selected final or nullable-phase text block, if any.
- */
- collectOutput(): ContentBlock[] {
- const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer
- return selected !== undefined && selected.trim().length > 0
- ? [{ type: 'text', text: selected }]
- : []
- }
- /**
- * The latest safe unattended permission fact observed for this run.
- * @returns provider-authored diagnostic text, when one was observed.
- */
- collectDiagnostic(): string | undefined {
- return this.diagnostic
- }
- /**
- * Observe product stderr while retaining only enough tail to recognize fixed
- * permission signatures. The raw text is never copied into the diagnostic.
- * @param chunk - one decoded stderr chunk already forwarded to the host.
- */
- observeStderr(chunk: string): void {
- const observed = `${this.stderrTail}${chunk}`
- let latestIndex = -1
- let latest: (typeof STDERR_PERMISSION_SIGNATURES)[number] | undefined
- for (const signature of STDERR_PERMISSION_SIGNATURES) {
- const index = observed.lastIndexOf(signature.text)
- if (index > latestIndex) {
- latestIndex = index
- latest = signature
- }
- }
- if (latest !== undefined) {
- this.recordDiagnostic(latest.request, latest.decision, latest.reason)
- }
- this.stderrTail = stderrSignatureTail(observed)
- }
- /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */
- close(): void {
- if (this.closed) return
- this.closed = true
- this.input.off('end', this.onInputEnd)
- this.transport.close()
- }
- private async guarded<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
- const withFatal = Promise.race([this.fatal.promise, pending])
- return raceAbort(withFatal, signal)
- }
- private fail(error: Error): void {
- this.fatal.reject(error)
- }
- private readonly onInputError = (error: Error): void => {
- this.fail(error)
- }
- private readonly onOutputError = (error: Error): void => {
- this.fail(error)
- }
- private readonly onInputEnd = (): void => {
- this.fail(new Error('subagent-codex: app-server protocol stream closed'))
- }
- private observePendingTurnId(id: string): void {
- if (this.turnCompleted === undefined) {
- throw new Error('subagent-codex: app-server referenced a turn before turn/start')
- }
- if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
- throw new Error('subagent-codex: app-server referenced conflicting turns')
- }
- this.pendingTurnId = id
- }
- private commitTurnId(id: string): void {
- if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) {
- throw new Error('subagent-codex: turn/start response did not match the active turn')
- }
- this.turnId = id
- const pendingDiagnostic = this.pendingDiagnostic
- this.pendingDiagnostic = undefined
- if (pendingDiagnostic !== undefined) {
- this.recordDiagnostic(
- pendingDiagnostic.request,
- pendingDiagnostic.decision,
- pendingDiagnostic.reason,
- pendingDiagnostic.order,
- )
- }
- const notifications = this.earlyTurnNotifications.splice(0)
- for (const notification of notifications) {
- this.handleNotification(
- notification.method,
- notification.params,
- notification.order,
- )
- }
- }
- /**
- * Validate the request's thread and turn association.
- * @returns `true` when the matching turn is still provisional, so the caller
- * defers its diagnostic until `commitTurnId()`.
- */
- private validateRunIds(
- params: JsonObject,
- nullableTurn = false,
- ): boolean {
- if (params.threadId !== this.threadId) {
- throw new Error('subagent-codex: app-server request referenced another thread')
- }
- if (nullableTurn && params.turnId === null) return false
- const id = string(params.turnId, 'server request turn id')
- if (this.turnId === undefined) {
- this.observePendingTurnId(id)
- return true
- }
- if (id !== this.turnId) {
- throw new Error('subagent-codex: app-server request referenced another turn')
- }
- return false
- }
- private recordRequestDiagnostic(
- provisional: boolean,
- request: Parameters<typeof unattendedDiagnostic>[1],
- decision: Parameters<typeof unattendedDiagnostic>[2],
- reason: string,
- ): void {
- const order = this.nextObservationOrder()
- if (provisional) {
- this.pendingDiagnostic = {
- order,
- request,
- decision,
- reason,
- }
- return
- }
- this.recordDiagnostic(request, decision, reason, order)
- }
- private recordDiagnostic(
- request: Parameters<typeof unattendedDiagnostic>[1],
- decision: Parameters<typeof unattendedDiagnostic>[2],
- reason: string,
- order = this.nextObservationOrder(),
- ): void {
- if (order < this.diagnosticOrder) return
- this.diagnosticOrder = order
- this.diagnostic = unattendedDiagnostic(
- this.permissionMode,
- request,
- decision,
- reason,
- )
- }
- private nextObservationOrder(): number {
- this.observationOrder += 1
- return this.observationOrder
- }
- private recordDeclinedItem(item: JsonObject, order?: number): boolean {
- if (item.type === 'commandExecution' && item.status === 'declined') {
- this.recordDiagnostic(
- 'command execution',
- 'declined',
- 'Codex declined the command under the selected permission mode',
- order,
- )
- return true
- }
- if (item.type === 'fileChange' && item.status === 'declined') {
- this.recordDiagnostic(
- 'file change',
- 'declined',
- 'Codex declined the file change under the selected permission mode',
- order,
- )
- return true
- }
- return false
- }
- private handleServerRequest(method: string, params: JsonObject): Promise<unknown> {
- try {
- switch (method) {
- case 'item/commandExecution/requestApproval':
- {
- const provisional = this.validateRunIds(params)
- const decision = unattendedDecision(params)
- this.recordRequestDiagnostic(
- provisional,
- 'command approval',
- decision === 'cancel' ? 'cancelled' : 'declined',
- 'the provider does not grant interactive approval',
- )
- return Promise.resolve({ decision })
- }
- case 'item/fileChange/requestApproval':
- {
- const provisional = this.validateRunIds(params)
- const decision = unattendedDecision(params)
- this.recordRequestDiagnostic(
- provisional,
- 'file approval',
- decision === 'cancel' ? 'cancelled' : 'declined',
- 'the provider does not grant interactive approval',
- )
- return Promise.resolve({ decision })
- }
- case 'item/permissions/requestApproval':
- this.recordRequestDiagnostic(
- this.validateRunIds(params),
- 'permission grant',
- 'denied',
- 'the provider grants no additional turn permissions',
- )
- return Promise.resolve({ permissions: {}, scope: 'turn' })
- case 'item/tool/requestUserInput':
- this.recordRequestDiagnostic(
- this.validateRunIds(params),
- 'user input',
- 'empty response',
- 'the provider does not collect interactive answers',
- )
- return Promise.resolve({ answers: {} })
- case 'mcpServer/elicitation/request':
- this.recordRequestDiagnostic(
- this.validateRunIds(params, true),
- 'MCP elicitation',
- 'declined',
- 'the provider does not collect interactive MCP input',
- )
- return Promise.resolve({ action: 'decline', content: null, _meta: null })
- default:
- throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`)
- }
- } catch (error: unknown) {
- const normalized = thrown(error)
- this.fail(normalized)
- return Promise.reject(normalized)
- }
- }
- private handleNotification(
- method: string,
- params: JsonObject,
- order?: number,
- ): void {
- if (method === 'turn/started') {
- const threadId = string(params.threadId, 'turn/started thread id')
- if (threadId !== this.threadId) return
- const turn = object(params.turn, 'turn/started turn')
- if (this.turnCompleted !== undefined && this.turnId === undefined) {
- this.observePendingTurnId(string(turn.id, 'turn/started turn id'))
- }
- return
- }
- if (method === 'item/completed') {
- const threadId = string(params.threadId, 'item/completed thread id')
- if (threadId !== this.threadId) return
- const id = string(params.turnId, 'item/completed turn id')
- if (this.turnId === undefined) {
- if (this.turnCompleted !== undefined) {
- this.observePendingTurnId(id)
- this.earlyTurnNotifications.push({
- method,
- params,
- order: this.nextObservationOrder(),
- })
- }
- return
- }
- if (id !== this.turnId) return
- const item = object(params.item, 'item/completed item')
- if (this.recordDeclinedItem(item, order)) return
- if (item.type !== 'agentMessage') return
- const text = typeof item.text === 'string'
- ? item.text
- : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
- if (item.phase === 'final_answer') {
- this.lastFinalAnswer = text
- } else if (item.phase === null) {
- this.lastUnphasedAnswer = text
- } else if (item.phase !== 'commentary') {
- throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
- }
- return
- }
- if (method !== 'turn/completed') return
- const threadId = string(params.threadId, 'turn/completed thread id')
- if (threadId !== this.threadId) return
- const turn = object(params.turn, 'turn/completed turn')
- const id = string(turn.id, 'turn/completed turn id')
- const turnCompleted = this.turnCompleted
- if (turnCompleted === undefined) return
- if (this.turnId === undefined) {
- this.observePendingTurnId(id)
- this.earlyTurnNotifications.push({
- method,
- params,
- order: this.nextObservationOrder(),
- })
- return
- }
- if (id !== this.turnId) return
- if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) {
- throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`)
- }
- turnCompleted.resolve({
- params,
- order: order ?? this.nextObservationOrder(),
- })
- }
- }
|