diff --git a/runner/server.py b/runner/server.py index 7c0795fc..5f7f7097 100644 --- a/runner/server.py +++ b/runner/server.py @@ -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 @@ -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] = [] @@ -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": diff --git a/runner/tests/test_codex_reconnect.py b/runner/tests/test_codex_reconnect.py index f3ecae31..a817cb2b 100644 --- a/runner/tests/test_codex_reconnect.py +++ b/runner/tests/test_codex_reconnect.py @@ -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 diff --git a/runner/tests/test_codex_requests.py b/runner/tests/test_codex_requests.py new file mode 100644 index 00000000..9c5de319 --- /dev/null +++ b/runner/tests/test_codex_requests.py @@ -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 diff --git a/ui/src/app/hr.css b/ui/src/app/hr.css index 00695128..d4c90c1e 100644 --- a/ui/src/app/hr.css +++ b/ui/src/app/hr.css @@ -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; } diff --git a/ui/src/components/FilePreview.tsx b/ui/src/components/FilePreview.tsx index b11de39a..3110d2a4 100644 --- a/ui/src/components/FilePreview.tsx +++ b/ui/src/components/FilePreview.tsx @@ -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'; @@ -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) => { @@ -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' : ',') }); @@ -135,6 +139,14 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri
{name} + {st.kind === 'html' && ( +
+ {(['preview', 'source'] as const).map((id) => ( + + ))} +
+ )}
@@ -154,6 +166,17 @@ export function FilePreview({ file, onClose }: { file: { url: string; name: stri {st.text || ''} )} + {st.kind === 'html' && view === 'source' && ( + + {st.text || ''} + + )} + {/* 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' &&