24 KiB
Codex App Server Provider Notes
These notes track the planned Promptfoo integration for the Codex app-server protocol. They are intentionally implementation-facing: keep them current as the provider, docs, examples, and verification expand.
For the broader coding-agent provider taxonomy, see
coding-agent-provider-taxonomy.md.
Objective
Add an experimental Promptfoo provider that drives codex app-server directly. The
provider should complement, not replace, the existing OpenAI Codex SDK provider:
- Codex SDK provider: best default for CI and automation.
- Codex app-server provider: best for evaluating rich-client behavior exposed by the Codex app-server protocol, including streamed item events, approvals, skills, plugins, apps, filesystem requests, and thread lifecycle primitives.
Primary provider IDs:
openai:codex-app-serveropenai:codex-app-server:<model>openai:codex-desktopopenai:codex-desktop:<model>
Optional top-level aliases may be added after the OpenAI-scoped provider is stable:
codex:app-servercodex:desktop
Source Material
- Official docs: https://developers.openai.com/codex/app-server
- Local CLI:
/Applications/Codex.app/Contents/Resources/codex app-server --help - Local generated schema command:
codex app-server generate-ts --out /tmp/codex-app-server-schema/ts
codex app-server generate-json-schema --out /tmp/codex-app-server-schema/json
Current local schema inspection was generated from codex-cli 0.118.0.
Protocol Shape
Transport:
stdio://default, JSONL messages.ws://IP:PORTexperimental, one JSON-RPC message per WebSocket text frame.
Handshake:
- Send
initializewith Promptfoo client metadata. - Send
initializednotification. - Start or resume a thread.
- Start a turn.
- Read notifications until
turn/completed.
Core client requests:
initializethread/startthread/resumethread/archivethread/unsubscribethread/readturn/startturn/steerturn/interruptreview/startmodel/listskills/listplugin/listplugin/readapp/list
High-risk client requests that should not be exposed casually:
fs/writeFilefs/removefs/copyconfig/value/writeconfig/batchWriteplugin/installplugin/uninstallcommand/exec
Core server notifications:
thread/startedthread/status/changedturn/startedturn/completeditem/starteditem/completeditem/agentMessage/deltaitem/commandExecution/outputDeltaitem/fileChange/outputDeltaitem/mcpToolCall/progressserverRequest/resolvedthread/tokenUsage/updatederror
Core server requests requiring deterministic Promptfoo responses:
item/commandExecution/requestApprovalitem/fileChange/requestApprovalitem/permissions/requestApprovalitem/tool/requestUserInputmcpServer/elicitation/requestitem/tool/call
Provider Contract
Inputs
Promptfoo prompt strings remain the default. The provider should also accept a JSON array of Codex input items:
[
{ "type": "text", "text": "Review this project" },
{ "type": "local_image", "path": "/absolute/path/to/screenshot.png" },
{ "type": "skill", "name": "skill-creator", "path": "/absolute/path/SKILL.md" }
]
Supported input item types for the first implementation:
textlocal_image, mapped to app-serverinputImageskill
Unknown prompt JSON should be treated as plain text instead of throwing.
Output
The provider response should include:
output: final assistant text, assembled fromitem/agentMessage/deltaand completedagentMessageitems.sessionId: thread id.raw: serialized protocol-level turn summary and selected notifications.metadata.codexAppServer: thread id, turn id, model, cwd, sandbox, approvals, server requests, item counts, command/file/tool trajectories, and app-server protocol data useful for debugging.metadata.skillCalls/metadata.attemptedSkillCalls: heuristic skill usage where available.tokenUsage: fromthread/tokenUsage/updatedif emitted.cost: estimated from Promptfoo's Codex pricing table when model is known.
Config
Provider-level config should be strict. Prompt-level merged config should strip unknown keys so generic Promptfoo prompt config does not break rows.
Core config:
apiKeybase_urlworking_diradditional_directoriesskip_git_repo_checkcodex_path_overridemodelmodel_providerservice_tiersandbox_modesandbox_policyapproval_policyapprovals_reviewermodel_reasoning_effortreasoning_summarypersonalityoutput_schemathread_idpersist_threadsthread_pool_sizeephemeralpersist_extended_historyexperimental_raw_eventsexperimental_apicli_configcli_envinherit_process_envreuse_serverdeep_tracingrequest_timeout_msstartup_timeout_msserver_request_policy
Safety Defaults
Default stance should favor repeatable evals over convenience:
approval_policy:neversandbox_mode:read-onlynetwork_access_enabled:falseephemeral:truereuse_server:trueunlessdeep_tracingis enabledinherit_process_env:false- Server-side approval requests: decline/cancel or empty grants unless explicitly configured.
Rationale:
- The app-server exposes shell, filesystem, app connector, plugin, and config surfaces.
- Promptfoo evals should be deterministic and should not block on human approval.
- Eval prompts and target behavior can be adversarial.
Implementation Phases
-
Stdio JSON-RPC client
- Spawn
codex app-server. - Parse JSONL stdout.
- Route responses, notifications, and server requests.
- Capture stderr for debug logs.
- Support abort and timeout.
- Spawn
-
Provider lifecycle
- Register with
providerRegistry. - Reuse app-server process by default.
- Shut down child processes, pending requests, and readline handles.
- Disable reuse when
deep_tracingis enabled.
- Register with
-
Thread and turn execution
- Validate working directory.
- Start/resume threads.
- Start turns with prompt input, model, cwd, sandbox, approvals, effort, personality, service tier, and output schema.
- Serialize turns per reused thread.
- Unsubscribe/archive non-persistent threads during cleanup.
-
Streaming aggregation
- Track
turnId. - Assemble assistant deltas.
- Store completed items.
- Build item counts and trajectory metadata.
- Capture command output, file changes, MCP calls, dynamic tool calls, web search, plans, reasoning summaries, and review output.
- Track
-
Server request handling
- Deterministically answer command approvals.
- Deterministically answer file-change approvals.
- Return empty permission grants by default.
- Support configured answers for
tool/requestUserInput. - Decline/cancel MCP elicitation by default.
- Support static dynamic-tool responses.
- Record all requests and decisions in metadata.
-
Tracing
- Wrap
callApiinwithGenAISpan. - Add item-level spans when streaming notifications arrive.
- Sanitize command output, tool arguments, and message text before trace attributes.
- Inject OTEL env when
deep_tracingis enabled.
- Wrap
-
Docs and examples
- Add provider docs.
- Add provider index entry.
- Add examples for basic usage, read-only repo review, structured output, approval handling, skills, and tracing.
-
Verification
- Unit tests with mocked child process and mocked protocol frames.
- Registry tests for provider IDs and model-in-path parsing.
- Docs/examples lint where applicable.
- Local smoke config using a harmless prompt and
sandbox_mode: read-onlyif credentials/login are available. - Final dogfood: run the new provider against the git diff and iterate on comments.
Progress Log
2026-04-09
- Added initial provider implementation at
src/providers/openai/codex-app-server.ts. - Added provider IDs under the OpenAI registry:
openai:codex-app-serveropenai:codex-app-server:<model>openai:codex-desktopopenai:codex-desktop:<model>
- Implemented stdio JSON-RPC lifecycle:
- spawn
codex app-server --listen stdio:// initializeinitializedthread/startthread/resumeturn/start- notification handling through
turn/completed thread/unsubscribe/thread/archivecleanup modes
- spawn
- Implemented safe config defaults:
approval_policy: neversandbox_mode: read-onlyephemeral: truethread_cleanup: unsubscribe- process env isolation unless
inherit_process_env: true
- Implemented deterministic server request responses:
- command execution approvals default to
decline - file changes default to
decline - permission requests default to empty grants
- user input requests default to empty answers
- MCP elicitations default to
decline - dynamic tools can use static configured responses
- command execution approvals default to
- Implemented output normalization:
- assistant delta aggregation
- completed
agentMessagefallback/preference - token usage from
thread/tokenUsage/updated - cost estimate for known Codex models
- metadata with item counts, items, server request decisions, thread/turn ids
- Implemented provider-level GenAI tracing and item spans.
- Added mocked protocol tests in
test/providers/openai-codex-app-server.test.ts. - Added registry tests in
test/providers/index.test.ts. - Verification so far:
npx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=falsenpx vitest run test/providers/index.test.ts -t "Codex app-server|Codex desktop" --sequence.shuffle=falsenpm run tsc -- --pretty false
- Expanded mocked protocol tests to cover:
thread/resume- structured prompt input normalization
- default
thread/unsubscribecleanup - user input request policy
- dynamic tool static response policy
- metadata sanitization
- Ran a real local smoke eval through
npm run local -- eval -c examples/openai-codex-app-server/promptfooconfig.yaml --no-cache -o /tmp/promptfoo-codex-app-server-example.json.- Result: pass.
- Provider returned Codex app-server
sessionId, token usage, item counts, thread id, turn id, and structured JSON output.
- Ran docs build:
cd site && SKIP_OG_GENERATION=true npm run build- Result: pass.
- First dogfood review through
examples/openai-codex-app-server/review-diff/promptfooconfig.yamlfound four actionable provider issues:- startup timeout could leak a spawned app-server and leave a rejected reusable connection promise cached
- reused connections closed over the first turn's server request policy
- app-server exit during a turn could leave the eval waiting forever when no turn timeout was configured
rawresponse payload serialized unsanitized protocol items
- Fixed the dogfood findings and added regression coverage:
- failed startup closes the process and a later call spawns a fresh process
- active turns store their effective config so prompt-level server request policies are honored on reused servers
- connection exit resolves active turns with a provider error and removes the dead connection from reuse maps
rawnow contains sanitized thread, turn, token usage, notifications, and item metadata- final output now uses the last completed
agentMessage, which avoids concatenating progress messages with final structured review output
- Verification after fixes:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 13 provider tests.
- Second dogfood review passed the Promptfoo eval and returned valid JSON, but still
reported two provider comments:
- legacy
execCommandApproval/applyPatchApprovalrequests identify the active thread withconversationIdand expect legacy review decisions - persistent thread-pool eviction deleted local handles without unsubscribing the evicted loaded thread
- legacy
- Additional hardening from the second dogfood pass:
- stdio parser now buffers partial JSON-RPC lines and rejoins literal newlines inside command-output strings as escaped newlines before parsing
- legacy approval requests now map prompt-level policy to
approved,approved_for_session,denied, andabort - legacy server requests can find active turn state by
conversationId - evicted persistent cached threads now send
thread/unsubscribebefore being removed - added regression coverage for literal-newline JSON-RPC notifications, legacy approval requests, and persistent thread-pool eviction
- Verification after second dogfood fixes:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 16 provider tests.
- Third dogfood review passed transport/eval and reported two lifecycle comments:
- stale persistent thread handles remained after a reused app-server process exited
- JSON-RPC request timeout cleanup removed the pending request but left an abort listener attached
- Additional hardening from the third dogfood pass:
- connection close now removes cached thread handles owned by that connection key
- per-request timeout cleanup now removes abort listeners before rejecting
- added regression coverage for cached-thread invalidation after process exit and abort-listener cleanup on JSON-RPC timeout
- Verification after third dogfood fixes:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 18 provider tests.
- Fourth dogfood review passed transport/eval and reported two thread-cache comments:
- persistent thread caching was still enabled for fresh-per-call app-server processes
(
reuse_server: falseordeep_tracing) - pool eviction could unsubscribe an active cached thread before its turn completed
- persistent thread caching was still enabled for fresh-per-call app-server processes
(
- Additional hardening from the fourth dogfood pass:
- thread caching is now allowed only when the app-server connection itself is reusable
- active/reserved thread ids are protected with a small refcount while a call is using them
- thread-pool eviction skips protected threads and temporarily allows the pool to exceed its soft cap rather than evicting an in-flight turn
- added regression coverage for non-reusable persistent-thread configs and active-turn eviction avoidance
- Verification after fourth dogfood fixes:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 20 provider tests.
- Fifth dogfood review passed transport/eval and reported one persistent-thread race:
- concurrent calls with the same persistent-thread cache key could both miss the cache
while the first
thread/startwas still pending, creating duplicate persistent threads and leaking the earlier one
- concurrent calls with the same persistent-thread cache key could both miss the cache
while the first
- Additional hardening from the fifth dogfood pass:
- added an in-flight thread promise map keyed by thread cache key
- concurrent same-cache
thread/start/thread/resumecallers now share the same pending thread handle - added regression coverage for concurrent same-cache persistent calls, ensuring only
one
thread/startis sent and both turns use the shared thread
- Verification after fifth dogfood fix:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 21 provider tests.
- Sixth dogfood review passed transport/eval and reported two cache/default comments:
- reusable connections could keep the first request timeout for requests that did not pass a per-call timeout
- persistent thread cache keys omitted thread-start options such as
ephemeral,experimental_raw_events, andpersist_extended_history
- Additional hardening from the sixth dogfood pass:
- all provider-owned app-server requests now pass the effective per-call request timeout explicitly
- persistent thread cache keys now include thread-start options that can change thread semantics
- added regression coverage for prompt-level request timeouts on reused connections and thread-start option changes in persistent cache keys
- Verification after sixth dogfood fixes:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 23 provider tests.
- Seventh dogfood retry:
- first attempt hit an external Codex connectivity failure (
Network is unreachable,Reconnecting... 2/5), which the provider surfaced as a clean provider error - retry completed transport/eval and reported one metadata issue: skill-root detection used the parent process env instead of the resolved app-server child env
- first attempt hit an external Codex connectivity failure (
- Additional hardening from the seventh dogfood pass:
- turn state now carries the resolved app-server environment produced by
prepareEnvironment - skill root detection now uses the child env for
CODEX_HOME,HOME, andUSERPROFILE - added regression coverage for
cli_env.HOMEskill-call metadata detection
- turn state now carries the resolved app-server environment produced by
- Verification after seventh dogfood fix:
npm run tsc -- --pretty falsenpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false- Result: pass, 24 provider tests.
- Eighth dogfood review:
npm run local -- eval -c examples/openai-codex-app-server/review-diff/promptfooconfig.yaml --no-cache --no-share -o /tmp/promptfoo-codex-app-server-review.json- Result: pass.
- Provider output:
{"comments":[],"summary":"No actionable findings; TypeScript and focused provider tests passed."} - This confirms the provider can be used to review the current git diff and return schema-valid JSON with no remaining actionable comments from the dogfood reviewer.
- Final verification sweep:
npm run f: pass with existing complexity warnings only; no formatting changes needednpm run tsc -- --pretty false: passnpx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false: pass, 24 provider testsnpx vitest run test/providers/index.test.ts -t "Codex app-server|Codex desktop" --sequence.shuffle=false: pass, 2 registry testsnpm run l: pass with existing complexity warnings onlycd site && SKIP_OG_GENERATION=true npm run build: passnpm run local -- eval -c examples/openai-codex-app-server/promptfooconfig.yaml --no-cache --no-share -o /tmp/promptfoo-codex-app-server-example.json: pass
QA Matrix
Required mocked unit tests:
- Constructor defaults and strict config validation.
- Prompt-level unknown config stripping.
- Missing/inaccessible/non-directory working directory.
- Git check and
skip_git_repo_check. - API key/env isolation and explicit
cli_env. - Handshake order:
initialize,initialized,thread/start,turn/start. - Model from provider path overrides/defaults correctly.
thread_idusesthread/resume.persist_threadsreuses cached thread and serializes turns.- Non-persistent calls unsubscribe/archive as configured.
- Assistant deltas aggregate into final output.
- Completed
agentMessagefallback works when deltas are missing. - Token usage from
thread/tokenUsage/updated. - Error notification produces provider error.
- Failed turn produces provider error.
- Abort before start.
- Abort during turn sends
turn/interruptand returns aborted error. - Command approval request default decline.
- File change request default decline.
- Permission request default empty grant.
- User input request configured answers.
- Dynamic tool call configured static response.
- MCP elicitation default decline/cancel.
- Metadata contains item counts, trajectories, approvals, raw notifications, and server request decisions without leaking API keys.
cleanupkills child process and unregisters provider.deep_tracinginjects OTEL env and disables reuse/thread persistence.- Provider-level GenAI tracing records response body, token usage, session id, and item count attributes.
Required docs/examples checks:
- Provider docs render in Docusaurus.
- Examples are listed and runnable from repo root with
npm run local -- eval ... --no-cache. - Config docs call out experimental status, safety defaults, and difference from Codex SDK.
Open Questions
- Whether to expose WebSocket transport in the first public version. Stdio is enough for Promptfoo-managed app-server processes; WebSocket is useful for external clients but adds auth and lifecycle complexity.
- Whether to support top-level
codex:*aliases immediately or keep all new IDs underopenai:*for consistency with the existing Codex SDK provider. - Whether to persist generated app-server protocol types in source. The current plan is to implement a narrow local type surface and document how to regenerate schemas instead of committing a large generated bundle.
Critical Audit Follow-up
Review feedback and red-team audit items addressed after the initial dogfood pass:
- Fixed registry env propagation for object-shaped provider configs.
loadApiProvideralready merges suite-level and provider-level env intoproviderOptions.env; the registry now passes that merged env toOpenAICodexAppServerProvider. - Fixed
service_tierto match the generated app-server schema fromcodex-cli 0.118.0:fastandflexonly. - Reset the hoisted
spawnmock implementation inbeforeEachto satisfytest/AGENTS.mdmock isolation rules. - Regenerated app-server TypeScript and JSON Schema into
/tmp/codex-app-server-schema-current.XVTCwLduring the audit and compared rare app-server fields against the implementation. - Added coverage for schema-supported rare fields:
model_reasoning_effort: none- exact
personalityvalues:none,friendly,pragmatic - granular approval policy objects
- app-server command approval amendment objects
- session-scoped permission grants
- accepted MCP elicitation responses with content and metadata
base_instructions,developer_instructions, andcollaboration_mode
- Dogfood review then found additional issues:
- reusable app-server connections stayed alive after JSON-RPC request timeouts, which could leave late side-effecting responses unmanaged
- docs listed
thread_pool_sizeas unlimited even though the implementation defaults to1 - provider cleanup cleared active turns before resolving them, which could hang shutdown while a turn was in flight
- raw JSON-RPC notifications were retained even when
include_raw_eventswas false - spawned app-server processes could be missed if cleanup ran while
initializewas still pending - concurrent persistent thread starts could temporarily exceed
thread_pool_sizeand remain over capacity after active turns finished - deep-tracing calls that shared a
thread_idcould overlap turns because the queue key returned early - the OpenAI provider docs heading change would have broken the existing
#codex-sdkanchor - default
thread_idresumes skipped unsubscribe cleanup - sent JSON-RPC request aborts kept the reusable app-server alive
- retryable app-server
errornotifications withwillRetry: truewere treated as terminal - concurrent default-cleanup
thread_idrows could unsubscribe while another row was queued for the same thread
- Fixed these by closing/evicting connections on timeout and abort, resolving active
turns during cleanup, tracking pending initialization processes, making raw event
retention opt-in, rebalancing the persistent thread pool after turns finish, serializing
explicit
thread_idturns even under deep tracing, preserving the OpenAI docs#codex-sdkheading, default-unsubscribing non-persistent resumed threads, honoring retryable app-server errors, and deferring resumed-thread unsubscribe until no other protected queued caller remains. - Updated docs to explain why the app-server provider should stay separate from the Codex SDK provider: the SDK is the right default for CI and automation, while app-server is for rich-client protocol event surfaces and does not attach to a running Codex Desktop app.
Latest focused verification after these fixes:
npx vitest run test/providers/openai-codex-app-server.test.ts --sequence.shuffle=false: pass, 35 provider tests.npm run local -- eval -c examples/openai-codex-app-server/review-diff/promptfooconfig.yaml --no-cache --no-share: pass with{"comments":[],"summary":"No actionable issues found in the current diff."}.