Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions runner/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6953,6 +6953,32 @@ def _failure_reason(refusal: str, ev_err: str, tail: str, rc: int) -> str:
# item/agentMessage/delta). Flag-gated; the default codex path stays `codex exec` (batch). We run
# one turn per app-server process (spawn -> initialize -> thread start/resume -> turn -> done), a
# single-threaded read loop that also writes the follow-up requests inline as responses arrive.
def _codex_request_answer(method: str, params: dict) -> tuple[dict | None, str | None]:
"""The runner's answer to a request FROM the app-server, as (result, error): nobody is
attached and the sandbox is the trust boundary, so every approval is granted and every
question nobody can answer is declined. A request left pending parks the turn forever
(thread status waitingOnApproval): codex 0.154 asks before any MCP tool not marked read-only
through mcpServer/elicitation/request, and the runner's silence stalled every plug write
(InsForge create_table, Vercel deploy) while reads sailed through (2026-09-30).

Shapes are codex's own (app-server schema 0.154 to 0.156): permissions want the grant
back, approvals want a decision, elicitations want an action (+ the form content on accept),
user input wants answers. Anything else gets a JSON-RPC error so codex hears "no" at once."""
if method == "item/permissions/requestApproval":
return {"permissions": params.get("permissions") or {}}, None # grant what it asked for
if method.endswith("/requestApproval"):
return {"decision": "accept"}, None
if method == "mcpServer/elicitation/request":
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
if meta.get("codex_approval_kind"): # codex's own question about an MCP tool: approve it
return {"action": "accept", "content": {}}, None
return {"action": "decline"}, None # the MCP server's question: nobody here can answer
if method == "item/tool/requestUserInput":
return {"answers": {}}, None
return None, f"{method} is not answered by this client"



_CODEX_SANDBOX = os.environ.get("CODEX_APPSERVER_SANDBOX", "danger-full-access") # kebab enum; env-tunable


Expand Down Expand Up @@ -7043,8 +7069,9 @@ def send(method: str, params: dict, notify: bool = False):
proc.stdin.flush() # type: ignore[union-attr]
return msg.get("id")

def reply(mid, result: dict) -> None:
proc.stdin.write(json.dumps({"id": mid, "result": result}) + "\n") # type: ignore[union-attr]
def reply(mid, result: dict | None, error: str | None = None) -> None:
body = {"id": mid, "error": {"code": -32601, "message": error}} if error else {"id": mid, "result": result}
proc.stdin.write(json.dumps(body) + "\n") # type: ignore[union-attr]
proc.stdin.flush() # type: ignore[union-attr]

errbuf: list[str] = []
Expand Down Expand Up @@ -7088,13 +7115,13 @@ def reply(mid, result: dict) -> None:
"approvalPolicy": _CODEX_APPROVAL, "input": [{"type": "text", "text": prompt}]})
continue
if mid is not None and method: # a request FROM the app-server
if method.endswith("/requestApproval"):
# The exec policy's "prompt" rules (rm -f among them, default.rules) are
# answered here: the sandbox is the trust boundary, nobody is attached, and
# under approvalPolicy never the same rules REJECTED the command with
# "rm -f style commands are not permitted" and the turn died on a delete inside
# the agent's own workspace (a customer benchmark, 2026-09-30).
reply(mid, {"decision": "accept"})
# Every request gets an answer at once (see _codex_request_answer): the exec
# policy's "prompt" rules (rm -f among them), codex's question before a write MCP
# tool, a permission grant. Under approvalPolicy never the same rules rejected the
# command outright and the turn died; left unanswered, the turn parks (2026-09-30).
result, err = _codex_request_answer(method, msg.get("params") or {})
print(f"[codex] {method}: {'error ' + err if err else json.dumps(result)[:120]}", flush=True)
reply(mid, result, err)
continue
p = msg.get("params") or {} # a notification
if method == "item/agentMessage/delta":
Expand Down
2 changes: 1 addition & 1 deletion runner/tests/test_codex_reconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ def test_the_loop_continues_on_a_transient_error_and_answers_approval_requests()
loop = src[src.index('elif method == "error":'):src.index('elif method == "turn/failed":')]
assert "if transient:" in loop and "continue" in loop
assert server._CODEX_APPROVAL == "on-request"
assert 'if method.endswith("/requestApproval"):' in src and 'reply(mid, {"decision": "accept"})' in src
assert 'result, err = _codex_request_answer(method, msg.get("params") or {})' in src and 'reply(mid, result, err)' in src
58 changes: 58 additions & 0 deletions runner/tests/test_codex_requests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Every request the codex app-server sends gets an answer in the shape codex expects, at once.

Pinned on a live reproduction (codex 0.154.0 app-server, 2026-09-30): with approvalPolicy
on-request codex asks before any MCP tool whose annotations do not say read-only, through
mcpServer/elicitation/request with _meta.codex_approval_kind = "mcp_tool_call". The runner
answered only */requestApproval, so the thread sat in waitingOnApproval for the rest of the turn
and every plug write (InsForge create_table, Vercel deploy) stalled while reads sailed through.
The elicitation below is the captured request, payload shortened, shape untouched."""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server import _codex_request_answer # noqa: E402

ELICITATION = {
"threadId": "01a0f5f2-9116-7772-8f72-29ac4a674f56", "turnId": "01a0f5f2-9152-7ae3-9a35-2a2f1d8b5c01",
"serverName": "plugs", "mode": "form",
"_meta": {"codex_approval_kind": "mcp_tool_call", "persist": ["session", "always"],
"tool_description": "Create a table in the project database.",
"tool_params": {"name": "greetings", "columns": ["id", "text"]},
"tool_params_display": [{"name": "columns", "value": ["id", "text"], "display_name": "columns"},
{"name": "name", "value": "greetings", "display_name": "name"}]},
"message": "Allow the plugs MCP server to run tool \"create_table\"?",
"requestedSchema": {"type": "object", "properties": {}},
}


def test_codex_asking_before_a_write_mcp_tool_is_told_yes():
result, err = _codex_request_answer("mcpServer/elicitation/request", ELICITATION)
assert err is None and result == {"action": "accept", "content": {}}


def test_an_mcp_servers_own_question_is_declined_because_nobody_is_attached():
result, err = _codex_request_answer("mcpServer/elicitation/request", {
"serverName": "crm", "mode": "form", "message": "Which account?",
"requestedSchema": {"type": "object", "properties": {"account": {"type": "string"}}}})
assert err is None and result == {"action": "decline"}


def test_command_and_file_approvals_are_accepted():
for method in ("item/commandExecution/requestApproval", "item/fileChange/requestApproval",
"execCommandApproval/requestApproval"):
assert _codex_request_answer(method, {"itemId": "x"}) == ({"decision": "accept"}, None)


def test_a_permission_request_is_granted_in_its_own_shape_not_as_a_decision():
perms = {"network": {"enabled": True}, "fileSystem": {"entries": [{"path": "/tmp", "access": "write"}]}}
result, err = _codex_request_answer("item/permissions/requestApproval", {"itemId": "x", "permissions": perms})
assert err is None and result == {"permissions": perms}


def test_a_user_input_request_gets_empty_answers_so_the_turn_goes_on():
assert _codex_request_answer("item/tool/requestUserInput", {"questions": [{"id": "q1"}]}) == ({"answers": {}}, None)


def test_an_unknown_request_is_refused_aloud_rather_than_left_pending():
result, err = _codex_request_answer("attestation/generate", {})
assert result is None and "attestation/generate" in err
4 changes: 4 additions & 0 deletions ui/src/app/hr.css
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ a { color: inherit; text-decoration: none; }
.fp-dl { font-size: 12.5px; font-weight: 600; color: var(--brand); }
.fp-close { width: 30px; height: 30px; border: none; background: transparent; font-size: 22px; line-height: 1; color: var(--sidebar-mute); cursor: pointer; border-radius: 8px; }
.fp-close:hover { background: var(--brand-50); color: var(--ink); }
/* the Preview | Source switch of an HTML file, a segmented control in the header */
.fp-seg { flex-shrink: 0; display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--bg); border: 1px solid var(--line); }
.fp-seg-btn { height: 24px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: var(--sidebar-mute); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.fp-seg-btn.on { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
.fp-body { flex: 1; min-height: 0; overflow: auto; background: var(--bg); }
.fp-center { display: grid; place-items: center; min-height: 100%; padding: 16px; }
.fp-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px; }
Expand Down
27 changes: 25 additions & 2 deletions ui/src/components/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function kindOf(name: string, mime: string): string {
if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(e)) return 'audio';
if (e === 'csv' || e === 'tsv') return 'csv';
if (e === 'md' || e === 'markdown') return 'markdown';
if (e === 'html' || e === 'htm') return 'html'; // the page itself, or its source: the header switches
if (SHEET.has(e)) return 'sheet'; // real spreadsheet grid (SheetJS), with sheet tabs
if (OFFICE_PDF.has(e)) return 'office'; // server converts to pdf for a faithful render
if (mime.startsWith('text/') || LANG[e] || /(txt|log|env|conf|cfg|gitignore)/.test(e)) return 'code';
Expand All @@ -49,9 +50,12 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri
const { url, name } = file;
const [st, setSt] = useState<{ kind: string; objUrl?: string; text?: string; html?: string; error?: string; sheets?: { name: string; html: string; filled: boolean }[] }>({ kind: 'loading' });
const [activeSheet, setActiveSheet] = useState(0);
// An HTML file opens as the page it is; Source shows what was written. The choice lives in the
// header beside the name, so it reads as a property of the file being looked at.
const [view, setView] = useState<'preview' | 'source'>('preview');
useEffect(() => {
let alive = true; let obj: string | undefined;
setSt({ kind: 'loading' }); setActiveSheet(0);
setSt({ kind: 'loading' }); setActiveSheet(0); setView('preview');
// Authenticated fetch (LIVE-B): a bare fetch carries no session, and the server rejects
// headerless file reads, which used to render the error JSON as the "file content".
harnessFetch(url, { headers: authHeaders() }).then(async (r) => {
Expand Down Expand Up @@ -115,7 +119,7 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri
}
return;
}
if (kind === 'code' || kind === 'markdown' || kind === 'csv') {
if (kind === 'code' || kind === 'markdown' || kind === 'csv' || kind === 'html') {
const t = await r.text();
if (!alive) return;
if (kind === 'csv') setSt({ kind: 'csv', html: csvToTable(t, extOf(name) === 'tsv' ? '\t' : ',') });
Expand All @@ -135,6 +139,14 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri
<header className="fp-head">
<span className="fp-ic"><FileTypeIcon name={name} size={18} /></span>
<span className="fp-name" title={name}>{name}</span>
{st.kind === 'html' && (
<div className="fp-seg" role="tablist" aria-label="Show the page or its source">
{(['preview', 'source'] as const).map((id) => (
<button key={id} type="button" role="tab" aria-selected={view === id}
className={'fp-seg-btn' + (view === id ? ' on' : '')} onClick={() => setView(id)}>{id === 'preview' ? 'Preview' : 'Source'}</button>
))}
</div>
)}
<button className="fp-icbtn" title="Download" onClick={() => { downloadFile(url, name).catch(() => undefined); }}><IcDownload /></button>
<button className="fp-icbtn" title="Close" onClick={onClose}><IcX /></button>
</header>
Expand All @@ -154,6 +166,17 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri
{st.text || ''}
</SyntaxHighlighter>
)}
{st.kind === 'html' && view === 'source' && (
<SyntaxHighlighter language="markup" style={oneLight} showLineNumbers
customStyle={{ margin: 0, padding: '16px 18px', background: '#FBFBFD', color: '#383A42', fontSize: 12.5, lineHeight: 1.6, whiteSpace: 'pre', overflowX: 'auto' }}
lineNumberStyle={{ color: '#B0B4C0', minWidth: '2.4em', paddingRight: '14px' }}
codeTagProps={{ style: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: '#383A42', whiteSpace: 'pre' } }}>
{st.text || ''}
</SyntaxHighlighter>
)}
{/* The page runs in its own origin with scripts only: a mockup's own script and styles work,
and nothing in it can read the console, its cookies or its storage. */}
{st.kind === 'html' && view === 'preview' && <iframe className="fp-pdf" title={name} sandbox="allow-scripts" srcDoc={st.text || ''} />}
{st.kind === 'csv' && <div className="fp-sheet" dangerouslySetInnerHTML={{ __html: st.html || '' }} />}
{st.kind === 'sheet' && st.sheets && (
<div className="fp-xlsx">
Expand Down
Loading