codex

package
v0.18.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 3, 2026 License: MIT Imports: 18 Imported by: 0

README

provider/codex

Implements the agent.Provider contract against codex app-server for the v0.5.0 Go agent runner. Per F.1.1 §3.2 + F.0.1 codex deep-dive.

Architecture

One codex.Provider instance owns exactly one codex app-server subprocess. Sessions are JSON-RPC thread/start calls that multiplex over the same stdio pipe; each codex.Handle subscribes to notifications matching its threadId.

codex.Provider
  └── child process: `codex app-server` (long-lived JSON-RPC over stdio)
        ├── thread_1 (Handle A) — independent agent.Spec
        ├── thread_2 (Handle B)
        └── thread_N

This mirrors the legacy TS packages/core/src/providers/codex-app-server-provider.tsAppServerProcessManager + AppServerAgentHandle pair.

Why no exec fallback

Per F.0.1 §6 (item 2) and the F.2.4 dispatch brief: v0.5.0 ships app-server only. The legacy TS codex exec fallback was a band-aid for stale codex binaries; Wave 6 requires a known-good codex on PATH. If codex is missing or the JSON-RPC initialize handshake fails, codex.New returns agent.ErrProviderUnavailable so the runner fails fast before doing any worktree work.

Capability matrix (F.1.1 §3.2 lock)

Capability v0.5.0
SupportsMessageInjection false
SupportsSessionResume true
SupportsToolPlugins true
NeedsBaseInstructions true
NeedsPermissionConfig true
SupportsCodeIntelligenceEnforcement false
EmitsSubagentEvents false
SupportsReasoningEffort true
ToolPermissionFormat codex

SupportsMessageInjection is false because Handle.Inject is hard- wired to agent.ErrUnsupported. The legacy TS provider does support mid-turn steering via turn/steer, but the v0.5.0 Go port keeps the surface minimal — steering flows through Provider.Resume + a fresh Spec.

Approval bridge

The codex app-server fires JSON-RPC server-requests (id + method) for every tool execution when the session's approvalPolicy is on-request. The bridge in approval.go translates each request into an accept / decline / acceptForSession reply against:

  1. Built-in safety deny patterns (always enforced; cannot be overridden) — rm -rf /, git worktree remove/prune, git reset --hard, git push --force (without --force-with-lease), sudo, curl … | bash, recursive chmod/chown on absolute paths.
  2. Spec.PermissionConfig.DisallowPatterns — user-supplied regex denies, evaluated in order.
  3. Spec.PermissionConfig.AllowPatterns — when present, ONLY matching commands are accepted; everything else is declined.
  4. Default decisionallow (default) → acceptForSession; deny / promptdecline (autonomous mode cannot prompt).

Every approval emits a synthetic agent.ToolUseEvent + agent.ToolResultEvent so the runner sees the call flow even when it auto-approves. Declined approvals additionally emit a agent.SystemEvent{Subtype: "approval_denied"} for observability.

The bridge ships in v0.5.0 (per F.1.1 open-question #5: ship the bridge, not default-allow — autonomous fleets need real safety rules).

MCP servers

Spec.MCPServers is pushed to the app-server via JSON-RPC config/batchWrite (mcpServers keyPath, replace merge strategy) exactly once per Provider lifetime, on the first Spawn. The app-server then runs each MCP server as its own subprocess; the codex side discovers and routes tools without further help from this provider.

If the codex version returns -32601 Method not found for config/batchWrite, the call is treated as a soft failure — the provider still works, sessions just run without tool plugins.

Failure modes (F.1.1 §5)

  • Transient JSON-RPC errorRequestWithRetry does 3 attempts with 1s/2s/4s backoff. Permanent errors (parse / invalid request / method not found) return immediately.
  • App-server crash → the JSON-RPC client's read loop sees EOF / pipe-closed, fires onClose, and the Provider marks every live Handle terminal with agent.ErrorEvent{Code: "app_server_crashed"}.
  • ctx.Done() on a Handle → forwarder sends turn/interrupt + thread/unsubscribe, emits agent.ErrorEvent{Code: "context_cancelled"}, closes events.
  • Server-request with no handler → JSON-RPC -32601 Method not found reply so codex doesn't hang on us, plus a agent.SystemEvent{Subtype: "unhandled_server_request"} for observability.

Event-channel close protocol (REN-1460)

The events channel on Handle has multiple potential closers — the forwarder goroutine's defer (terminal event reached), Stop (caller- initiated teardown), and failNow (Provider's onClientClose hook fires when the shared app-server stream dies). Closes can race in either direction with sends from emit, the synthetic app_server_crashed ErrorEvent, and the forwarder's context_cancelled ErrorEvent.

The close protocol enforces:

  1. eventsClosed atomic.Bool is the single source of truth for "events has been closed".
  2. closeEvents() takes eventsMu.Lock(), flips eventsClosed, then close(h.events). Idempotent under the flag check.
  3. emit() takes eventsMu.RLock(), returns early when eventsClosed is set, otherwise selects on send vs h.closed so a slow consumer does not pin shutdown.
  4. signalClosed() closes h.closed exactly once via a dedicated closedOnce (separated from the prior shared closeOnce so each path runs its own RPC-side cleanup independently of who wins the close race).

Both Stop and failNow are safe to call concurrently with each other and with the forwarder; whichever runs first owns the user- visible close, the others are silent no-ops via the idempotent helpers.

Additionally, Client.Request re-checks pending.ch before returning on its <-ctx.Done / <-timeoutCh / <-c.doneCh cases. Without that re-check, a response delivered in the same instant as a client-stop broadcast was lost ~50% of the time to Go's random-select tie-break, surfacing as a spurious client stopped Spawn failure.

File layout

File Responsibility
doc.go Package overview
codex.go Provider lifecycle (New / Spawn / Resume / Shutdown)
jsonrpc.go Bidirectional JSON-RPC 2.0 client over stdio
handle.go Per-session Handle + forwarder goroutine
approval.go Approval bridge (Spec.PermissionConfig → decision)
spec_translation.go agent.Spec → JSON-RPC param mapping
event_mapping.go JSON-RPC notification → agent.Event mapping
signal_unix.go SIGTERM lookup (unix)
signal_windows.go os.Interrupt fallback (windows; out of scope)

Testing

  • *_test.go — unit tests using a fake stdio JSON-RPC server.
  • integration_test.go (build-tagged codex_integration) — smoke test against a real codex app-server if installed.
# Unit tests (default)
go test -race ./provider/codex/

# Integration tests (requires codex + OPENAI_API_KEY)
go test -tags codex_integration -timeout 120s ./provider/codex/

What was intentionally dropped vs legacy TS

  • codex exec fallback — F.0.1 §6 item 2 calls this a band-aid; v0.5.0 fails fast instead.
  • turn/steer mid-turn injectionHandle.Inject returns agent.ErrUnsupported. F.5 may revisit if the runner needs it.
  • Reasoning-delta coalescing — the legacy TS buffers reasoning text streams to avoid char-by-char log spam. Out of scope for the provider; the runner can coalesce its own log output.
  • PID-file orphan-killing — the legacy TS writes ~/.donmai/codex-app-server.pid to detect stranded processes on restart. Wave 6 daemon owns subprocess lifecycle (REN-1408+); the provider does not duplicate.

See also

  • ../agentfactory/packages/core/src/providers/codex-app-server-provider.ts (read-only legacy reference, 1928 LOC)
  • ../agentfactory/packages/core/src/providers/codex-approval-bridge.ts (read-only legacy reference, 124 LOC)
  • F.1.1 design doc §3.2
  • F.0.1 codex deep-dive

Documentation

Overview

Package codex implements the agent.Provider contract against the `codex app-server` JSON-RPC subprocess.

Architecture (one Provider, N Handles, one shared subprocess):

codex.Provider
  └── child process: `codex app-server` (long-lived, JSON-RPC over stdio)
        ├── thread_1 (Handle A) — independent agent.Spec
        ├── thread_2 (Handle B)
        └── thread_N (Handle …)

One Provider instance owns exactly one app-server subprocess. Sessions are multiplexed as JSON-RPC `thread/start` calls; each Handle subscribes to notifications matching its threadId. This mirrors the legacy TS `CodexAppServerProvider`/`AppServerProcessManager` pair from ../agentfactory/packages/core/src/providers/codex-app-server-provider.ts.

Why no exec fallback

Per F.0.1 §6 (item 2) and F.1.1 §3.2 the v0.5.0 Go port ships app-server only. The legacy `codex exec` band-aid covered stale codex binaries; Wave 6 requires a known-good codex on PATH. If the binary is missing, codex.New returns agent.ErrProviderUnavailable so the runner fails fast before doing any worktree work.

Capability matrix (locked in F.1.1 §3.2)

  • SupportsMessageInjection : false (Codex CLI lacks mid-session user-message injection per legacy TS comment)
  • SupportsSessionResume : true (thread/resume)
  • SupportsToolPlugins : true (config/batchWrite mcpServers)
  • NeedsBaseInstructions : true (thread/start.baseInstructions)
  • NeedsPermissionConfig : true (approval bridge consumes it)
  • SupportsCodeIntelligenceEnforcement : false (no canUseTool callback)
  • EmitsSubagentEvents : false (Codex has no Anthropic Task tool)
  • SupportsReasoningEffort : true (turn/start.reasoningEffort)
  • ToolPermissionFormat : "codex"

Approval bridge

The codex app-server fires JSON-RPC server-requests (id + method) for every tool execution when `approvalPolicy: "on-request"` is set on the thread. The bridge in approval.go consumes Spec.PermissionConfig and replies with an accept / decline / acceptForSession decision. v0.5.0 ships the bridge so autonomous fleets do not have to default-allow every command (per F.1.1 open-question #5: ship the bridge).

MCP servers

Stdio MCP server configs from Spec.MCPServers are pushed to the app-server via JSON-RPC `config/batchWrite` immediately after the initialize handshake (the legacy TS calls this once per process and caches with mcpConfigured). The app-server's own subprocess machinery then runs each MCP server as a child of the app-server, not of this provider; the provider just hands it the configs.

Failure modes (F.1.1 §5)

  • JSON-RPC request: 3-attempt exponential backoff (1s/2s/4s) on transient errors. Permanent errors return immediately.
  • App-server crash: detected via process exit; every live Handle receives an ErrorEvent (Code: "app_server_crashed") and its events channel closes.
  • ctx.Done() on a Handle: send `thread/unsubscribe` + `turn/interrupt`, drain remaining notifications, close channel.

Package layout

  • codex.go — Provider lifecycle (New, Spawn, Resume, Shutdown)
  • jsonrpc.go — JSON-RPC 2.0 stdio client (request/notification dispatch)
  • handle.go — Per-session Handle (thread + events channel)
  • approval.go — Approval bridge (Spec.PermissionConfig → decisions)
  • spec_translation.go — agent.Spec → JSON-RPC param mapping
  • event_mapping.go — JSON-RPC notification → agent.Event mapping

See README.md for the operator-facing overview.

Index

Constants

View Source
const DefaultCodexModel = "gpt-5-codex"

DefaultCodexModel is the model identifier used when Spec.Model is unset. Mirrors the legacy TS CODEX_DEFAULT_MODEL constant from ../agentfactory/packages/core/src/providers/codex-app-server-provider.ts.

Variables

This section is empty.

Functions

This section is empty.

Types

type ApprovalAction

type ApprovalAction string

ApprovalAction is the JSON value codex expects on approval responses.

Mirrors the legacy TS ApprovalDecision.action union from ../agentfactory/packages/core/src/providers/codex-approval-bridge.ts.

const (
	// ActionAccept approves a single tool call and returns to
	// per-call prompting for the next call.
	ActionAccept ApprovalAction = "accept"

	// ActionAcceptForSession approves the call AND remembers the
	// approval for the rest of the session (subsequent identical
	// calls auto-approve without re-asking).
	ActionAcceptForSession ApprovalAction = "acceptForSession"

	// ActionDecline denies the call.
	ActionDecline ApprovalAction = "decline"
)

type ApprovalBridge

type ApprovalBridge struct {
	// contains filtered or unexported fields
}

ApprovalBridge evaluates inbound codex approval requests against a Spec.PermissionConfig + the built-in safety rules. Mirrors the behavior of the legacy TS evaluateCommandApproval + evaluateFileChangeApproval.

Concurrency: ApprovalBridge is safe for concurrent calls. The configured patterns are read-only after construction.

func NewApprovalBridge

func NewApprovalBridge(cfg *agent.PermissionConfig) *ApprovalBridge

NewApprovalBridge compiles the configured patterns and returns a ready-to-use bridge. A nil config produces a bridge that runs the built-in safety deny rules and otherwise auto-approves (per F.1.1 §10.5: ship the bridge, default to allow when no policy is set so autonomous fleets do not hang waiting for a human).

func (*ApprovalBridge) Evaluate

Evaluate produces the verdict for one inbound approval request.

Order:

  1. Built-in safety deny patterns (always enforced; cannot be overridden by user-supplied config — these are the rules that would otherwise corrupt the worktree).
  2. User-supplied DisallowPatterns.
  3. User-supplied AllowPatterns. When at least one allow pattern is present, ONLY commands matching an allow pattern are accepted.
  4. Default decision: acceptForSession when autoApprove, decline otherwise.

type ApprovalDecision

type ApprovalDecision struct {
	Action ApprovalAction
	Reason string
}

ApprovalDecision is the verdict the bridge emits for one inbound approval request. Reason is filled when ActionDecline so observers see why something was blocked.

type ApprovalKind

type ApprovalKind string

ApprovalKind classifies the inbound request shape — "command" (shell), "file_change" (write/edit), or "unknown" for shapes we have not seen yet (those default to acceptForSession to avoid hangs, with a SystemEvent surfacing the surprise).

const (
	ApprovalKindCommand    ApprovalKind = "command"
	ApprovalKindFileChange ApprovalKind = "file_change"
	ApprovalKindUnknown    ApprovalKind = "unknown"
)

ApprovalKind constants name the inbound approval-request shapes the codex app-server emits today.

type ApprovalRequest

type ApprovalRequest struct {
	Kind    ApprovalKind
	Command string // populated when Kind == Command
	Path    string // populated when Kind == FileChange
	Cwd     string // worktree root, used to enforce path containment
}

ApprovalRequest is the parsed approval payload handed to the bridge.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a bidirectional JSON-RPC 2.0 client over stdio.

The legacy TS `AppServerProcessManager` is the reference; this Go port keeps the same responsibilities (request/response correlation, notification routing, server-request delivery) but pulls them into a dedicated type so handle.go can focus on session semantics.

A Client is created with NewClient(stdin, stdout) and Stop()ped on shutdown. Concurrent calls to Request and Notify are safe; the underlying writer is mutex-protected.

func NewClient

func NewClient(w io.Writer, r io.Reader) *Client

NewClient wires a Client to a pair of stdio streams.

w is typically the codex app-server child's stdin; r its stdout. The caller is responsible for spawning the child process and managing its lifecycle outside the Client (codex.go owns that).

func (*Client) CloseErr

func (c *Client) CloseErr() error

CloseErr returns the cause of client close, or nil before close.

func (*Client) Done

func (c *Client) Done() <-chan struct{}

Done returns a channel that's closed when the client has stopped.

func (*Client) Notify

func (c *Client) Notify(method string, params map[string]any) error

Notify sends a JSON-RPC notification (no id, no response expected).

func (*Client) Request

func (c *Client) Request(ctx context.Context, method string, params map[string]any, timeout time.Duration) (json.RawMessage, error)

Request sends a JSON-RPC request and waits for the matching response.

timeout caps how long we wait for the response; a zero timeout leaves it bounded only by ctx. The error includes ctx and timeout reasons separately so callers can implement backoff.

func (*Client) RequestWithRetry

func (c *Client) RequestWithRetry(ctx context.Context, method string, params map[string]any, perAttempt time.Duration) (json.RawMessage, error)

RequestWithRetry wraps Request with the F.1.1 §5.1 3-attempt exponential backoff (1s, 2s, 4s) for transient JSON-RPC errors.

"Transient" here means: timeouts, write errors, and JSON-RPC errors whose code is not -32601 (Method not found) and not -32700/-32600 (Parse / Invalid request — those are programmer errors, not worth retrying). Permanent errors return immediately.

func (*Client) RespondToServerRequest

func (c *Client) RespondToServerRequest(id json.RawMessage, result map[string]any) error

RespondToServerRequest sends a successful JSON-RPC response to a server-request previously delivered to a notification handler.

id MUST be the raw json.RawMessage captured from the inbound message (Codex sends ints in some versions, strings in others — preserve the original encoding to avoid mismatch).

func (*Client) RespondToServerRequestWithError

func (c *Client) RespondToServerRequestWithError(id json.RawMessage, code int, message string) error

RespondToServerRequestWithError replies with a JSON-RPC error to a server-request we cannot satisfy (typically -32601 "Method not found"). Mirrors the legacy TS respondToServerRequestWithError; the codex side hangs the agent if a server-request never gets a reply, so we always send something.

func (*Client) SetOnClose

func (c *Client) SetOnClose(fn func(error))

SetOnClose registers a hook fired once when the read loop exits. The hook receives the terminal error (or nil for clean close). The Provider uses this to mark every live Handle as failed when the shared app-server crashes.

func (*Client) Stop

func (c *Client) Stop(cause error)

Stop releases pending requests and closes the read loop.

After Stop, no new Request calls succeed. Pending requests fail with the supplied cause (or "client stopped" when nil).

func (*Client) Subscribe

func (c *Client) Subscribe(threadID string, h notificationHandler)

Subscribe registers a notification handler for a specific threadId.

Handlers are invoked synchronously on the Client's read goroutine. Subscribing twice for the same thread replaces the prior handler.

func (*Client) SubscribeGlobal

func (c *Client) SubscribeGlobal(h notificationHandler)

SubscribeGlobal registers a fall-through handler invoked for notifications that do not carry a threadId or whose threadId has no dedicated subscriber. Used by codex.go for the post-initialize `initialized` notification and similar.

func (*Client) Unsubscribe

func (c *Client) Unsubscribe(threadID string)

Unsubscribe removes a thread handler. Safe to call after Stop.

type Handle

type Handle struct {
	// contains filtered or unexported fields
}

Handle is one live codex thread. Multiple Handles share the same codex app-server subprocess via the parent Provider; each subscribes to JSON-RPC notifications carrying its own threadId.

Handle implements agent.Handle. The lifecycle is:

  1. Provider.Spawn assembles a Handle and calls handle.start(ctx, spec) to issue thread/start + the first turn/start.
  2. The Handle runs a forwarder goroutine that consumes inbound notifications, translates them via mapNotification, and publishes to events.
  3. On terminal ResultEvent / ErrorEvent / context cancellation / app-server crash, the forwarder closes events and unsubscribes.

func (*Handle) Events

func (h *Handle) Events() <-chan agent.Event

Events returns the read-only event channel. Closes after the terminal event.

func (*Handle) Inject

func (h *Handle) Inject(_ context.Context, _ string) error

Inject is intentionally unsupported on codex. Mirrors F.1.1 §3.2 the "Codex provider does not support mid-session message injection" note — the legacy TS supports turn-level steering, but the v0.5.0 Go port keeps the surface minimal and routes steering through Provider.Resume + a fresh Spec.

func (*Handle) SessionID

func (h *Handle) SessionID() string

SessionID returns the codex thread id once thread/start has resolved.

func (*Handle) Stop

func (h *Handle) Stop(ctx context.Context) error

Stop interrupts the active turn (if any), unsubscribes the thread, and closes the events channel. Idempotent.

type HandleOptions

type HandleOptions struct {
	// RPCTimeout caps how long a single JSON-RPC request waits.
	// Defaults to 30s (matches the legacy TS PendingRequest timer).
	RPCTimeout time.Duration

	// EventBuffer sets the events channel buffer. Defaults to 256.
	EventBuffer int
}

HandleOptions tweaks handle behavior. Used by tests; production code uses the defaults via Provider.Spawn.

type Options

type Options struct {
	// CodexBin is the codex binary path. Defaults to $CODEX_BIN, then
	// "codex" looked up via $PATH.
	CodexBin string

	// Args overrides the subcommand args; defaults to ["app-server"].
	Args []string

	// Cwd is the working directory for the codex app-server child.
	// Defaults to os.Getwd(). Sessions still pass per-thread Cwd via
	// thread/start params; this is just where the app-server itself
	// runs.
	Cwd string

	// Env is merged into the parent process environment for the
	// subprocess. Use to inject OPENAI_API_KEY.
	Env map[string]string

	// HandshakeTimeout caps the JSON-RPC initialize handshake.
	// Defaults to 30s.
	HandshakeTimeout time.Duration

	// RPCTimeout is forwarded to handles for per-request timeouts.
	// Defaults to 30s.
	RPCTimeout time.Duration
	// contains filtered or unexported fields
}

Options configures Provider construction. Most fields are optional; the empty value runs `codex` from PATH against the parent process' environment.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider is the agent.Provider implementation backed by a long-lived `codex app-server` subprocess. One Provider instance owns one subprocess; sessions multiplex via JSON-RPC `thread/start` calls.

Concurrency: Spawn / Resume / Shutdown are safe to call concurrently. Inflight Handles share the same Client and read goroutine; the Provider tracks them so Shutdown can fail them all if the app-server dies.

func New

func New(opts Options) (*Provider, error)

New constructs the Provider, spawning the codex app-server subprocess and completing the JSON-RPC initialize handshake. Returns agent.ErrProviderUnavailable wrapped with context if the binary is missing or the handshake fails.

func (*Provider) Capabilities

func (p *Provider) Capabilities() agent.Capabilities

Capabilities implements agent.Provider. The values are locked per F.1.1 §3.2.

func (*Provider) Name

func (p *Provider) Name() agent.ProviderName

Name implements agent.Provider.

func (*Provider) Resume

func (p *Provider) Resume(ctx context.Context, sessionID string, spec agent.Spec) (agent.Handle, error)

Resume implements agent.Provider.

func (*Provider) Shutdown

func (p *Provider) Shutdown(ctx context.Context) error

Shutdown implements agent.Provider. Idempotent.

func (*Provider) Spawn

func (p *Provider) Spawn(ctx context.Context, spec agent.Spec) (agent.Handle, error)

Spawn implements agent.Provider. Translates the Spec to JSON-RPC params, pushes MCP server config (once per Provider instance), opens a thread, and starts the first turn.

type RPCError

type RPCError struct {
	Method  string
	Code    int
	Message string
}

RPCError is returned by Request when the server replied with a JSON-RPC error object. Callers can errors.As to unwrap.

func (*RPCError) Error

func (e *RPCError) Error() string

type SpawnPlan

type SpawnPlan struct {
	// MCPConfig is the value for `config/batchWrite` mcpServers, or
	// nil when Spec.MCPServers is empty.
	MCPConfig map[string]any

	// ThreadStart is the params for the JSON-RPC `thread/start`
	// request that opens a fresh session.
	ThreadStart map[string]any

	// TurnStart is the params for the first JSON-RPC `turn/start`
	// request after thread creation. ThreadID is empty here and is
	// filled in by the Handle once thread/start returns.
	TurnStart map[string]any

	// PromptInput is the input array reused for steering / resume
	// when a fresh turn must be started on an existing thread.
	PromptInput []map[string]any

	// IgnoredFields lists the agent.Spec fields the codex provider
	// does NOT translate — surfaced for tests + observability so we
	// know which fields are silently dropped vs. silently lost.
	IgnoredFields []SpecFieldNote
}

SpawnPlan is the bag of JSON-RPC params Provider.Spawn assembles up front. It exists as a separate value so spec_translation_test.go can table-test the full Spec → params translation without touching live stdio.

func NewSpawnPlan

func NewSpawnPlan(spec agent.Spec) SpawnPlan

NewSpawnPlan returns the JSON-RPC params for Spawn, plus the accounting of which Spec fields were translated and which were dropped. The accounting is exercised by spec_translation_test.go to ensure every one of the 19 Spec fields is either translated or explicitly noted.

type SpecFieldNote

type SpecFieldNote struct {
	Field  string
	Reason string
}

SpecFieldNote is one entry in SpawnPlan.IgnoredFields — names a dropped Spec field and the reason it was dropped.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL