optio-codex 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (59) hide show
  1. {optio_codex-0.2.0 → optio_codex-0.3.0}/PKG-INFO +2 -2
  2. {optio_codex-0.2.0 → optio_codex-0.3.0}/pyproject.toml +2 -2
  3. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/__init__.py +2 -0
  4. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/conversation.py +2 -1
  5. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/host_actions.py +98 -2
  6. optio_codex-0.3.0/src/optio_codex/info.py +7 -0
  7. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/session.py +87 -5
  8. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/types.py +61 -37
  9. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/PKG-INFO +2 -2
  10. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/SOURCES.txt +3 -0
  11. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/requires.txt +1 -1
  12. optio_codex-0.3.0/tests/test_agent_info.py +15 -0
  13. optio_codex-0.3.0/tests/test_claustrum.py +133 -0
  14. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_config.py +82 -30
  15. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_config_hooks.py +1 -1
  16. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation.py +36 -36
  17. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation_controls.py +8 -8
  18. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation_listener.py +24 -6
  19. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_cred_watcher.py +6 -2
  20. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_file_download.py +1 -1
  21. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_file_upload.py +1 -1
  22. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_fs_allowlist.py +12 -5
  23. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_host_actions.py +26 -1
  24. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_input_wiring.py +2 -1
  25. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_conversation.py +1 -0
  26. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_seed_resume.py +4 -0
  27. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_session.py +1 -0
  28. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_conversation.py +32 -28
  29. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_lease.py +1 -0
  30. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_local.py +7 -1
  31. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_remote.py +5 -0
  32. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_resume.py +1 -0
  33. optio_codex-0.3.0/tests/test_session_sandbox.py +143 -0
  34. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_seed.py +1 -0
  35. optio_codex-0.2.0/tests/test_session_sandbox.py +0 -93
  36. {optio_codex-0.2.0 → optio_codex-0.3.0}/README.md +0 -0
  37. {optio_codex-0.2.0 → optio_codex-0.3.0}/setup.cfg +0 -0
  38. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/conversation_listener.py +0 -0
  39. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/cred_watcher.py +0 -0
  40. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/fs_allowlist.py +0 -0
  41. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/models.py +0 -0
  42. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/prompt.py +0 -0
  43. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/seed_manifest.py +0 -0
  44. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/snapshots.py +0 -0
  45. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/verify.py +0 -0
  46. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/dependency_links.txt +0 -0
  47. {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/top_level.txt +0 -0
  48. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_await_codex_gone.py +0 -0
  49. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_codex_cache.py +0 -0
  50. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_import.py +0 -0
  51. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_kill_ttyd_by_socket.py +0 -0
  52. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_models.py +0 -0
  53. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_prompt.py +0 -0
  54. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_sandbox_enforce.py +0 -0
  55. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_seed_manifest.py +0 -0
  56. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_snapshots.py +0 -0
  57. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_teardown_session_tree.py +0 -0
  58. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_verify.py +0 -0
  59. {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_workdir_trust.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-codex
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -22,7 +22,7 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: optio-core<0.4,>=0.3
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.5,>=0.4
25
+ Requires-Dist: optio-agents<0.6,>=0.5
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Requires-Dist: aiohttp>=3.9
28
28
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-codex"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -28,7 +28,7 @@ classifiers = [
28
28
  dependencies = [
29
29
  "optio-core>=0.3,<0.4",
30
30
  "optio-host>=0.2,<0.3",
31
- "optio-agents>=0.4,<0.5",
31
+ "optio-agents>=0.5,<0.6",
32
32
  "asyncssh>=2.14",
33
33
  "aiohttp>=3.9",
34
34
  ]
@@ -9,6 +9,7 @@ from optio_host import (
9
9
  SSHConfig,
10
10
  )
11
11
 
12
+ from optio_codex.info import AGENT_INFO
12
13
  from optio_codex.seed_manifest import (
13
14
  CODEX_CRED_MANIFEST,
14
15
  CODEX_SEED_MANIFEST,
@@ -38,6 +39,7 @@ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
38
39
 
39
40
 
40
41
  __all__ = [
42
+ "AGENT_INFO",
41
43
  "create_codex_task",
42
44
  "run_codex_session",
43
45
  "AllowedDir",
@@ -101,6 +101,7 @@ from optio_agents.conversation import (
101
101
  PermissionRequest,
102
102
  )
103
103
  from optio_codex import models as codex_models
104
+ from optio_codex.info import AGENT_INFO
104
105
 
105
106
  _LOG = logging.getLogger(__name__)
106
107
 
@@ -120,7 +121,7 @@ class CodexConversation:
120
121
  self,
121
122
  *,
122
123
  cwd: str,
123
- agent_label: str = "codex",
124
+ agent_label: str = AGENT_INFO.slug,
124
125
  permission_gate: bool = False,
125
126
  model: str | None = None,
126
127
  reasoning_effort: str | None = None,
@@ -13,6 +13,8 @@ from datetime import datetime, timezone
13
13
  from typing import TYPE_CHECKING
14
14
 
15
15
  from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
16
+ from optio_agents import claustrum
17
+ from optio_agents import pane_surfacing
16
18
  from optio_agents import tmux_input as _tmux_input
17
19
  from optio_host.host import proc_wait
18
20
 
@@ -146,6 +148,65 @@ async def _resolve_codex_cache_dir(host: "Host", override: str | None) -> str:
146
148
  return path.rstrip("/")
147
149
 
148
150
 
151
+ async def ensure_claustrum_installed(
152
+ hook_ctx: "HookContextProtocol",
153
+ *,
154
+ install_dir: str | None = None,
155
+ ) -> str:
156
+ """Ensure a functioning claustrum binary (pinned tag, host arch) is on the
157
+ host, and return its path.
158
+
159
+ Thin wrapper over :func:`optio_agents.claustrum.ensure_claustrum_installed`:
160
+ it resolves the codex-owned target cache dir (on the worker) and the
161
+ engine-local build cache root, then delegates the cross-compile/place/
162
+ validate to the shared module (fail-closed, functional probe, ELF-guarded
163
+ build cache). The codex-owned grant set + wrap live in
164
+ ``session._build_claustrum_wrap``. Claustrum — not codex's native sandbox —
165
+ is the trusted fs-isolation layer: the native sandbox confines shell-exec
166
+ commands but the codex agent's OWN process tree is only whole-tree confined
167
+ by the outer Landlock ruleset; the native mode is kept for the network knob.
168
+ """
169
+ host = hook_ctx._host
170
+ cache_dir = await _resolve_codex_cache_dir(host, install_dir)
171
+ return await claustrum.ensure_claustrum_installed(
172
+ host,
173
+ cache_dir=cache_dir,
174
+ engine_cache_dir=os.path.expanduser("~/.cache/optio-codex"),
175
+ report_progress=hook_ctx.report_progress,
176
+ )
177
+
178
+
179
+ async def claustrum_newer_tag() -> str | None:
180
+ """Return the newest claustrum tag if it is newer than the pinned one, else None.
181
+
182
+ Engine-side egress only. Best-effort: network failure returns None (no
183
+ notice). Mirrors the claudecode/cursor shim.
184
+ """
185
+ try:
186
+ p = await asyncio.create_subprocess_exec(
187
+ "git", "ls-remote", "--tags", "--refs", claustrum.CLAUSTRUM_REPO,
188
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
189
+ )
190
+ out, _ = await p.communicate()
191
+ if p.returncode != 0:
192
+ return None
193
+ except Exception: # noqa: BLE001
194
+ return None
195
+ tags = []
196
+ for line in out.decode().splitlines():
197
+ ref = line.rsplit("/", 1)[-1].strip()
198
+ if ref.startswith("v"):
199
+ tags.append(ref)
200
+
201
+ def key(t: str) -> tuple:
202
+ return tuple(int(x) for x in t.lstrip("v").split(".") if x.isdigit())
203
+
204
+ if not tags:
205
+ return None
206
+ newest = max(tags, key=key)
207
+ return newest if key(newest) > key(claustrum.CLAUSTRUM_PINNED_TAG) else None
208
+
209
+
149
210
  async def ensure_codex_installed(
150
211
  hook_ctx: "HookContextProtocol",
151
212
  *,
@@ -525,6 +586,7 @@ def _build_codex_shell_command(
525
586
  workdir: str,
526
587
  extra_env: dict[str, str] | None,
527
588
  codex_flags: list[str],
589
+ claustrum_wrap: list[str] | None = None,
528
590
  ) -> tuple[list[str], str]:
529
591
  workdir_clean = workdir.rstrip("/")
530
592
  iso = _isolation_env(workdir_clean)
@@ -541,9 +603,24 @@ def _build_codex_shell_command(
541
603
  for k, v in extra.items():
542
604
  env_assignments.append(f"{k}={v}")
543
605
 
544
- codex_argv = " ".join(shlex.quote(c) for c in [codex_path, *codex_flags])
606
+ # Stage 9: when fs_isolation is on, ``claustrum_wrap`` is the claustrum
607
+ # argv prefix (``claustrum … --``) that Landlock-confines codex + its whole
608
+ # subprocess tree. None ⇒ unconfined (fs_isolation off).
609
+ codex_argv = " ".join(
610
+ shlex.quote(c) for c in [*(claustrum_wrap or []), codex_path, *codex_flags]
611
+ )
545
612
  log_path = f"{workdir_clean}/optio.log"
546
613
 
614
+ # ALWAYS-ON launch-failure surfacing: on abnormal exit, tail the tmux
615
+ # pane mirror (codex-pane.log, written live by `tmux pipe-pane` in
616
+ # launch_ttyd_with_codex) into optio.log after the ERROR line, so the real
617
+ # failure — whatever codex paints before exiting — is not swallowed. The
618
+ # mirror uses pipe-pane INSTEAD of a stdout/stderr redirect because a
619
+ # redirect makes isatty() false and the TUI never renders. The pane_log is
620
+ # derived from the same workdir the launch mirror uses, so both resolve to
621
+ # the same file.
622
+ pane_log = pane_surfacing.pane_log_path(workdir_clean, "codex")
623
+
547
624
  if path_override is not None:
548
625
  path_expr = (
549
626
  f"export PATH={shlex.quote(f'{home_local_bin}:{path_override}')}; "
@@ -554,7 +631,10 @@ def _build_codex_shell_command(
554
631
  f"{path_expr}"
555
632
  f"cd {shlex.quote(workdir_clean)} && {codex_argv}; rc=$?; "
556
633
  f'if [ "$rc" = 0 ]; then echo DONE >> {shlex.quote(log_path)}; '
557
- f"else printf 'ERROR: codex exited %s\\n' \"$rc\" >> {shlex.quote(log_path)}; fi"
634
+ f"else "
635
+ f"printf 'ERROR: codex exited %s\\n' \"$rc\" >> {shlex.quote(log_path)}; "
636
+ f"{pane_surfacing.error_tail_snippet(log_path, pane_log, 'codex')}"
637
+ f"fi"
558
638
  )
559
639
  shell_command = "env " + " ".join(
560
640
  shlex.quote(x) for x in [*env_assignments, "bash", "-c", bash_payload]
@@ -707,12 +787,14 @@ def build_tmux_session_argv(
707
787
  session_name: str,
708
788
  extra_env: dict[str, str] | None,
709
789
  codex_flags: list[str],
790
+ claustrum_wrap: list[str] | None = None,
710
791
  ) -> list[str]:
711
792
  _, shell_command = _build_codex_shell_command(
712
793
  codex_path=codex_path,
713
794
  workdir=workdir,
714
795
  extra_env=extra_env,
715
796
  codex_flags=codex_flags,
797
+ claustrum_wrap=claustrum_wrap,
716
798
  )
717
799
  return [
718
800
  tmux_path, "-S", socket_path, "new-session", "-d",
@@ -781,6 +863,7 @@ async def launch_ttyd_with_codex(
781
863
  bind_iface: str,
782
864
  extra_env: dict[str, str] | None,
783
865
  codex_flags: list[str],
866
+ claustrum_wrap: list[str] | None = None,
784
867
  ready_timeout_s: float = 30.0,
785
868
  env_remove: list[str] | None = None,
786
869
  session_name: str = "optio",
@@ -797,12 +880,25 @@ async def launch_ttyd_with_codex(
797
880
  session_name=session_name,
798
881
  extra_env=extra_env,
799
882
  codex_flags=codex_flags,
883
+ claustrum_wrap=claustrum_wrap,
800
884
  )
801
885
  session_cmd = " ".join(shlex.quote(a) for a in session_argv)
802
886
  await _launch_detached_checked(
803
887
  host, session_cmd, env_remove=env_remove, what="tmux new-session",
804
888
  )
805
889
 
890
+ # ALWAYS-ON pane mirror: duplicate the live pane to codex-pane.log (outside
891
+ # the workdir, so it survives the workdir teardown on failure). pipe-pane
892
+ # copies the PTY output stream — it does NOT insert a pipe in codex's fd
893
+ # chain, so the TUI is unaffected (a stdout/stderr redirect would make
894
+ # isatty() false and the TUI would never render). The launch payload tails
895
+ # this on abnormal exit (see _build_codex_shell_command).
896
+ pane_log = pane_surfacing.pane_log_path(host.workdir, "codex")
897
+ await host.run_command(pane_surfacing.mkdir_pane_dir_cmd(pane_log))
898
+ await host.run_command(
899
+ pane_surfacing.pipe_pane_cmd(tmux_path, socket_path, session_name, pane_log)
900
+ )
901
+
806
902
  ttyd_argv = build_ttyd_attach_argv(
807
903
  ttyd_path=ttyd_path,
808
904
  tmux_path=tmux_path,
@@ -0,0 +1,7 @@
1
+ from optio_agents import AgentInfo
2
+
3
+ AGENT_INFO = AgentInfo(
4
+ slug="codex",
5
+ name="Codex",
6
+ url="https://openai.com/codex",
7
+ )
@@ -24,6 +24,7 @@ from optio_host.paths import task_dir
24
24
 
25
25
  from optio_codex import cred_watcher, host_actions
26
26
  from optio_codex import models as codex_models
27
+ from optio_codex.info import AGENT_INFO
27
28
  from optio_codex.conversation import CodexConversation
28
29
  from optio_codex.conversation_listener import ConversationListener
29
30
  from optio_codex.fs_allowlist import (
@@ -54,6 +55,41 @@ def _build_host(config: CodexTaskConfig, process_id: str) -> Host:
54
55
  return host_actions.build_host(config.ssh, taskdir)
55
56
 
56
57
 
58
+ async def _build_claustrum_wrap(
59
+ host: Host, config: CodexTaskConfig, claustrum_path: str | None,
60
+ ) -> list[str] | None:
61
+ """claustrum argv prefix for an fs-isolated launch, or None when
62
+ fs_isolation is off. Shared by the iframe and conversation launch paths so
63
+ both confine codex + its whole subprocess tree identically.
64
+
65
+ ``workdir`` (incl. the isolated home) is granted rwx; the codex binary cache
66
+ (where the ``<workdir>/home/.local/bin/codex`` symlink targets, outside the
67
+ workdir) is granted read+exec so codex can exec itself. ``~/`` caller extras
68
+ expand against the REAL host home (codex runs under an isolated $HOME, and
69
+ grants reach claustrum verbatim — no shell between). Consumes the shared
70
+ ``optio_agents.fs_grants`` + ``optio_agents.claustrum`` helpers.
71
+ """
72
+ if not config.fs_isolation:
73
+ return None
74
+ from optio_agents import fs_grants
75
+ cache_dir = await host_actions._resolve_codex_cache_dir(host, config.install_dir)
76
+ host_home = (
77
+ await host.resolve_host_home() if config.extra_allowed_dirs else None
78
+ )
79
+ grants = fs_grants.build_grant_flags(
80
+ workdir=host.workdir,
81
+ engine_cache_dir=cache_dir,
82
+ extra_allowed_dirs=config.extra_allowed_dirs,
83
+ host_home=host_home,
84
+ # codex writes temp files under /tmp (its _isolation_env sets HOME/
85
+ # CODEX_HOME/XDG_* but NOT TMPDIR, so tempfile falls back to /tmp), so
86
+ # claustrum must grant /tmp (+ /var/tmp) or those writes are denied.
87
+ # codex-specific: the other engines confine temp to the workdir.
88
+ extra_baseline=[("--rw", "/tmp"), ("--rw", "/var/tmp")],
89
+ )
90
+ return host_actions.claustrum.build_claustrum_wrap(claustrum_path, grants)
91
+
92
+
57
93
  async def _call_maybe_async(fn, *args) -> None:
58
94
  """Invoke a callback that may be sync or async."""
59
95
  result = fn(*args)
@@ -98,6 +134,13 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
98
134
  # build_sandbox_cli_args; the codex app-server launch via thread/start's
99
135
  # `sandbox` mode + build_sandbox_config_overrides in the conversation body).
100
136
  sandbox_settings: SandboxSettings | None = None
137
+ # Stage 9: claustrum (Landlock, fail-closed) is the fs-isolation guarantee.
138
+ # Provisioned once in _prepare when fs_isolation is on; the resolved binary
139
+ # path feeds every launch surface's claustrum wrap. ``claustrum_newer`` is
140
+ # the newest upstream tag when it exceeds the pinned one (drives the update
141
+ # notice), else None.
142
+ claustrum_path: str | None = None
143
+ claustrum_newer: str | None = None
101
144
  cancelled = False
102
145
  # Whether a snapshot was restored this run (suppresses the auto-start
103
146
  # positional). Set by _prepare, read by the body.
@@ -152,7 +195,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
152
195
  """
153
196
  nonlocal codex_path, ttyd_path, resuming, resume_session_id
154
197
  nonlocal resolved_seed_id, lease_holder, cred_baseline
155
- nonlocal sandbox_settings
198
+ nonlocal sandbox_settings, claustrum_path, claustrum_newer
156
199
 
157
200
  resume_requested = bool(getattr(ctx, "resume", False))
158
201
  snapshot = None
@@ -193,6 +236,25 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
193
236
  # renders from this single object.
194
237
  host_home = await host.resolve_host_home()
195
238
  sandbox_settings = resolve_sandbox_settings(config, host_home=host_home)
239
+ # Stage 9: provision claustrum (Landlock, fail-closed) — the trusted fs
240
+ # guarantee. Any failure RAISES here, so the launch never proceeds
241
+ # unconfined. claustrum_newer drives the update notice below.
242
+ if config.fs_isolation:
243
+ claustrum_path = await host_actions.ensure_claustrum_installed(
244
+ hook_ctx, install_dir=config.install_dir,
245
+ )
246
+ claustrum_newer = await host_actions.claustrum_newer_tag()
247
+ # SECURITY: under claustrum codex runs danger-full-access (its native
248
+ # bubblewrap sandbox can't nest inside Landlock), so codex's NETWORK
249
+ # is NOT confined — the network_access knob is a no-op here. Make the
250
+ # gap loud. The pending shared pasta/netns layer will close it.
251
+ _LOG.warning(
252
+ "codex fs is claustrum-confined, but its NETWORK is UNCONFINED "
253
+ "(bubblewrap can't nest in claustrum; network_access=%s is a "
254
+ "no-op under fs_isolation). Network isolation pending the shared "
255
+ "pasta/netns layer.",
256
+ config.network_access,
257
+ )
196
258
  # Conversation mode is headless (codex app-server stdio) — no ttyd.
197
259
  if config.mode != "conversation":
198
260
  ttyd_path = await host_actions.ensure_ttyd_installed(
@@ -252,6 +314,18 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
252
314
  await host_actions._append_resume_log_entry(
253
315
  host, refreshed=refreshed_files,
254
316
  )
317
+ # Stage 9: if a newer claustrum release exists, route the security
318
+ # notice through on_deliverable (a new release may patch a
319
+ # vulnerability). No-op without a callback or a newer tag. Runs for both
320
+ # iframe and conversation modes.
321
+ if config.fs_isolation and claustrum_newer:
322
+ await host_actions.claustrum.emit_claustrum_update_notice(
323
+ host, hook_ctx,
324
+ delivery_type=config.delivery_type,
325
+ on_deliverable=config.on_deliverable,
326
+ newer=claustrum_newer,
327
+ pinned=host_actions.claustrum.CLAUSTRUM_PINNED_TAG,
328
+ )
255
329
  if config.before_execute is not None:
256
330
  # End-of-prepare placement matches claudecode (its
257
331
  # _plant_session_content ends with before_execute, inside its
@@ -290,7 +364,10 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
290
364
  **(config.env or {}),
291
365
  **(hook_ctx.browser_launch_env or {}),
292
366
  }
293
- ctx.report_progress(None, "Launching Codex…")
367
+ # Stage 9: Landlock-confine codex + its whole tmux/bash subprocess tree
368
+ # (None when fs_isolation is off).
369
+ claustrum_wrap = await _build_claustrum_wrap(host, config, claustrum_path)
370
+ ctx.report_progress(None, f"Launching {AGENT_INFO.name}…")
294
371
  handle, tmux_path_local, ttyd_port, tmux_socket, tmux_session = await host_actions.launch_ttyd_with_codex(
295
372
  host,
296
373
  ttyd_path=ttyd_path,
@@ -298,6 +375,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
298
375
  bind_iface=ttyd_iface,
299
376
  extra_env=launch_env,
300
377
  codex_flags=codex_flags,
378
+ claustrum_wrap=claustrum_wrap,
301
379
  ready_timeout_s=READY_TIMEOUT_S,
302
380
  env_remove=config.scrub_env,
303
381
  )
@@ -309,7 +387,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
309
387
  await ctx.set_widget_data({
310
388
  "iframeSrc": "{widgetProxyUrl}/",
311
389
  })
312
- ctx.report_progress(None, "Codex is live")
390
+ ctx.report_progress(None, f"{AGENT_INFO.name} is live")
313
391
 
314
392
  # iframe-input widget: start the engine-side input listener and publish it
315
393
  # as the control upstream. The operator types messages / drives TUI menus
@@ -373,7 +451,11 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
373
451
  # thread id recorded in the restored snapshot.
374
452
  resume_thread_id=resume_session_id if resuming else None,
375
453
  )
454
+ # Stage 9: Landlock-confine the app-server process tree (None when
455
+ # fs_isolation is off).
456
+ claustrum_wrap = await _build_claustrum_wrap(host, config, claustrum_path)
376
457
  argv = [
458
+ *(claustrum_wrap or []),
377
459
  codex_path, "app-server",
378
460
  *build_sandbox_config_overrides(sandbox_settings),
379
461
  ]
@@ -383,7 +465,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
383
465
  **(config.env or {}),
384
466
  **(hook_ctx.browser_launch_env or {}),
385
467
  }
386
- ctx.report_progress(None, "Launching Codex (conversation)…")
468
+ ctx.report_progress(None, f"Launching {AGENT_INFO.name} (conversation)…")
387
469
  handle = await host.launch_subprocess(
388
470
  cmd, env=env, cwd=host.workdir,
389
471
  env_remove=config.scrub_env, stdin=True, merge_stderr=False,
@@ -398,7 +480,7 @@ async def run_codex_session(ctx: ProcessContext, config: CodexTaskConfig) -> Non
398
480
  raise
399
481
 
400
482
  ctx.publish_result(conversation)
401
- ctx.report_progress(None, "Codex conversation is live")
483
+ ctx.report_progress(None, f"{AGENT_INFO.name} conversation is live")
402
484
 
403
485
  # Opt-in dashboard chat widget: per-task SSE listener over the
404
486
  # published conversation, reached via the widget proxy (which injects
@@ -32,6 +32,7 @@ from optio_agents import (
32
32
  ThinkingVerbosity,
33
33
  ToolVerbosity,
34
34
  )
35
+ from optio_agents.config_types import ClaustrumConfigMixin
35
36
  from optio_agents.protocol.session import (
36
37
  CallerMessageCallback,
37
38
  DeliverableCallback,
@@ -90,13 +91,16 @@ def _identity_resume_refresh(config: "CodexTaskConfig") -> "CodexTaskConfig":
90
91
  return config
91
92
 
92
93
 
93
- @dataclass
94
- class CodexTaskConfig:
95
- """Configuration for one optio-codex task instance (Stage 0).
94
+ @dataclass(frozen=True, kw_only=True)
95
+ class CodexTaskConfig(ClaustrumConfigMixin):
96
+ """Configuration for one optio-codex task instance.
96
97
 
97
- Stages 0-2 cover iframe/ttyd mode on local and SSH-remote hosts with
98
- resume/snapshots. Seeds, conversation mode, and filesystem isolation
99
- arrive in later stages.
98
+ Inherits the claustrum filesystem-isolation triad (``fs_isolation`` /
99
+ ``extra_allowed_dirs`` / ``delivery_type``) from ``ClaustrumConfigMixin``;
100
+ those fields stay top-level here (callers write ``fs_isolation=`` /
101
+ ``delivery_type=`` verbatim). Frozen because the mixin is frozen;
102
+ ``kw_only`` because the mixin contributes defaulted fields ahead of the
103
+ required ``consumer_instructions``.
100
104
  """
101
105
 
102
106
  consumer_instructions: str
@@ -124,27 +128,33 @@ class CodexTaskConfig:
124
128
  # watching). In conversation mode the thread's approvalPolicy is derived
125
129
  # from permission_gate (never / on-request), NOT from this field.
126
130
  ask_for_approval: ApprovalPolicy = "never"
127
- # codex-native sandbox mode. None (default) derives from fs_isolation:
128
- # workspace-write when isolation is on, danger-full-access when off.
129
- # Explicit values are cross-validated against fs_isolation below.
131
+ # codex-native sandbox mode. Claustrum (see session.py), NOT this native
132
+ # mode, owns filesystem isolation. codex's native sandbox is BUBBLEWRAP,
133
+ # which cannot nest inside claustrum, so with fs_isolation=True (default) the
134
+ # native mode MUST be danger-full-access (no bwrap) — None (default) resolves
135
+ # to danger-full-access, and an explicit workspace-write/read-only +
136
+ # fs_isolation is rejected. Set fs_isolation=False to run codex's native
137
+ # sandbox standalone (then None → workspace-write is NOT auto-picked; pass an
138
+ # explicit sandbox=). Decoupled from ``fs_isolation``.
130
139
  sandbox: SandboxMode | None = None
131
- # Grant network to sandboxed tool commands (codex workspace-write default
132
- # is network OFF — [sandbox_workspace_write] network_access). False
133
- # mirrors codex; note this is STRICTER than grok/claudecode, whose fs
134
- # sandboxes do not restrict the network at all.
140
+ # Grant network to sandboxed tool commands. This is a native-bubblewrap
141
+ # ([sandbox_workspace_write]) knob and therefore applies ONLY when codex runs
142
+ # its native workspace-write sandbox standalone (fs_isolation=False,
143
+ # sandbox='workspace-write'). Under claustrum (fs_isolation=True) codex has NO
144
+ # network confinement — bwrap can't nest — so this field is a NO-OP there and
145
+ # session.py warns at launch. The pending shared pasta/netns layer will
146
+ # restore network isolation universally.
135
147
  network_access: bool = False
136
148
 
137
- # --- filesystem isolation (Stage 8) ---------------------------------
138
- # Confine codex tool subprocesses to the task workdir + /tmp + explicit
139
- # rw grants, kernel-enforced via codex's NATIVE sandbox (bundled
140
- # bubblewrap primary, Landlock+seccomp fallback on Linux). Default-ON.
141
- fs_isolation: bool = True
142
- # Additional path grants beyond the workdir + temp dirs. ``~/`` expands
143
- # against the real host home at launch. "ro" grants are a documented
144
- # no-op on codex (reads are unrestricted in workspace-write). codex's
145
- # native sandbox has no execute bit, so a ``rox`` grant is treated as
146
- # ``ro`` and a ``rwx`` grant as ``rw``.
147
- extra_allowed_dirs: list[AllowedDir] | None = None
149
+ # --- filesystem isolation ------------------------------------------------
150
+ # fs_isolation / extra_allowed_dirs / delivery_type are INHERITED from
151
+ # ClaustrumConfigMixin. Claustrum (Landlock, fail-closed) confines codex
152
+ # and its whole tool-subprocess tree to the task workdir + explicit grants;
153
+ # ``extra_allowed_dirs`` rw grants ALSO feed codex's native ``writable_roots``
154
+ # so the redundant native layer never blocks a write claustrum permits.
155
+ # ``~/`` grants expand against the real host home at launch. delivery_type
156
+ # is MANDATORY when fs_isolation is on (routes the "newer claustrum
157
+ # available" security notice via on_deliverable).
148
158
 
149
159
  ssh: SSHConfig | None = None
150
160
 
@@ -263,11 +273,23 @@ class CodexTaskConfig:
263
273
 
264
274
  @property
265
275
  def effective_sandbox_mode(self) -> SandboxMode:
276
+ # Claustrum (Landlock, see session.py) is the sole fs-isolation layer.
277
+ # codex's NATIVE sandbox (bubblewrap) CANNOT nest inside claustrum — its
278
+ # user+mount-namespace setup fails ("setting up uid map / make / slave:
279
+ # Permission denied") because Landlock denies the /proc write and the
280
+ # mount-propagation op. So under claustrum the native sandbox is DISABLED
281
+ # (danger-full-access = no bwrap) and claustrum alone confines the fs.
282
+ # Consequence: network confinement (a bubblewrap-only feature) is
283
+ # unavailable here — session.py warns at launch; the pending shared
284
+ # pasta/netns layer will restore it. Explicit ``sandbox=`` is honored
285
+ # (power-user escape hatch), but workspace-write/read-only + fs_isolation
286
+ # is rejected in __post_init__ (it would try to nest bwrap and fail).
266
287
  if self.sandbox is not None:
267
288
  return self.sandbox
268
- return "workspace-write" if self.fs_isolation else "danger-full-access"
289
+ return "danger-full-access"
269
290
 
270
291
  def __post_init__(self) -> None:
292
+ self._validate_claustrum()
271
293
  if self.mode not in _VALID_MODES:
272
294
  raise ValueError(
273
295
  f"CodexTaskConfig.mode={self.mode!r} is not one of "
@@ -340,20 +362,22 @@ class CodexTaskConfig:
340
362
  f"CodexTaskConfig.reasoning_effort={self.reasoning_effort!r} "
341
363
  f"is not one of {sorted(_VALID_REASONING_EFFORTS)}"
342
364
  )
343
- if self.fs_isolation and self.effective_sandbox_mode == "danger-full-access":
365
+ # fs_isolation drives CLAUSTRUM (see session.py). codex's native
366
+ # bubblewrap sandbox cannot nest inside claustrum's Landlock domain, so
367
+ # an explicit restrictive native mode + fs_isolation is a hard error:
368
+ # the launch would try to start bwrap under claustrum and fail-closed.
369
+ if self.fs_isolation and self.sandbox in ("workspace-write", "read-only"):
344
370
  raise ValueError(
345
- "CodexTaskConfig: fs_isolation=True is incompatible with "
346
- "sandbox='danger-full-access' — fs_isolation exists to "
347
- "guarantee a kernel-enforced sandbox. Set fs_isolation=False "
348
- "to run unconfined."
349
- )
350
- if not self.fs_isolation and self.sandbox in ("read-only", "workspace-write"):
351
- raise ValueError(
352
- "CodexTaskConfig: fs_isolation=False launches codex "
353
- "unconfined (danger-full-access); an explicit restrictive "
354
- f"sandbox={self.sandbox!r} contradicts it. Drop one of the "
355
- "two settings."
371
+ f"CodexTaskConfig: sandbox={self.sandbox!r} (codex's native "
372
+ "bubblewrap sandbox) cannot run inside claustrum — bwrap's "
373
+ "user/mount-namespace setup fails under Landlock. With "
374
+ "fs_isolation=True, claustrum is the fs sandbox and the native "
375
+ "mode must be danger-full-access (the default). Set "
376
+ "fs_isolation=False to use codex's native sandbox standalone."
356
377
  )
378
+ # Native-mode-internal couplings (apply only with fs_isolation=False +
379
+ # an explicit native mode): rw grants + network both need workspace-write,
380
+ # since read-only carries neither.
357
381
  rw_extras = [
358
382
  d for d in (self.extra_allowed_dirs or []) if d.mode in ("rw", "rwx")
359
383
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-codex
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Run OpenAI Codex as an optio task; local subprocess; ttyd-served TUI iframe.
5
5
  Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
6
  License-Expression: Apache-2.0
@@ -22,7 +22,7 @@ Requires-Python: >=3.11
22
22
  Description-Content-Type: text/markdown
23
23
  Requires-Dist: optio-core<0.4,>=0.3
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.5,>=0.4
25
+ Requires-Dist: optio-agents<0.6,>=0.5
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Requires-Dist: aiohttp>=3.9
28
28
  Provides-Extra: dev
@@ -6,6 +6,7 @@ src/optio_codex/conversation_listener.py
6
6
  src/optio_codex/cred_watcher.py
7
7
  src/optio_codex/fs_allowlist.py
8
8
  src/optio_codex/host_actions.py
9
+ src/optio_codex/info.py
9
10
  src/optio_codex/models.py
10
11
  src/optio_codex/prompt.py
11
12
  src/optio_codex/seed_manifest.py
@@ -18,7 +19,9 @@ src/optio_codex.egg-info/SOURCES.txt
18
19
  src/optio_codex.egg-info/dependency_links.txt
19
20
  src/optio_codex.egg-info/requires.txt
20
21
  src/optio_codex.egg-info/top_level.txt
22
+ tests/test_agent_info.py
21
23
  tests/test_await_codex_gone.py
24
+ tests/test_claustrum.py
22
25
  tests/test_codex_cache.py
23
26
  tests/test_config.py
24
27
  tests/test_config_hooks.py
@@ -1,6 +1,6 @@
1
1
  optio-core<0.4,>=0.3
2
2
  optio-host<0.3,>=0.2
3
- optio-agents<0.5,>=0.4
3
+ optio-agents<0.6,>=0.5
4
4
  asyncssh>=2.14
5
5
  aiohttp>=3.9
6
6
 
@@ -0,0 +1,15 @@
1
+ from optio_codex import AGENT_INFO
2
+ from optio_codex.types import CodexTaskConfig
3
+
4
+
5
+ def test_agent_info_values():
6
+ assert AGENT_INFO.slug == "codex"
7
+ assert AGENT_INFO.name == "Codex"
8
+ assert AGENT_INFO.url == "https://openai.com/codex"
9
+
10
+
11
+ def test_agent_info_slug_matches_agent_type():
12
+ import dataclasses
13
+
14
+ fields = {f.name: f for f in dataclasses.fields(CodexTaskConfig)}
15
+ assert fields["agent_type"].default == AGENT_INFO.slug