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.
- optio_codex/__init__.py +66 -0
- optio_codex/conversation.py +553 -0
- optio_codex/conversation_listener.py +322 -0
- optio_codex/cred_watcher.py +138 -0
- optio_codex/fs_allowlist.py +149 -0
- optio_codex/host_actions.py +1070 -0
- optio_codex/models.py +68 -0
- optio_codex/prompt.py +184 -0
- optio_codex/seed_manifest.py +91 -0
- optio_codex/session.py +731 -0
- optio_codex/snapshots.py +147 -0
- optio_codex/types.py +325 -0
- optio_codex/verify.py +352 -0
- optio_codex-0.1.0.dist-info/METADATA +220 -0
- optio_codex-0.1.0.dist-info/RECORD +17 -0
- optio_codex-0.1.0.dist-info/WHEEL +5 -0
- optio_codex-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)]
|