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.
- {optio_codex-0.2.0 → optio_codex-0.3.0}/PKG-INFO +2 -2
- {optio_codex-0.2.0 → optio_codex-0.3.0}/pyproject.toml +2 -2
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/__init__.py +2 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/conversation.py +2 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/host_actions.py +98 -2
- optio_codex-0.3.0/src/optio_codex/info.py +7 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/session.py +87 -5
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/types.py +61 -37
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/PKG-INFO +2 -2
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/SOURCES.txt +3 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/requires.txt +1 -1
- optio_codex-0.3.0/tests/test_agent_info.py +15 -0
- optio_codex-0.3.0/tests/test_claustrum.py +133 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_config.py +82 -30
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_config_hooks.py +1 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation.py +36 -36
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation_controls.py +8 -8
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_conversation_listener.py +24 -6
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_cred_watcher.py +6 -2
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_file_download.py +1 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_file_upload.py +1 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_fs_allowlist.py +12 -5
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_host_actions.py +26 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_input_wiring.py +2 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_conversation.py +1 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_seed_resume.py +4 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_real_codex_session.py +1 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_conversation.py +32 -28
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_lease.py +1 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_local.py +7 -1
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_remote.py +5 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_resume.py +1 -0
- optio_codex-0.3.0/tests/test_session_sandbox.py +143 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_session_seed.py +1 -0
- optio_codex-0.2.0/tests/test_session_sandbox.py +0 -93
- {optio_codex-0.2.0 → optio_codex-0.3.0}/README.md +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/setup.cfg +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/conversation_listener.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/cred_watcher.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/fs_allowlist.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/models.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/prompt.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/seed_manifest.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/snapshots.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex/verify.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/dependency_links.txt +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/src/optio_codex.egg-info/top_level.txt +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_await_codex_gone.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_codex_cache.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_import.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_kill_ttyd_by_socket.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_models.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_prompt.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_sandbox_enforce.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_seed_manifest.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_snapshots.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_teardown_session_tree.py +0 -0
- {optio_codex-0.2.0 → optio_codex-0.3.0}/tests/test_verify.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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
|
-
|
|
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, "
|
|
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
|
|
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, "
|
|
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
|
|
94
|
+
@dataclass(frozen=True, kw_only=True)
|
|
95
|
+
class CodexTaskConfig(ClaustrumConfigMixin):
|
|
96
|
+
"""Configuration for one optio-codex task instance.
|
|
96
97
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
128
|
-
#
|
|
129
|
-
#
|
|
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
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
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
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
|
|
142
|
-
#
|
|
143
|
-
# against the real host home at launch.
|
|
144
|
-
#
|
|
145
|
-
#
|
|
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 "
|
|
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
|
-
|
|
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:
|
|
346
|
-
"sandbox
|
|
347
|
-
"
|
|
348
|
-
"
|
|
349
|
-
|
|
350
|
-
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|