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/verify.py ADDED
@@ -0,0 +1,352 @@
1
+ """Codex seed verify + refresh via OpenAI's OIDC token endpoint (host-free,
2
+ non-billable) — with the agent probe kept as a documented fallback.
3
+
4
+ Primary path: read the seed's ``auth.json`` and, for a ChatGPT-mode seed whose
5
+ token is stale (codex refreshes proactively after 8 days — TOKEN_REFRESH_INTERVAL),
6
+ perform a standard OIDC ``refresh_token`` grant against codex's hardcoded
7
+ refresh URL (_REFRESH_URL; NOT the OIDC discovery token_endpoint — see the facts
8
+ block below), writing the rotated tokens back into the seed. No codex process,
9
+ no model inference — mirrors optio-claudecode's direct-endpoint ``oauth.py`` and
10
+ optio-grok's ``verify.py`` (grok's discovery token_endpoint IS its refresh URL;
11
+ codex's is not — the one divergence in this path).
12
+
13
+ Fallback (codex-specific divergence from grok, which removed its probe): when
14
+ OIDC discovery is unreachable (no usable ``token_endpoint`` in the response —
15
+ used only to confirm reachability), fall back to the billable
16
+ agent probe (``codex exec --json … '<challenge>'``) — the previous behavior —
17
+ so a seed is still verifiable if the endpoint is unreachable.
18
+
19
+ OpenAI OIDC facts (pinned Task 0, 2026-07-03, codex-cli 0.142.5):
20
+ issuer = https://auth.openai.com
21
+ discovery = <issuer>/.well-known/openid-configuration
22
+ discovery.token_endpoint = https://auth.openai.com/api/accounts/oauth/token
23
+ -- an account-management surface; NOT codex's refresh URL.
24
+ Used here ONLY as a reachability signal (discovery down
25
+ -> agent-probe fallback), never as the refresh endpoint.
26
+ refresh_url = https://auth.openai.com/oauth/token (codex hardcodes
27
+ this; env override CODEX_REFRESH_TOKEN_URL_OVERRIDE)
28
+ public client_id = app_EMoamEEZ73f0CkXaXp7hrann (login OAuth URL; no secret)
29
+ auth.json shape = {"OPENAI_API_KEY": null|str, "auth_mode": <str>,
30
+ "tokens": {"id_token","access_token","refresh_token",
31
+ "account_id"} | null,
32
+ "last_refresh": <RFC3339 (nanosecond) / epoch>}
33
+ A refresh rotates tokens.access_token + tokens.refresh_token (+ tokens.id_token
34
+ if returned) and stamps last_refresh; account_id, auth_mode and OPENAI_API_KEY
35
+ are preserved (only tokens + last_refresh are mutated). API-key seeds
36
+ (OPENAI_API_KEY set, tokens null) carry no rotating token — alive-by-presence,
37
+ no refresh.
38
+
39
+ NOTE: endpoint/grant/public-client/shape are pinned above; the exact request
40
+ headers want one confirmation against a live seed (a wrong guess fails CLOSED:
41
+ a 4xx marks the seed dead; a network/discovery error is inconclusive and never
42
+ retires a healthy seed).
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import asyncio
48
+ import io
49
+ import json
50
+ import logging
51
+ import os
52
+ import re
53
+ import tarfile
54
+ import urllib.parse
55
+ import urllib.request
56
+ import uuid
57
+ from datetime import datetime, timedelta, timezone
58
+ from typing import Callable
59
+ from urllib.error import HTTPError, URLError
60
+
61
+ from optio_host.paths import task_dir
62
+
63
+ from optio_agents import seeds
64
+ from optio_codex import host_actions
65
+ from optio_codex.seed_manifest import CODEX_SEED_MANIFEST, CODEX_SEED_SUFFIX
66
+
67
+ _LOG = logging.getLogger(__name__)
68
+
69
+ _ISSUER = "https://auth.openai.com"
70
+ _CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
71
+ # Codex hardcodes its ChatGPT refresh URL (manager.rs) — it is NOT the OIDC
72
+ # discovery `token_endpoint` (…/api/accounts/oauth/token), which is a separate
73
+ # account-management surface. Honor codex's own env override.
74
+ _REFRESH_URL = os.environ.get(
75
+ "CODEX_REFRESH_TOKEN_URL_OVERRIDE", "https://auth.openai.com/oauth/token"
76
+ )
77
+ _AUTH_RELPATH = "home/.codex/auth.json"
78
+ _AUTH_MEMBER = ".codex/auth.json"
79
+ _HTTP_TIMEOUT_S = 20
80
+ _USER_AGENT = "optio-codex-seed-verify/1"
81
+ # codex refreshes proactively after 8 days (manager.rs TOKEN_REFRESH_INTERVAL).
82
+ _REFRESH_AFTER = timedelta(days=8)
83
+
84
+ # Sentinel: the refresh endpoint returned a 4xx (invalid_grant) — the refresh
85
+ # token lineage is definitively spent/revoked → mark the seed dead. Distinct
86
+ # from ``None`` (a network/transport failure → inconclusive, never mark dead).
87
+ _DEAD = "__dead__"
88
+
89
+ # Agent-probe fallback (discovery unavailable) — the previous behavior. The
90
+ # answer token ("paris") must NOT appear in the prompt so a prompt-echoing error
91
+ # path can never false-positive.
92
+ PROBE_PROMPT = "What is the capital of France? Answer with the city name."
93
+ PROBE_ANSWER_RE = re.compile(r"paris", re.IGNORECASE)
94
+
95
+
96
+ # --- synchronous HTTP (run in an executor; no host, no codex) ----------------
97
+
98
+ def _discover_sync(issuer: str) -> "dict | None":
99
+ url = issuer.rstrip("/") + "/.well-known/openid-configuration"
100
+ try:
101
+ req = urllib.request.Request(
102
+ url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
103
+ method="GET",
104
+ )
105
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
106
+ return json.loads(resp.read().decode("utf-8"))
107
+ except (HTTPError, URLError, OSError, ValueError):
108
+ return None
109
+
110
+
111
+ def _refresh_sync(refresh_url: str, refresh_token: str, client_id: str) -> "dict | str | None":
112
+ """OIDC refresh_token grant against codex's hardcoded refresh URL (NOT the
113
+ discovery token_endpoint — see module docstring). Returns the token response
114
+ dict on success, ``_DEAD`` on a 4xx (dead lineage), or ``None`` on a
115
+ transport error."""
116
+ body = urllib.parse.urlencode({
117
+ "grant_type": "refresh_token",
118
+ "refresh_token": refresh_token,
119
+ "client_id": client_id,
120
+ }).encode("utf-8")
121
+ req = urllib.request.Request(
122
+ refresh_url, data=body, method="POST",
123
+ headers={
124
+ "User-Agent": _USER_AGENT,
125
+ "Content-Type": "application/x-www-form-urlencoded",
126
+ "Accept": "application/json",
127
+ },
128
+ )
129
+ try:
130
+ with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
131
+ return json.loads(resp.read().decode("utf-8"))
132
+ except HTTPError:
133
+ return _DEAD # invalid_grant / 4xx → the refresh token is spent
134
+ except (URLError, OSError, ValueError):
135
+ return None # network/transport → inconclusive
136
+
137
+
138
+ async def _in_executor(fn, *args):
139
+ return await asyncio.get_event_loop().run_in_executor(None, fn, *args)
140
+
141
+
142
+ # --- helpers -----------------------------------------------------------------
143
+
144
+ def _parse_last_refresh(value) -> "datetime | None":
145
+ """Parse codex's ``last_refresh`` — an RFC3339 string (possibly ``Z`` /
146
+ sub-second/nanosecond) or an epoch number. None when unparseable/absent (→
147
+ treated as stale, i.e. refresh)."""
148
+ if isinstance(value, (int, float)):
149
+ ts = value / 1000 if value > 1e12 else value
150
+ try:
151
+ return datetime.fromtimestamp(ts, tz=timezone.utc)
152
+ except (ValueError, OSError, OverflowError):
153
+ return None
154
+ if isinstance(value, str) and value.strip():
155
+ s = value.strip()
156
+ if s.endswith("Z"):
157
+ s = s[:-1] + "+00:00"
158
+ s = re.sub(r"\.(\d{6})\d+", r".\1", s) # nanoseconds → microseconds
159
+ try:
160
+ dt = datetime.fromisoformat(s)
161
+ except ValueError:
162
+ return None
163
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
164
+ return None
165
+
166
+
167
+ def _read_auth(blob_plain: bytes) -> "dict | None":
168
+ """The codex auth.json dict from the seed tar, or None if absent/malformed."""
169
+ try:
170
+ with tarfile.open(fileobj=io.BytesIO(blob_plain), mode="r:gz") as tar:
171
+ f = tar.extractfile(_AUTH_MEMBER)
172
+ if f is None:
173
+ return None
174
+ auth = json.loads(f.read().decode("utf-8"))
175
+ except (tarfile.TarError, KeyError, ValueError, UnicodeDecodeError):
176
+ return None
177
+ return auth if isinstance(auth, dict) else None
178
+
179
+
180
+ # --- public API --------------------------------------------------------------
181
+
182
+ async def verify_and_refresh_seed(
183
+ db,
184
+ *,
185
+ prefix: str,
186
+ suffix: str = CODEX_SEED_SUFFIX,
187
+ seed_id: str,
188
+ ssh=None,
189
+ install_dir: str | None = None,
190
+ encrypt: "Callable[[bytes], bytes] | None" = None,
191
+ decrypt: "Callable[[bytes], bytes] | None" = None,
192
+ ) -> bool:
193
+ """Verify a codex seed host-free via OpenAI's OIDC token endpoint; refresh
194
+ the rotating token in place. Falls back to the billable agent probe only
195
+ when OIDC discovery is unavailable.
196
+
197
+ Returns True iff the seed is alive. Never raises for a dead seed. Marks pool
198
+ status ``dead`` ONLY on a definitive dead signal (no refresh token,
199
+ malformed auth, or a 4xx invalid_grant); a transport/discovery failure is
200
+ inconclusive and leaves status untouched. Call only on a FREE seed or one
201
+ whose lease the caller holds (a refresh rotates the single-use token).
202
+ """
203
+ from motor.motor_asyncio import AsyncIOMotorGridFSBucket
204
+
205
+ doc = await seeds.load_seed(db, prefix=prefix, suffix=suffix, seed_id=seed_id)
206
+ if doc is None:
207
+ return False
208
+
209
+ async def _finish(alive: bool, *, mark_dead: bool) -> bool:
210
+ await seeds.declare_metadata(
211
+ db, prefix=prefix, suffix=suffix, seed_id=seed_id,
212
+ metadata={"verify": {"alive": alive, "checkedAt": datetime.now(timezone.utc)}},
213
+ )
214
+ if alive:
215
+ await seeds.mark_seed_status(db, prefix=prefix, suffix=suffix, seed_id=seed_id, status="alive")
216
+ elif mark_dead:
217
+ await seeds.mark_seed_status(db, prefix=prefix, suffix=suffix, seed_id=seed_id, status="dead")
218
+ return alive
219
+
220
+ buf = io.BytesIO()
221
+ await AsyncIOMotorGridFSBucket(db).download_to_stream(doc["blobId"], buf)
222
+ dec = decrypt or (lambda b: b)
223
+ auth = _read_auth(dec(buf.getvalue()))
224
+ if auth is None:
225
+ return await _finish(False, mark_dead=True)
226
+
227
+ tokens = auth.get("tokens")
228
+ # API-key seed: no rotating token → alive by presence.
229
+ if not tokens:
230
+ if auth.get("OPENAI_API_KEY"):
231
+ return await _finish(True, mark_dead=False)
232
+ return await _finish(False, mark_dead=True) # neither tokens nor key
233
+
234
+ refresh_token = tokens.get("refresh_token") if isinstance(tokens, dict) else None
235
+ if not refresh_token:
236
+ return await _finish(False, mark_dead=True)
237
+
238
+ # Discovery is a REACHABILITY gate only: if OpenAI's OIDC surface is
239
+ # unreachable we fall back to the agent probe. We deliberately do NOT use
240
+ # disco["token_endpoint"] as the refresh URL — for codex that is a different
241
+ # (account-management) surface; codex refreshes against the hardcoded
242
+ # _REFRESH_URL (see module docstring / Task 0 facts).
243
+ disco = await _in_executor(_discover_sync, _ISSUER)
244
+ if not isinstance(disco, dict) or not disco.get("token_endpoint"):
245
+ _LOG.warning(
246
+ "seed %s: OIDC discovery unavailable — falling back to the agent probe",
247
+ seed_id,
248
+ )
249
+ return await _verify_via_probe(
250
+ db, prefix=prefix, suffix=suffix, seed_id=seed_id, ssh=ssh,
251
+ install_dir=install_dir, encrypt=encrypt, decrypt=decrypt,
252
+ finish=_finish,
253
+ )
254
+
255
+ now = datetime.now(timezone.utc)
256
+ last = _parse_last_refresh(auth.get("last_refresh"))
257
+ need_refresh = last is None or (now - last) >= _REFRESH_AFTER
258
+ if not need_refresh:
259
+ # Fresh (codex hasn't hit its proactive-refresh window) → trust alive,
260
+ # do not rotate. (Codex tokens carry no cheap userinfo scope like grok;
261
+ # freshness is the liveness signal — documented divergence.)
262
+ return await _finish(True, mark_dead=False)
263
+
264
+ resp = await _in_executor(_refresh_sync, _REFRESH_URL, refresh_token, _CLIENT_ID)
265
+ if resp is _DEAD:
266
+ return await _finish(False, mark_dead=True)
267
+ if not isinstance(resp, dict) or not resp.get("access_token"):
268
+ return await _finish(False, mark_dead=False) # transport → inconclusive
269
+
270
+ tokens["access_token"] = resp["access_token"]
271
+ if resp.get("refresh_token"):
272
+ tokens["refresh_token"] = resp["refresh_token"]
273
+ if resp.get("id_token"):
274
+ tokens["id_token"] = resp["id_token"]
275
+ auth["tokens"] = tokens
276
+ auth["last_refresh"] = now.isoformat().replace("+00:00", "Z")
277
+ try:
278
+ await seeds.overwrite_seed_member(
279
+ db, prefix=prefix, suffix=suffix, seed_id=seed_id,
280
+ member_path=_AUTH_MEMBER, content=json.dumps(auth).encode("utf-8"),
281
+ encrypt=encrypt, decrypt=decrypt,
282
+ )
283
+ except Exception: # noqa: BLE001 — save-back failed; the refresh still rotated
284
+ _LOG.exception("seed %s: refreshed auth save-back failed", seed_id)
285
+ return await _finish(True, mark_dead=False)
286
+
287
+
288
+ async def _verify_via_probe(
289
+ db, *, prefix, suffix, seed_id, ssh, install_dir, encrypt, decrypt, finish,
290
+ ) -> bool:
291
+ """Fallback: plant the seed and run one billable ``codex exec`` challenge
292
+ probe; verdict from stdout, rotated auth.json saved back. The previous
293
+ behavior, retained for when OIDC discovery is unavailable.
294
+
295
+ The probe scrubs OPENAI_API_KEY from its environment
296
+ (``host_actions._PROBE_SCRUB_ENV_KEYS``), so an ambient provider key on the
297
+ verifying host cannot authenticate the probe via codex's API-key fallback
298
+ and mask a dead ChatGPT-mode seed."""
299
+ taskdir = task_dir(
300
+ ssh=ssh, process_id=f"seed-verify-{uuid.uuid4().hex[:12]}",
301
+ consumer_name="optio-codex",
302
+ )
303
+ host = host_actions.build_host(ssh, taskdir)
304
+ await host.connect()
305
+ try:
306
+ await host.setup_workdir()
307
+ codex_exec = await host_actions.resolve_codex(
308
+ host, install_dir=install_dir, install_if_missing=False,
309
+ )
310
+ await seeds.plant_seed(
311
+ db, host, prefix=prefix, seed_id=seed_id,
312
+ manifest=CODEX_SEED_MANIFEST, suffix=suffix, decrypt=decrypt,
313
+ )
314
+ stdout, exit_code = await host_actions.run_codex_probe(
315
+ host, codex_executable=codex_exec, prompt=PROBE_PROMPT,
316
+ )
317
+ # Verdict: stdout-only. The exit code carries zero verdict bits (answer
318
+ # present proves the full chain regardless) — diagnostics only.
319
+ alive = PROBE_ANSWER_RE.search(stdout) is not None
320
+ if not alive:
321
+ _LOG.info(
322
+ "seed %s: probe dead (exit=%s, stdout[:200]=%r)",
323
+ seed_id, exit_code, stdout[:200],
324
+ )
325
+ # Write back the (possibly rotated) auth.json — valid files only (tokens
326
+ # or OPENAI_API_KEY non-null).
327
+ workdir = host.workdir.rstrip("/")
328
+ try:
329
+ auth_raw = await host.fetch_bytes_from_host(f"{workdir}/{_AUTH_RELPATH}")
330
+ auth = json.loads(auth_raw.decode("utf-8"))
331
+ if isinstance(auth, dict) and (
332
+ auth.get("tokens") is not None or auth.get("OPENAI_API_KEY") is not None
333
+ ):
334
+ await seeds.overwrite_seed_member(
335
+ db, prefix=prefix, suffix=suffix, seed_id=seed_id,
336
+ member_path=_AUTH_MEMBER, content=auth_raw,
337
+ encrypt=encrypt, decrypt=decrypt,
338
+ )
339
+ except (FileNotFoundError, ValueError, UnicodeDecodeError):
340
+ _LOG.warning("seed %s: no valid auth.json after probe; skipping write-back", seed_id)
341
+ # Probe failure is a definitive dead signal (the seed's own creds were
342
+ # exercised end-to-end), so mark_dead=True here (unlike a transport error).
343
+ return await finish(alive, mark_dead=not alive)
344
+ finally:
345
+ try:
346
+ await host.cleanup_taskdir(aggressive=True)
347
+ except Exception: # noqa: BLE001
348
+ _LOG.exception("verify: cleanup_taskdir failed")
349
+ try:
350
+ await host.disconnect()
351
+ except Exception: # noqa: BLE001
352
+ _LOG.exception("verify: host.disconnect failed")
@@ -0,0 +1,220 @@
1
+ Metadata-Version: 2.4
2
+ Name: optio-codex
3
+ Version: 0.1.0
4
+ Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
+ Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/deai-network/optio
8
+ Project-URL: Repository, https://github.com/deai-network/optio
9
+ Project-URL: Issues, https://github.com/deai-network/optio/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Classifier: Framework :: AsyncIO
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: optio-core<0.4,>=0.3
24
+ Requires-Dist: optio-host<0.3,>=0.2
25
+ Requires-Dist: optio-agents<0.4,>=0.3
26
+ Requires-Dist: asyncssh>=2.14
27
+ Requires-Dist: aiohttp>=3.9
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
31
+
32
+ # optio-codex
33
+
34
+ Run OpenAI Codex as an `optio` task — either as the interactive TUI embedded
35
+ in the optio dashboard via an iframe widget served by `ttyd`, or in
36
+ conversation mode (codex app-server over stdio) rendered by the
37
+ `optio-conversation-ui` widget. Local or remote (SSH) workers.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install optio-codex
43
+ ```
44
+
45
+ Requires Python 3.11+. Pulls `optio-core`, `optio-host`, `optio-agents`,
46
+ `asyncssh`, and `aiohttp`.
47
+
48
+ ## What it does
49
+
50
+ optio-codex launches `codex` inside a detached tmux session, serves the
51
+ TUI over `ttyd`, and coordinates with the host harness through the
52
+ `optio.log` keyword channel (STATUS / DELIVERABLE / DONE / ERROR). The
53
+ agent reads its task from an `AGENTS.md` file planted in the workdir.
54
+ The tmux+ttyd machinery follows the optio-claudecode pattern, including
55
+ browser handling: `redirect` — `codex login` opens the loopback OAuth URL
56
+ via `xdg-open`, which the redirect shim captures as a `BROWSER:` marker so
57
+ the harness surfaces it to the operator (who completes the sign-in),
58
+ instead of silently swallowing it.
59
+
60
+ ### Isolation
61
+
62
+ Each task runs under an isolated `HOME` (`<workdir>/home`, created at
63
+ prepare time) with `CODEX_HOME` pointing at `<workdir>/home/.codex`, so
64
+ the operator's real `~/.codex` identity and config do not leak into the
65
+ session. The codex binary is launched via a per-task path
66
+ (`<workdir>/home/.local/bin/codex`), so teardown only ever kills this
67
+ task's process.
68
+
69
+ ### Filesystem sandbox
70
+
71
+ Beyond the per-task `HOME`, every codex tool subprocess is confined by
72
+ codex's **own native sandbox** — kernel-enforced (bundled bubblewrap
73
+ primary, Landlock+seccomp fallback on Linux), covering all shell/tool
74
+ commands the agent runs. optio-codex does **not** vendor claustrum for
75
+ this; it renders one resolved sandbox posture (`fs_allowlist.py` SSOT)
76
+ onto every launch surface: the interactive TUI argv, the `codex exec`
77
+ probe flags, and the `codex app-server` command line (`-c
78
+ sandbox_workspace_write.*` overrides; the sandbox *mode* is selected
79
+ out-of-band via `thread/start`'s `sandbox` enum — the 0.142.5 app-server
80
+ schema has no `thread/start.sandboxPolicy` object).
81
+
82
+ `fs_isolation=True` (the default) selects codex `workspace-write`: writes
83
+ are confined to the task workdir, `/tmp`, and any `rw` grants; **reads are
84
+ not restricted**. That read-open behaviour is a deliberate divergence from
85
+ optio-grok/optio-claudecode (whose sandboxes also deny reads) — so
86
+ `AllowedDir("…", "ro")` is a *documented no-op* on codex (an additive grant
87
+ that is already trivially satisfied), kept only for cross-wrapper config
88
+ portability. Only `AllowedDir("…", "rw")` changes behaviour, becoming a
89
+ `sandbox_workspace_write.writable_roots` entry (`~/` expands against the
90
+ real host home at launch). Network access is **OFF** by default (stricter
91
+ than the other wrappers, whose fs sandboxes never touch the network);
92
+ `network_access=True` relaxes it. `fs_isolation=False` runs codex
93
+ unconfined (`danger-full-access`).
94
+
95
+ Extra grants and the mode are cross-validated at config time — e.g.
96
+ `fs_isolation=True` with `sandbox="danger-full-access"`, or an `rw` grant
97
+ under an explicit `read-only` mode, raise `ValueError` rather than silently
98
+ mis-configuring the sandbox.
99
+
100
+ **No optio-side enforcement guard is needed.** codex fails **closed**: on a
101
+ host with no working sandbox mechanism (bubblewrap or Landlock), codex
102
+ errors or panics and the model's command never runs — it never falls back
103
+ to running unconfined (the only unconfined path is the explicit
104
+ `--dangerously-bypass-approvals-and-sandbox` opt-out, which optio-codex
105
+ never emits). This was verified empirically against codex-cli 0.142.5 (see
106
+ the Stage-8 probe verdict in `docs/2026-07-02-optio-codex-design.md`), so
107
+ optio-codex relies on that fail-closed guarantee instead of a launch-time
108
+ probe. As a free hardening bonus, `.codex/` and `.git/` under a writable
109
+ root stay read-only to the agent's shell, so the sandboxed agent cannot
110
+ rewrite its own per-task `auth.json` even though `CODEX_HOME` lives inside
111
+ the workdir.
112
+
113
+ ### Authentication
114
+
115
+ The primary mechanism is **seeds**: log in once, reuse the identity for
116
+ every later task. Run the setup task and log into codex interactively in
117
+ the embedded terminal (`codex login --device-auth`, or `codex login
118
+ --with-api-key`); on teardown the session's `home/.codex` (`auth.json` +
119
+ `config.toml`) is captured as a reusable seed and surfaced through the
120
+ `on_seed_saved` callback. A later task started with
121
+ `CodexTaskConfig(seed_id=…)` merges that stored identity into its fresh
122
+ workdir before launch, so codex starts already logged-in — and the new
123
+ workdir is pre-trusted automatically (`[projects."<workdir>"] trust_level =
124
+ "trusted"` appended to `config.toml`), so codex never prompts about an
125
+ untrusted directory. `seed_id` also accepts a `SeedProvider` callable that
126
+ leases a seed from a pool (the task's `process_id` is the lease holder).
127
+
128
+ Store-binding CRUD helpers (`list_seeds` / `delete_seed` / `purge_seed`)
129
+ operate over the `{prefix}_codex_seeds` collection.
130
+
131
+ **Credential rotation (why a seeded session does more than merge-once):**
132
+ codex's ChatGPT-mode `auth.json` carries a *single-use rotating* refresh
133
+ token (openai/codex#15410) — codex proactively refreshes it after 8 days
134
+ and on any 401, rewriting `auth.json` in place, and a used refresh token
135
+ invalidates every other copy. So a seeded session runs an in-session
136
+ **credential watcher** that saves the rotated `auth.json` back into the
137
+ seed (plus a final teardown backstop); pooled seeds take a **lease** (one
138
+ live lineage per seed — the watcher renews it and aborts the session on
139
+ lease loss); and `verify_and_refresh_seed` refreshes idle pooled seeds
140
+ **host-free** — a direct OpenAI OIDC `refresh_token` grant (no codex
141
+ process, no model turn, non-billable) that persists a fresh token before
142
+ the 8-day cliff, falling back to a headless `codex exec` probe only when
143
+ OIDC discovery is unreachable.
144
+
145
+ Fallbacks without a seed: pass an API key into the session env
146
+ (`CodexTaskConfig(env={"OPENAI_API_KEY": …})`) or log in interactively
147
+ (`codex login`) inside the embedded terminal.
148
+
149
+ ### Binary provisioning
150
+
151
+ The codex binary is resolved through an optio-owned, evictable cache on the
152
+ worker — `OPTIO_CODEX_CACHE_DIR`, else
153
+ `${XDG_CACHE_HOME:-~/.cache}/optio-codex/bin` — resolved host-side, so it is
154
+ correct on a remote SSH worker and never lives under a task workdir or the
155
+ operator's `~/.codex`. On a cache miss the cache is seeded from a host
156
+ `codex` on `PATH` (`cp -L` deref → a stable copy), or, when none exists, the
157
+ pinned release is auto-downloaded (`rust-v0.142.5`, static musl,
158
+ `{x86_64,aarch64}-unknown-linux-musl`). The per-task launch symlink
159
+ (`<workdir>/home/.local/bin/codex`) is preserved and points into the cache,
160
+ so task-scoped teardown stays unaffected.
161
+
162
+ ## Status — Stages 0–8 (feature-complete against the Appendix-A parity bar)
163
+
164
+ Verified against `docs/writing-agent-wrappers.md` Appendix A — 28 of 29 items
165
+ green (see `docs/2026-07-02-optio-codex-parity-audit.md` for per-item
166
+ `file:line` evidence). Suite: 188 passed, 4 skipped (the skips are the opt-in
167
+ real-binary tests, env-gated, never in the default suite).
168
+
169
+ Shipped:
170
+
171
+ - **Two run modes** — iframe/ttyd interactive TUI *and* conversation mode
172
+ (codex app-server over stdio) with a conversation-ui widget
173
+ (`optio-conversation-ui`, `widgetData.protocol = "codex"` → `CodexView`)
174
+ - `optio.log` keyword-protocol coordination + exit-status DONE/ERROR channel
175
+ - per-task `HOME` / `CODEX_HOME` isolation (tree provisioned at prepare)
176
+ - **filesystem isolation** via codex's native sandbox — default-ON
177
+ `fs_isolation` (`workspace-write`), `extra_allowed_dirs` (`rw` grants →
178
+ `writable_roots`), `network_access` (OFF by default); fail-closed, no
179
+ optio-side guard needed (see the Filesystem sandbox section above)
180
+ - task-scoped teardown (per-task codex path; orphan-ttyd reap = crash-orphan
181
+ rescue)
182
+ - `create_codex_task`, `run_codex_session`, `CodexTaskConfig`
183
+ - remote SSH workers (`ssh=SSHConfig(...)` routes to `RemoteHost`; verified
184
+ end-to-end against a docker-sshd harness)
185
+ - resume / workdir snapshots: session-id-keyed relaunch (`codex resume <id>`,
186
+ never `resume --last`), Mongo snapshot store (retention 5, single workdir
187
+ GridFS blob carrying `home/.codex/sessions`), `resume.log` + AGENTS.md
188
+ resume section synced to the snapshot exclude list
189
+ (`workdir_exclude`; defaults drop `home/.codex/packages`, `*.sqlite*`,
190
+ caches — never `home/.codex/sessions`); auto-resume-on-restart via
191
+ optio-core (`supports_resume=True`)
192
+ - seeds: log-in-once capture (`on_seed_saved`) + `seed_id` consume with
193
+ automatic workdir pre-trust; store-binding CRUD (`list_seeds` /
194
+ `delete_seed` / `purge_seed`) over `{prefix}_codex_seeds`
195
+ - pool leases + in-session credential save-back (single-use rotating
196
+ refresh token) with a teardown backstop; lease loss aborts the session
197
+ - host-free `verify_and_refresh_seed` — primary path is a direct OpenAI OIDC
198
+ `refresh_token` grant (non-billable, no codex process) with rotated-token
199
+ write-back and pool-status stamping; falls back to a headless `codex exec`
200
+ probe (stdout-only verdict) only when OIDC discovery is unreachable
201
+ - optio-owned evictable binary cache (`OPTIO_CODEX_CACHE_DIR`), seeded from a
202
+ host binary (`cp -L`) or real GitHub-release auto-download (pinned
203
+ `rust-v0.142.5`, musl); per-task launch symlink preserved
204
+ - conversation-ui surface: permission gate, **inline** model switching, file
205
+ upload/download (`optio-file:`), tool verbosity
206
+ - demo trio: seed-setup + seed-pinned iframe + seed-pinned conversation tasks
207
+ (auto-appear via `fw.resync()`)
208
+
209
+ Remaining opt gaps (deliberate — see the parity audit):
210
+
211
+ - **session restore / rebase (scripted transcript reconstruction):** not
212
+ shipped. codex's resume story is snapshot + `codex resume <id>` (shipped
213
+ above); claudecode's scripted `transcript.py` rebase is engine-specific and
214
+ has no codex analogue. optio-grok and optio-opencode also omit it — parity,
215
+ not a regression.
216
+ - **at-rest encryption of the session blob:** the `encrypt`/`decrypt` seam is
217
+ plumbed through but not activated (sessions pass `encrypt=None`) — identical
218
+ posture to every other optio wrapper.
219
+
220
+ Not yet published to PyPI (first release is a separate, user-approved step).
@@ -0,0 +1,17 @@
1
+ optio_codex/__init__.py,sha256=7H3gMx-YDBwDmoqrGzTlxSUnYMwA6Lp6Vnna_Z2zRaY,1412
2
+ optio_codex/conversation.py,sha256=Pa7PEuU_MYGNuS3EWEX2n7UexIIA9nkV0ExgcKCSfB8,24795
3
+ optio_codex/conversation_listener.py,sha256=_0ocQ6AhM2fUqIeFttrqJHew5BNrlhwATNX-ONPpmT8,14295
4
+ optio_codex/cred_watcher.py,sha256=N8Ie1yj1JL47UHtaqWO-zAB2kdIBXrg1JlxMmuYQFbQ,5246
5
+ optio_codex/fs_allowlist.py,sha256=mnrhBkeEHpB11Yq8O_cyOSBpJvb-OcA_Zq9qwKdfH6g,6680
6
+ optio_codex/host_actions.py,sha256=nh2N5kIjJs24XQc9Crvk6i_qGrxUladb0XB_y_GY158,41520
7
+ optio_codex/models.py,sha256=TTBTvuKB2Wo7dGcIQsSxmO67_0an9AxpIk0u6p3_LEI,2776
8
+ optio_codex/prompt.py,sha256=Ornbe1pEfQSO5v885_WXGoc7dzR6ir3ZBKv05iyE5Do,7610
9
+ optio_codex/seed_manifest.py,sha256=Cnj2jFEP-0vk4_a4QxIwu8TbOs4-RjB-hiNZiO4KkRE,3889
10
+ optio_codex/session.py,sha256=-f56icy1oep47zzhpcKkJA52XpS8QULt3m24yjUVJF4,32929
11
+ optio_codex/snapshots.py,sha256=_lomxFocn4064PL_TL81WqXq_LnA2mMQGWzR04H_29A,5518
12
+ optio_codex/types.py,sha256=YaHDiOeeXhgrZm-jCGdmPhrPiYA5yi2iBYVSGiw3Te4,15125
13
+ optio_codex/verify.py,sha256=KIRmdRBXX6rr2Zv9jm_eHiCLmxxkMlafkDX2b3jBAZQ,15762
14
+ optio_codex-0.1.0.dist-info/METADATA,sha256=PXS4E0RE9T55h6ZC7Q0fQDecQq2qG7bgMyWTiHJ3GN8,11679
15
+ optio_codex-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ optio_codex-0.1.0.dist-info/top_level.txt,sha256=zR3_V-kB42Rped033T8CESbRT-qh9uarmfzC8WP1C7I,12
17
+ optio_codex-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ optio_codex