Skip to content

Codex on-request approvals need an app-server ServerRequest reply path #1171

Closed

Description

@currentbits

Parent #1162. Blocked on #1170 (interactive Codex runner → codex app-server for turn/steer). Related #170 (sandbox mapping), #846 (MCP under approval_policy=never), #509 (edit-before-approve), #813 (Codex PreToolUse guardrail). Do not implement #1170 steer in this issue.

Verdict

Not a small mapping. Do not flip Codex to on-request / untrusted until a live JSON-RPC reply path exists. Do not fake ask by killing the child. Keep approvalPolicy: "never" on the #1170 runner so these ServerRequests never stall a turn.

Claude asks because --permission-prompt-tool stdio keeps stdin open. Codex exec --json has no equivalent (see electron/codex-guardrail.js: "codex exec has no control_request"). App-server 0.153.4 does have an ask channel, but it is a different protocol than Claude control_request.

What Solenta has today

Layer Claude Codex now
Spawn claude -p --input-format stream-json --permission-prompt-tool stdio --permission-mode <mode> codex exec --json --sandbox <…> + approval_policy=never (exec pin)
Incoming ask JSONL control_request {request_id, request:{subtype:"can_use_tool", tool_name, input}} none
Pending store active[threadId].kind === "claude" + pendingPermissions[] kind: "codex", no list
Reply handle.respond → JSONL control_response {behavior:"allow"|"deny", updatedInput?, updatedPermissions?} {kill} only; stdin ignored
UI PermissionPrompt: Accept / Accept all / Deny; editable command (#509); AskUserQuestion / ExitPlanMode specials never shown for Codex
IPC threads.respondPermission (allow | allowAlways | deny, optional updatedCommand / answers) same IPC exists; runner falls through to persisted plan (#707)
Guardrail classifyTool can deny before the card PreToolUse hook; Ask is deny (Codex continues the tool after parsing ask)

getPendingPermission / respondPermission hard-gate on kind === "claude" (electron/runner.js). The renderer card is provider-agnostic if the runner fills ThreadDetail.pendingPermission. That is the only reuse.

Permission modes advertised for Codex are the full Claude set, but codexSandboxFor only changes --sandbox:

  • plan → read-only
  • default / acceptEdits → workspace-write
  • bypassPermissions → danger-full-access

No interactive ask. First-party MCP survives because #846 sets default_tools_approval_mode="approve" under the never policy.

What app-server 0.153.4 actually sends

Generated from live codex app-server generate-json-schema (CLI 0.153.4). turn/start uses the NEW item/* methods. execCommandApproval / applyPatchApproval are DEPRECATED (legacy SendUserTurn / SendUserMessage) — do not implement them on a turn/start runner.

AskForApproval on thread/start and turn/start: untrusted | on-request | never | {granular:{mcp_elicitations, rules, sandbox_approval, request_permissions?, skill_approval?}}.

approvalsReviewer: user (default) | auto_review | guardian_subagent. Solenta should keep user until a reply path exists.

1. item/commandExecution/requestApproval (stable, closest to Claude Bash)

Params (required: threadId, turnId, itemId, startedAtMs):

  • command: string or null (not Claude's {command, cmd, script} object)
  • cwd, reason, commandActions[], kind: command | writeStdin
  • approvalId: extra id when several callbacks share one itemId (zsh-exec-bridge / stdin)
  • availableDecisions[], proposedExecpolicyAmendment[], additionalPermissions, networkApprovalContext

Reply: { decision } where decision is:

  • accept ↔ Solenta allow
  • acceptForSession ↔ allowAlways (session cache, not Claude updatedPermissions addRules)
  • {acceptWithExecpolicyAmendment:{execpolicy_amendment:[]}} — no Solenta UI
  • {applyNetworkPolicyAmendment:{network_policy_amendment:{host,action}}} — no Solenta UI
  • decline — deny, turn continues
  • cancel — deny and interrupt the turn (Claude Deny does not do this)

#509 hole: response cannot rewrite the command. Accept runs the proposed string. Do not send updatedCommand into this RPC.

writeStdin is not a new tool — it is input to an existing terminal. Do not render it as a Bash card.

2. item/fileChange/requestApproval (stable)

Params: threadId, turnId, itemId, startedAtMs, optional reason / grantRoot (UNSTABLE). No file contents, no diffs. The client must correlate itemId with item/started (fileChange item) to show the patch. Reply: accept | acceptForSession | decline | cancel.

PermissionPrompt today is command-textarea or JSON preview. File-change needs a diff surface (or a read-only JSON dump of the correlated item — worse).

Deprecated twin: applyPatchApproval (callId, conversationId, fileChanges map). Ignore on turn/start.

3. item/permissions/requestApproval (stable)

Not allow/deny. Params: cwd, permissions (fileSystem entries + network.enabled), threadId, turnId, itemId, startedAtMs.

Reply: { permissions: GrantedPermissionProfile, scope: "turn"\|"session", strictAutoReview? }. This is a sandbox overlay grant (extra write roots / network). New UI, or auto-decline until a card exists.

4. mcpServer/elicitation/request (stable)

MCP elicitation/create passthrough: serverName, threadId, optional turnId, plus one of mode: form | openai/form | openaiForm | url with requestedSchema or url. Reply: { action: accept|decline|cancel, content? }.

This is a form/url, not PermissionPrompt. First-party Solenta MCP currently uses default_tools_approval_mode="approve" (#846) so it should not hit this if we keep that config; user-registered MCP might.

5. item/tool/requestUserInput (in the stable bundle; description says EXPERIMENTAL)

{threadId, turnId, itemId, isBlocking, questions[{id, header, question, options, isOther, isSecret}]}. Reply is structured answers. Closer to Claude AskUserQuestion / persisted pendingQuestion (#647) than to Allow/Deny. Blocking vs non-blocking must be honored: blocking without a reply stalls the turn.

Not permission prompts (do not map)

  • item/tool/call — client executes a dynamic tool
  • account/chatgptAuthTokens/refresh, attestation/generate
  • currentTime/read — experimental-schema only

Why a naive map would break

  1. No transport. startCodexRun → runCodex with stdin ignored. providerUsage.runStdioJsonRpc only matches client-initiated ids (handshakeId / matchId) and kills the child; an inbound ServerRequest is dropped and the turn waits forever.
  2. Reply shape. JSON-RPC {id, result:{decision}} vs Claude stdin control_response. Need handle.respondJsonRpc(id, result) on a long-lived app-server process (Switch Codex interactive runner to app-server for real turn/steer #1170).
  3. Kind gate. respondPermission bails to persisted-plan for any non-claude active run. Codex must grow pendingPermissions (or a sibling list keyed by JSON-RPC id + approvalId).
  4. Decision vocab. Three Solenta buttons vs six+ Codex decisions, plus decline vs cancel. Accept-all is session cache, not Claude addRules. Execpolicy / network-policy amendments have no control.
  5. Cannot edit the command. Shipping updatedCommand into Codex would silently ignore the edit and run the original.
  6. Multiple outstanding asks. approvalId can fan out under one itemId. Claude queues pendingPermissions and shows the oldest. Same queue is fine if the reply id is the JSON-RPC request id (and approvalId is stored).
  7. Stop / settle. Claude idle control_request gets respondError("No active turn…"). Codex needs a JSON-RPC error or decline/cancel on kill, or app-server hangs the turn.
  8. Guardrails. Live classifyTool deny must JSON-RPC-decline before the card. Do not rely on PreToolUse ask (it is deny-then-continue).
  9. Mode matrix. Solenta's four Claude modes ≠ AskForApproval × SandboxPolicy. acceptEdits would need granular auto-accept of fileChange while still asking for commands. Until that exists, keep advertising sandbox-only behavior; do not pretend default asks.
  10. Explicit Grok worker forks are impossible when approval policy is never #846 MCP. Flipping to on-request without keeping first-party default_tools_approval_mode="approve" re-breaks thread_send / memory tools.

What UI / IPC is needed (after #1170)

Reuse threads.respondPermission + PermissionPrompt only for item/commandExecution/requestApproval with kind=command:

  • requestId = JSON-RPC id (stringified)
  • toolName = "command" (or a stable label)
  • command = params.command (show the textarea read-only, or hide Accept until we accept that edits are dropped)
  • summary / input from command + cwd + reason
  • Map allow → accept, allowAlways → acceptForSession, deny → decline (not cancel; Stop already interrupts)
  • Ignore updatedCommand
  • Prefer availableDecisions when present; hide Accept all if acceptForSession is absent

New work, not a map:

Suggested mode mapping (only once command reply works)

Solenta mode sandboxPolicy approvalPolicy
plan readOnly never or on-request (read-only still escapes for network)
default workspace-write equivalent on-request
acceptEdits workspace-write not Claude acceptEdits until granular fileChange auto-accept exists; snap to default or document the lie
bypassPermissions dangerFullAccess never

Until #1170 lands, do not change codexSandboxFor or exec argv. Workflow / ask / commitmsg stay on exec with never.

Out of scope

PR plan

  1. After Switch Codex interactive runner to app-server for real turn/steer #1170 exists: bidirectional JSON-RPC + fail-closed: unknown ServerRequest → JSON-RPC error; stop → cancel/decline outstanding ids. Keep approvalPolicy: "never". Fake-server tests.
  2. Command ask: map item/commandExecution/requestApproval onto pendingPermission / respondPermission as above. Enable on-request only for default (and maybe plan). Guardrail deny → decline without a card.
  3. fileChange diff card via item/started correlation.
  4. permissions/requestApproval overlay grant (or auto-decline with an event).
  5. elicitation + requestUserInput (separate if the command path is enough for parity).

Gate each PR on a fake app-server. Do not spend tokens on a live in-progress model to prove the RPC.

Activity

  1. added and removed on Sep 9, 2026
  2. currentbits commented on Sep 10, 2026

    @currentbits
    OwnerAuthor

    Reply path landed without flipping on-request or switching exec to app-server.

    • electron/codexJsonRpc.js — long-lived bidirectional JSON-RPC. Unknown ServerRequests → -32601. Stop → cancel outstanding ids (no second reply).
    • electron/codexApprovals.js + runner.handleCodexServerRequest — item/commandExecution/requestApproval kind=command → pendingPermission. allow→accept, allowAlways→acceptForSession, deny→decline. updatedCommand ignored (textarea read-only). Guardrail deny declines without a card.
    • exec argv unchanged; approval_policy=never stays. Fake app-server tests in electron/test/codex-jsonrpc.test.js and codex-approvals-runner.test.js.

    #1170 must attach createCodexJsonRpcSession as the Codex handle and call handleCodexServerRequest from onServerRequest before enabling on-request.

    Follow-ups: #1189 fileChange diff card, #1190 permissions overlay + elicitation + requestUserInput.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions