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