optio-codex 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ """Per-task conversation listener — the opt-in dashboard gate for optio-codex.
2
+
3
+ Exposes one running CodexConversation over HTTP, reached through the optio-api
4
+ widget proxy (which injects the basic-auth credential):
5
+
6
+ GET /events — SSE: replay buffer first, then live tail. SSE id: is a
7
+ monotonic seq; Last-Event-ID resumes without dupes.
8
+ POST /send — {text} -> conversation.send
9
+ POST /interrupt — {} -> conversation.interrupt
10
+ POST /model — {model} -> conversation.request_model_change
11
+ (INLINE: pins the next turn/start's model — no restart)
12
+ POST /upload — multipart {file} parts -> upload_writer; returns
13
+ {ok, files:[{filename, path}]}
14
+ GET /download — ?path=<relpath> -> download_reader; returns the
15
+ bytes with Content-Disposition: attachment
16
+ POST /permission — {request_id, behavior, updated_input?, message?}
17
+ resolves the pending requestApproval future.
18
+
19
+ Structurally mirrors optio-grok's ConversationListener (itself from
20
+ optio-claudecode's). Permissions are correlated by the JSON-RPC ``id`` of the
21
+ ``item/commandExecution/requestApproval`` / ``item/fileChange/requestApproval``
22
+ server request — CodexConversation hands the whole JSON-RPC object to the
23
+ handler as ``PermissionRequest.raw``.
24
+
25
+ Projection principle: this listener only observes and forwards; attaching or
26
+ detaching viewers never influences task state.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import asyncio
31
+ import base64
32
+ import json
33
+ import logging
34
+ from collections import deque
35
+ from typing import Awaitable, Callable
36
+
37
+ from aiohttp import web
38
+
39
+ from optio_agents.conversation import ConversationClosed, PermissionDecision
40
+
41
+ _LOG = logging.getLogger(__name__)
42
+
43
+ BUFFER_MAXLEN = 1000
44
+ PING_INTERVAL_S = 15.0
45
+ # Bound aiohttp's graceful-shutdown wait so the long-lived /events SSE loop
46
+ # cannot stall the session's cooperative-cancel teardown past its grace period.
47
+ SHUTDOWN_TIMEOUT_S = 2.0
48
+ # Sentinel pushed into each subscriber queue on stop() so the SSE handler loop
49
+ # returns immediately instead of parking until the next ping timeout.
50
+ _STOP = object()
51
+
52
+
53
+ class ConversationListener:
54
+ def __init__(
55
+ self, conversation, *, password: str,
56
+ upload_writer: "Callable[[str, bytes], Awaitable[str]] | None" = None,
57
+ max_upload_bytes: int = 10_000_000,
58
+ download_reader: "Callable[[str], Awaitable[tuple[bytes, str]]] | None" = None,
59
+ max_download_bytes: int = 10_000_000,
60
+ ) -> None:
61
+ self._conversation = conversation
62
+ self._password = password
63
+ self._upload_writer = upload_writer
64
+ self._max_upload_bytes = max_upload_bytes
65
+ self._download_reader = download_reader
66
+ self._max_download_bytes = max_download_bytes
67
+ self._buffer: deque[tuple[int, dict]] = deque(maxlen=BUFFER_MAXLEN)
68
+ self._seq = 0
69
+ self._subscribers: set[asyncio.Queue] = set()
70
+ self._pending_permissions: dict[str, asyncio.Future] = {}
71
+ self._runner: web.AppRunner | None = None
72
+ self._unsubscribe = conversation.on_event(self._on_event)
73
+ conversation.on_permission_request(self._on_permission_request)
74
+
75
+ # -- event intake --------------------------------------------------------
76
+
77
+ def _broadcast(self, event: dict) -> None:
78
+ self._seq += 1
79
+ item = (self._seq, event)
80
+ self._buffer.append(item)
81
+ for q in list(self._subscribers):
82
+ q.put_nowait(item)
83
+
84
+ def _on_event(self, event: dict) -> None:
85
+ self._broadcast(event)
86
+
87
+ # -- permission gate -----------------------------------------------------
88
+
89
+ async def _on_permission_request(self, request) -> PermissionDecision:
90
+ # The raw requestApproval request already reached viewers via _on_event;
91
+ # here we only park until some operator POSTs /permission with its
92
+ # JSON-RPC id. CodexConversation stores the whole JSON-RPC request object
93
+ # as PermissionRequest.raw, so `id` is the correlation key.
94
+ request_id = str(request.raw.get("id"))
95
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
96
+ self._pending_permissions[request_id] = fut
97
+ try:
98
+ decision: PermissionDecision = await fut
99
+ finally:
100
+ self._pending_permissions.pop(request_id, None)
101
+ self._broadcast({
102
+ "type": "x-optio-permission-answered",
103
+ "request_id": request_id,
104
+ "behavior": decision.behavior,
105
+ })
106
+ return decision
107
+
108
+ # -- HTTP handlers -------------------------------------------------------
109
+
110
+ def _authorized(self, request: web.Request) -> bool:
111
+ # The widget proxy injects BasicAuth(username="optio", password=...).
112
+ auth = request.headers.get("Authorization", "")
113
+ if not auth.startswith("Basic "):
114
+ return False
115
+ try:
116
+ userpass = base64.b64decode(auth[6:]).decode("utf-8")
117
+ except Exception: # noqa: BLE001
118
+ return False
119
+ return userpass == f"optio:{self._password}"
120
+
121
+ async def _handle_events(self, request: web.Request) -> web.StreamResponse:
122
+ if not self._authorized(request):
123
+ return web.json_response({"ok": False}, status=401)
124
+ resp = web.StreamResponse(headers={
125
+ "Content-Type": "text/event-stream",
126
+ "Cache-Control": "no-cache",
127
+ "X-Accel-Buffering": "no",
128
+ })
129
+ await resp.prepare(request)
130
+
131
+ async def send_item(seq: int, event: dict) -> None:
132
+ payload = json.dumps(event)
133
+ await resp.write(f"id: {seq}\ndata: {payload}\n\n".encode("utf-8"))
134
+
135
+ last_id = 0
136
+ raw_last = request.headers.get("Last-Event-ID", "")
137
+ if raw_last.isdigit():
138
+ last_id = int(raw_last)
139
+
140
+ queue: asyncio.Queue = asyncio.Queue()
141
+ # Subscribe BEFORE replay so no event falls between replay and tail;
142
+ # the seq check below dedupes any overlap.
143
+ self._subscribers.add(queue)
144
+ try:
145
+ sent_through = last_id
146
+ for seq, event in list(self._buffer):
147
+ if seq > sent_through:
148
+ await send_item(seq, event)
149
+ sent_through = seq
150
+ while True:
151
+ try:
152
+ item = await asyncio.wait_for(
153
+ queue.get(), timeout=PING_INTERVAL_S,
154
+ )
155
+ except asyncio.TimeoutError:
156
+ await resp.write(b": ping\n\n")
157
+ continue
158
+ if item is _STOP:
159
+ break # stop() asked us to close so teardown can proceed
160
+ seq, event = item
161
+ if seq > sent_through:
162
+ await send_item(seq, event)
163
+ sent_through = seq
164
+ except (ConnectionResetError, asyncio.CancelledError):
165
+ pass
166
+ finally:
167
+ self._subscribers.discard(queue)
168
+ return resp
169
+
170
+ async def _handle_send(self, request: web.Request) -> web.Response:
171
+ if not self._authorized(request):
172
+ return web.json_response({"ok": False}, status=401)
173
+ try:
174
+ payload = await request.json()
175
+ except Exception: # noqa: BLE001
176
+ return web.json_response({"ok": False, "reason": "bad-json"}, status=400)
177
+ text = payload.get("text")
178
+ if not isinstance(text, str) or not text:
179
+ return web.json_response({"ok": False, "reason": "bad-text"}, status=400)
180
+ try:
181
+ await self._conversation.send(text)
182
+ except ConversationClosed:
183
+ return web.json_response({"ok": False, "reason": "closed"}, status=409)
184
+ return web.json_response({"ok": True})
185
+
186
+ async def _handle_interrupt(self, request: web.Request) -> web.Response:
187
+ if not self._authorized(request):
188
+ return web.json_response({"ok": False}, status=401)
189
+ try:
190
+ await self._conversation.interrupt()
191
+ except ConversationClosed:
192
+ return web.json_response({"ok": False, "reason": "closed"}, status=409)
193
+ return web.json_response({"ok": True})
194
+
195
+ async def _handle_upload(self, request: web.Request) -> web.Response:
196
+ if not self._authorized(request):
197
+ return web.json_response({"ok": False}, status=401)
198
+ if self._upload_writer is None:
199
+ return web.json_response({"ok": False, "reason": "no-writer"}, status=409)
200
+ stored: list[dict] = []
201
+ try:
202
+ reader = await request.multipart()
203
+ except Exception: # noqa: BLE001
204
+ return web.json_response({"ok": False, "reason": "bad-multipart"}, status=400)
205
+ while True:
206
+ part = await reader.next()
207
+ if part is None:
208
+ break
209
+ if part.name != "file":
210
+ continue
211
+ filename = part.filename or "file"
212
+ buf = bytearray()
213
+ while True:
214
+ chunk = await part.read_chunk()
215
+ if not chunk:
216
+ break
217
+ buf.extend(chunk)
218
+ if len(buf) > self._max_upload_bytes:
219
+ return web.json_response({"ok": False, "reason": "too-large"}, status=413)
220
+ path = await self._upload_writer(filename, bytes(buf))
221
+ stored.append({"filename": filename, "path": path})
222
+ return web.json_response({"ok": True, "files": stored})
223
+
224
+ async def _handle_download(self, request: web.Request) -> web.Response:
225
+ if not self._authorized(request):
226
+ return web.json_response({"ok": False}, status=401)
227
+ if self._download_reader is None:
228
+ return web.json_response({"ok": False, "reason": "no-reader"}, status=409)
229
+ path = request.query.get("path")
230
+ if not path:
231
+ return web.json_response({"ok": False, "reason": "bad-path"}, status=400)
232
+ try:
233
+ data, mime = await self._download_reader(path)
234
+ except FileNotFoundError:
235
+ return web.json_response({"ok": False, "reason": "not-found"}, status=404)
236
+ except ValueError as e:
237
+ reason = str(e)
238
+ status = 413 if reason == "too-large" else 403
239
+ return web.json_response({"ok": False, "reason": reason}, status=status)
240
+ base = path.split("/")[-1] or "file"
241
+ return web.Response(
242
+ body=data,
243
+ headers={
244
+ "Content-Type": mime,
245
+ "Content-Disposition": f'attachment; filename="{base}"',
246
+ },
247
+ )
248
+
249
+ async def _handle_model(self, request: web.Request) -> web.Response:
250
+ if not self._authorized(request):
251
+ return web.json_response({"ok": False}, status=401)
252
+ try:
253
+ payload = await request.json()
254
+ except Exception: # noqa: BLE001
255
+ return web.json_response({"ok": False, "reason": "bad-json"}, status=400)
256
+ model = payload.get("model")
257
+ if not isinstance(model, str) or not model:
258
+ return web.json_response({"ok": False, "reason": "bad-model"}, status=400)
259
+ try:
260
+ self._conversation.request_model_change(model)
261
+ except ConversationClosed:
262
+ return web.json_response({"ok": False, "reason": "closed"}, status=409)
263
+ return web.json_response({"ok": True})
264
+
265
+ async def _handle_permission(self, request: web.Request) -> web.Response:
266
+ if not self._authorized(request):
267
+ return web.json_response({"ok": False}, status=401)
268
+ try:
269
+ payload = await request.json()
270
+ except Exception: # noqa: BLE001
271
+ return web.json_response({"ok": False, "reason": "bad-json"}, status=400)
272
+ request_id = str(payload.get("request_id", ""))
273
+ behavior = payload.get("behavior")
274
+ if behavior not in ("allow", "deny"):
275
+ return web.json_response({"ok": False, "reason": "bad-behavior"}, status=400)
276
+ fut = self._pending_permissions.get(request_id)
277
+ if fut is None or fut.done():
278
+ return web.json_response({"ok": False, "reason": "unknown-request"}, status=404)
279
+ fut.set_result(PermissionDecision(
280
+ behavior=behavior,
281
+ updated_input=payload.get("updated_input"),
282
+ message=payload.get("message"),
283
+ ))
284
+ return web.json_response({"ok": True})
285
+
286
+ # -- lifecycle -----------------------------------------------------------
287
+
288
+ async def start(self, bind_iface: str) -> int:
289
+ app = web.Application()
290
+ app.router.add_get("/events", self._handle_events)
291
+ app.router.add_post("/send", self._handle_send)
292
+ app.router.add_post("/interrupt", self._handle_interrupt)
293
+ app.router.add_post("/model", self._handle_model)
294
+ app.router.add_post("/upload", self._handle_upload)
295
+ app.router.add_get("/download", self._handle_download)
296
+ app.router.add_post("/permission", self._handle_permission)
297
+ self._runner = web.AppRunner(app, shutdown_timeout=SHUTDOWN_TIMEOUT_S)
298
+ await self._runner.setup()
299
+ site = web.TCPSite(self._runner, bind_iface, 0)
300
+ await site.start()
301
+ # Read the OS-assigned port back from the bound server socket.
302
+ server = site._server # aiohttp exposes the asyncio.Server here
303
+ return server.sockets[0].getsockname()[1]
304
+
305
+ async def stop(self) -> None:
306
+ # Idempotent: teardown paths may call stop() more than once. Make the
307
+ # unsubscribe one-shot so a second call can't double-remove the handler.
308
+ unsubscribe = self._unsubscribe
309
+ self._unsubscribe = lambda: None
310
+ unsubscribe()
311
+ for fut in self._pending_permissions.values():
312
+ if not fut.done():
313
+ fut.set_result(PermissionDecision(
314
+ behavior="deny", message="optio harness: session ending",
315
+ ))
316
+ # Wake every open /events handler so it returns now, instead of letting
317
+ # runner.cleanup() wait for the long-lived SSE loops.
318
+ for queue in list(self._subscribers):
319
+ queue.put_nowait(_STOP)
320
+ if self._runner is not None:
321
+ await self._runner.cleanup()
322
+ self._runner = None
@@ -0,0 +1,138 @@
1
+ """In-session credential save-back for codex seeds.
2
+
3
+ Codex's ChatGPT-mode ``auth.json`` holds a **single-use rotating refresh
4
+ token** (``tokens.refresh_token``): the manager proactively refreshes after
5
+ 8 days (``TOKEN_REFRESH_INTERVAL``, manager.rs) and on any 401, rewriting
6
+ auth.json in place — and a used refresh token invalidates every other copy
7
+ (openai/codex#15410, by design). That is the exact failure mode
8
+ optio-opencode's watcher was built for and optio-grok ported; this module
9
+ is the codex adaptation (credential path ``<workdir>/home/.codex/auth.json``).
10
+ OpenAI's own CI/CD guidance is the same restore -> run -> persist pattern.
11
+
12
+ The watcher keeps the seed current by writing the changed in-session
13
+ auth.json back into the existing seed, plus a final backstop at teardown.
14
+ It also renews the seed's pool lease each tick and aborts the session on
15
+ lease loss (a new holder must never rotate the same token concurrently).
16
+ The seed is the single source of truth for credentials.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import hashlib
23
+ import json
24
+ import logging
25
+ from typing import Callable
26
+
27
+ from optio_host.host import Host
28
+
29
+ from optio_agents import seeds
30
+ from optio_codex.seed_manifest import CODEX_CRED_MANIFEST, CODEX_SEED_SUFFIX
31
+
32
+ _LOG = logging.getLogger(__name__)
33
+
34
+ CRED_WATCH_INTERVAL_S = 10.0
35
+ _CRED_RELPATH = "home/.codex/auth.json"
36
+
37
+
38
+ def _auth_valid(data: object) -> bool:
39
+ """True iff ``data`` is one of codex's two live auth shapes: ChatGPT
40
+ mode (``tokens`` non-null) or API-key mode (``OPENAI_API_KEY``
41
+ non-null). A logged-out ``{}``/null-tokens file is invalid — saving it
42
+ back would clobber a good seed."""
43
+ if not isinstance(data, dict) or not data:
44
+ return False
45
+ return data.get("tokens") is not None or data.get("OPENAI_API_KEY") is not None
46
+
47
+
48
+ async def cred_fingerprint(host: Host) -> str | None:
49
+ """SHA-256 of the live ``home/.codex/auth.json``, or None when it is
50
+ missing, unparseable, or logged-out (nothing worth saving back).
51
+
52
+ Guards against corrupting a seed with a half-written / logged-out file —
53
+ the codex analog of opencode's provider-entry gate, tightened to codex's
54
+ two documented auth shapes (tokens / OPENAI_API_KEY).
55
+ """
56
+ path = f"{host.workdir.rstrip('/')}/{_CRED_RELPATH}"
57
+ try:
58
+ raw = await host.fetch_bytes_from_host(path)
59
+ except FileNotFoundError:
60
+ return None
61
+ try:
62
+ data = json.loads(raw.decode("utf-8"))
63
+ except (ValueError, UnicodeDecodeError):
64
+ return None
65
+ if not _auth_valid(data):
66
+ return None
67
+ return hashlib.sha256(raw).hexdigest()
68
+
69
+
70
+ async def capture_gate_ok(host: Host) -> bool:
71
+ """Gate for seed CAPTURE: a valid ``auth.json`` is present.
72
+
73
+ Codex, like grok, has no separate model requirement (the model lives in
74
+ ``config.toml`` and is optional), so a valid credential is the whole
75
+ gate. Save-back uses ``cred_fingerprint`` directly; this is the terminal
76
+ capture gate."""
77
+ return await cred_fingerprint(host) is not None
78
+
79
+
80
+ async def save_back_if_changed(
81
+ ctx,
82
+ host: Host,
83
+ *,
84
+ seed_id: str,
85
+ baseline: str | None,
86
+ encrypt: "Callable[[bytes], bytes] | None",
87
+ decrypt: "Callable[[bytes], bytes] | None",
88
+ ) -> str | None:
89
+ """If the live auth.json differs from ``baseline`` and is valid, save it
90
+ back into the seed and return the new fingerprint. Otherwise return
91
+ ``baseline`` unchanged. Never raises — save-back is best-effort."""
92
+ fp = await cred_fingerprint(host)
93
+ if fp is None or fp == baseline:
94
+ return baseline
95
+ try:
96
+ await seeds.refresh_seed(
97
+ ctx, host, seed_id=seed_id, manifest=CODEX_CRED_MANIFEST,
98
+ suffix=CODEX_SEED_SUFFIX, encrypt=encrypt, decrypt=decrypt,
99
+ )
100
+ _LOG.info("seed %s: auth.json saved back", seed_id)
101
+ return fp
102
+ except Exception:
103
+ _LOG.exception("seed %s: auth.json save-back failed", seed_id)
104
+ return baseline
105
+
106
+
107
+ async def run_credential_watcher(
108
+ ctx,
109
+ host: Host,
110
+ *,
111
+ seed_id: str,
112
+ baseline: str | None,
113
+ encrypt: "Callable[[bytes], bytes] | None",
114
+ decrypt: "Callable[[bytes], bytes] | None",
115
+ lease_holder: str | None = None,
116
+ ) -> None:
117
+ """Poll every ``CRED_WATCH_INTERVAL_S``: save back the rotated auth.json,
118
+ and (when ``lease_holder`` is set) renew the seed's lease. If the lease
119
+ is lost, signal the session to stop (set the cancellation flag) and exit
120
+ — continuing would mean a token-rotation collision with the new holder.
121
+
122
+ Runs until cancelled. Best-effort save-back; lease-loss is decisive."""
123
+ current = baseline
124
+ while True:
125
+ await asyncio.sleep(CRED_WATCH_INTERVAL_S)
126
+ current = await save_back_if_changed(
127
+ ctx, host, seed_id=seed_id, baseline=current,
128
+ encrypt=encrypt, decrypt=decrypt,
129
+ )
130
+ if lease_holder is not None:
131
+ ok = await seeds.renew_lease(
132
+ ctx._db, prefix=ctx._prefix, suffix=CODEX_SEED_SUFFIX,
133
+ seed_id=seed_id, holder=lease_holder,
134
+ )
135
+ if not ok:
136
+ _LOG.warning("seed %s: lease lost; aborting session", seed_id)
137
+ ctx.cancellation_flag.set()
138
+ return
@@ -0,0 +1,149 @@
1
+ """Settings SSOT for codex's NATIVE sandbox (Stage 8 filesystem isolation).
2
+
3
+ optio-codex confines the agent's TOOL SUBPROCESSES using codex's own
4
+ kernel-level sandbox (bundled bubblewrap primary, Landlock+seccomp fallback
5
+ on Linux; helper bins materialize to ``$CODEX_HOME/tmp/arg0/``) rather than
6
+ porting optio-claudecode's claustrum. Unlike grok there is no planted
7
+ profile file: one resolved :class:`SandboxSettings` renders to
8
+
9
+ * CLI surfaces (interactive TUI + ``codex exec``): ``--sandbox <mode>`` plus
10
+ ``-c sandbox_workspace_write.writable_roots=[…]`` /
11
+ ``-c sandbox_workspace_write.network_access=true`` overrides
12
+ (:func:`build_sandbox_cli_args`); and
13
+ * the ``codex app-server`` launch (conversation mode): the mode travels
14
+ out-of-band via ``thread/start``'s ``sandbox`` field (a kebab-case
15
+ ``SandboxMode`` enum — the app-server has NO ``--sandbox`` flag), while
16
+ writable_roots/network_access ride the SAME ``-c sandbox_workspace_write.*``
17
+ overrides on the ``codex app-server`` command line
18
+ (:func:`build_sandbox_config_overrides`).
19
+
20
+ Schema note (probed, codex-cli 0.142.5 ``codex app-server
21
+ generate-json-schema``): ``ThreadStartParams`` exposes only ``sandbox``
22
+ (SandboxMode enum) + a generic ``config`` object — there is NO
23
+ ``sandboxPolicy`` object on ``thread/start``. A structured ``SandboxPolicy``
24
+ (a ``type``-tagged union: ``workspaceWrite``/``readOnly``/
25
+ ``dangerFullAccess``, camelCase ``writableRoots``/``networkAccess``) exists
26
+ only on ``turn/start``, which optio does not use to carry the sandbox — the
27
+ mode+``-c`` pair at launch defines the whole app-server process's posture.
28
+
29
+ Probed divergences vs grok/claudecode (codex-cli 0.142.5, 2026-07-02):
30
+
31
+ * ``workspace-write`` restricts WRITES only — the READ side is open, so
32
+ ``AllowedDir(mode="ro")`` grants are a documented no-op here (additive
33
+ grant, trivially satisfied). Only ``rw`` grants change behavior.
34
+ * Network is OFF by default in workspace-write (``[sandbox_workspace_write]
35
+ network_access``) — stricter than the other wrappers' fs-only sandboxes;
36
+ ``CodexTaskConfig.network_access=True`` relaxes it.
37
+ * ``.git/`` and ``.codex/`` under a writable root stay read-only for
38
+ sandboxed commands — the agent's shell cannot rewrite the per-task
39
+ ``auth.json`` even though ``CODEX_HOME`` lives inside the workdir.
40
+ * Failure mode with NO mechanism available: **FAIL-CLOSED** (Task-0 probe
41
+ verdict, codex-cli 0.142.5). codex never runs the model's shell command
42
+ unconfined as a result of a sandbox-setup failure — it errors/panics
43
+ (bwrap "Creating new namespace failed" rc=1, or bare-binary "bubblewrap is
44
+ unavailable" panic rc=101) and the command does not run. The only
45
+ unconfined path is the explicit ``--dangerously-bypass-approvals-and-
46
+ sandbox`` opt-out, which optio-codex never emits. Consequence: no
47
+ launch-time enforcement guard is required (Task 5B, evidence-only).
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ from dataclasses import dataclass
54
+ from typing import TYPE_CHECKING
55
+
56
+ if TYPE_CHECKING:
57
+ from optio_codex.types import CodexTaskConfig, SandboxMode
58
+
59
+
60
+ def _expand_home(path: str, host_home: str) -> str:
61
+ """Expand a leading ``~/`` against the REAL host home.
62
+
63
+ The codex process runs under an isolated ``$HOME`` (``<workdir>/home``),
64
+ so a ``~/`` grant cannot rely on shell expansion — it is resolved against
65
+ the operator's real home here, at settings-resolution time.
66
+ """
67
+ home = host_home.rstrip("/")
68
+ if path == "~":
69
+ return home
70
+ if path.startswith("~/"):
71
+ return f"{home}/{path[2:]}"
72
+ return path
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class SandboxSettings:
77
+ """One task's resolved sandbox posture — the SSOT every launch surface
78
+ (iframe/exec ``--sandbox`` argv, and the app-server's thread/start
79
+ ``sandbox`` mode + ``-c`` config overrides) renders from."""
80
+
81
+ mode: "SandboxMode"
82
+ writable_roots: tuple[str, ...] = ()
83
+ network_access: bool = False
84
+
85
+
86
+ def resolve_sandbox_settings(
87
+ config: "CodexTaskConfig", *, host_home: str,
88
+ ) -> SandboxSettings:
89
+ """Resolve ``fs_isolation``/``sandbox``/``extra_allowed_dirs``/
90
+ ``network_access`` into one :class:`SandboxSettings`.
91
+
92
+ ``ro`` grants are skipped (codex never restricts reads — see module
93
+ docstring); ``rw`` grants become ``writable_roots`` with ``~/`` expanded
94
+ against ``host_home``. Roots/network only apply to workspace-write
95
+ (validated in CodexTaskConfig.__post_init__).
96
+ """
97
+ mode = config.effective_sandbox_mode
98
+ roots: list[str] = []
99
+ if mode == "workspace-write":
100
+ for ad in config.extra_allowed_dirs or []:
101
+ if ad.mode == "rw":
102
+ roots.append(_expand_home(ad.path, host_home).rstrip("/"))
103
+ return SandboxSettings(
104
+ mode=mode,
105
+ writable_roots=tuple(roots),
106
+ network_access=bool(config.network_access) and mode == "workspace-write",
107
+ )
108
+
109
+
110
+ def _toml_str_array(paths: tuple[str, ...]) -> str:
111
+ # json.dumps output is valid TOML for basic strings.
112
+ return "[" + ", ".join(json.dumps(p) for p in paths) + "]"
113
+
114
+
115
+ def build_sandbox_config_overrides(settings: SandboxSettings) -> list[str]:
116
+ """Render the ``-c sandbox_workspace_write.*`` overrides ONLY (no
117
+ ``--sandbox`` flag).
118
+
119
+ Used by the ``codex app-server`` launch, which selects the mode
120
+ out-of-band via ``thread/start``'s ``sandbox`` field and has no
121
+ ``--sandbox`` flag; the writable_roots/network_access still need to reach
122
+ the process, and ``codex app-server`` accepts ``-c`` config overrides.
123
+ :func:`build_sandbox_cli_args` composes on top of this — one SSOT, two
124
+ launch surfaces. ``-c`` values are parsed as TOML, so the roots array is
125
+ emitted in TOML syntax. Empty outside workspace-write (and for a
126
+ workspace-write posture with no extras).
127
+ """
128
+ if settings.mode != "workspace-write":
129
+ return []
130
+ out: list[str] = []
131
+ if settings.writable_roots:
132
+ out += [
133
+ "-c",
134
+ "sandbox_workspace_write.writable_roots="
135
+ + _toml_str_array(settings.writable_roots),
136
+ ]
137
+ if settings.network_access:
138
+ out += ["-c", "sandbox_workspace_write.network_access=true"]
139
+ return out
140
+
141
+
142
+ def build_sandbox_cli_args(settings: SandboxSettings) -> list[str]:
143
+ """Render settings as codex CLI args (interactive TUI and ``exec``).
144
+
145
+ ``--sandbox`` is accepted by both surfaces; the ``-c`` overrides are the
146
+ same ones :func:`build_sandbox_config_overrides` produces for the
147
+ app-server (one SSOT). No overrides are emitted outside workspace-write.
148
+ """
149
+ return ["--sandbox", settings.mode, *build_sandbox_config_overrides(settings)]