optio-opencode 0.3.1__tar.gz → 0.4.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_opencode-0.3.1 → optio_opencode-0.4.0}/PKG-INFO +2 -2
  2. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/pyproject.toml +2 -2
  3. optio_opencode-0.4.0/src/optio_opencode/fs_allowlist.py +49 -0
  4. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/host_actions.py +101 -2
  5. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/session.py +58 -11
  6. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/types.py +25 -15
  7. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/PKG-INFO +2 -2
  8. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/SOURCES.txt +2 -0
  9. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/requires.txt +1 -1
  10. optio_opencode-0.4.0/tests/test_claustrum.py +149 -0
  11. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_config_harmonization.py +4 -26
  12. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_config.py +6 -1
  13. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_driver.py +95 -31
  14. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_session.py +11 -11
  15. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_ui_model.py +10 -10
  16. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_ui_session.py +40 -15
  17. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_conversation_upload.py +4 -4
  18. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_cred_watcher.py +17 -5
  19. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_file_download.py +5 -5
  20. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_file_upload.py +3 -3
  21. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_actions.py +61 -0
  22. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_local.py +1 -1
  23. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_sanity.py +4 -4
  24. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_seed_config.py +2 -2
  25. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_blob_hooks.py +4 -4
  26. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_hooks.py +6 -6
  27. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_local.py +14 -4
  28. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_remote.py +1 -1
  29. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_resume.py +6 -6
  30. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_seed.py +7 -7
  31. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_session_seed_saveback.py +3 -3
  32. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_types.py +20 -16
  33. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/README.md +0 -0
  34. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/setup.cfg +0 -0
  35. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/__init__.py +0 -0
  36. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/conversation.py +0 -0
  37. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/cred_watcher.py +0 -0
  38. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/info.py +0 -0
  39. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/model_probe.py +0 -0
  40. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/prompt.py +0 -0
  41. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/seed_manifest.py +0 -0
  42. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/snapshots.py +0 -0
  43. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode/verify.py +0 -0
  44. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  45. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
  46. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_agent_info.py +0 -0
  47. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_agent_sender_opencode.py +0 -0
  48. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_primitives_local.py +0 -0
  49. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_primitives_remote.py +0 -0
  50. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_remote_resume.py +0 -0
  51. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_host_resume.py +0 -0
  52. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_model_probe.py +0 -0
  53. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_model_probe_wiring.py +0 -0
  54. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_prompt.py +0 -0
  55. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_purge_seed.py +0 -0
  56. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_resume_sentence_opencode.py +0 -0
  57. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_smart_install.py +0 -0
  58. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_snapshots.py +0 -0
  59. {optio_opencode-0.3.1 → optio_opencode-0.4.0}/tests/test_verify_seed.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
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-opencode"
7
- version = "0.3.1"
7
+ version = "0.4.0"
8
8
  description = "Run opencode web as an optio task; local subprocess or remote via SSH."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -30,7 +30,7 @@ dependencies = [
30
30
  # 0.1.1 introduces the bind_addr kwarg on Host.establish_tunnel —
31
31
  # required for OPTIO_WIDGET_TUNNEL_BIND to work.
32
32
  "optio-host>=0.2,<0.3",
33
- "optio-agents>=0.4,<0.5",
33
+ "optio-agents>=0.5,<0.6",
34
34
  "asyncssh>=2.14",
35
35
  "aiohttp>=3.9",
36
36
  ]
@@ -0,0 +1,49 @@
1
+ """Build the claustrum filesystem-allowlist flags for an ``opencode web`` launch.
2
+
3
+ Thin delegator over the shared ``optio_agents.fs_grants`` builder (the system
4
+ baseline + workdir + engine-cache + caller-extras logic lives there, one SSOT
5
+ across every wrapper). opencode contributes ONE engine-specific grant on top:
6
+
7
+ * opencode's home/config/data/cache all live under ``<workdir>/home`` — already
8
+ covered by ``--rwx <workdir>``.
9
+ * BUT ``OPENCODE_DB`` = ``<taskdir>/opencode.db`` sits ONE LEVEL ABOVE the
10
+ workdir (the ``taskdir/workdir`` layout). Under-granting there breaks
11
+ opencode's live DB, so the whole taskdir is granted rwx via ``extra_baseline``.
12
+ * the opencode binary cache (``_resolve_install_dir``, outside every workdir)
13
+ is granted read+exec as the engine cache.
14
+
15
+ Non-existent paths are harmless: claustrum ignores missing paths.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from optio_agents import fs_grants
21
+
22
+ # Re-exported so tests / callers can build extra grants without reaching into
23
+ # optio_agents directly (mirrors the other wrappers' fs_allowlist surface).
24
+ from .types import AllowedDir # noqa: F401
25
+
26
+
27
+ def build_grant_flags(
28
+ *,
29
+ workdir: str,
30
+ taskdir: str,
31
+ opencode_cache_dir: str,
32
+ extra_allowed_dirs: "list[AllowedDir] | None",
33
+ host_home: str | None = None,
34
+ ) -> list[str]:
35
+ """Return the ordered claustrum grant flags for an opencode launch.
36
+
37
+ ``workdir`` (the per-task tree incl. the isolated ``home``) is granted rwx.
38
+ ``taskdir`` (the workdir's parent, holding the live ``opencode.db``) is
39
+ granted rwx as an engine ``extra_baseline`` so the server can write its DB.
40
+ ``opencode_cache_dir`` (where the real opencode binary lives, outside every
41
+ workdir) is granted read+exec. ``~``/``~/`` caller extras expand against
42
+ ``host_home`` (the REAL host home)."""
43
+ return fs_grants.build_grant_flags(
44
+ workdir=workdir,
45
+ engine_cache_dir=opencode_cache_dir,
46
+ extra_allowed_dirs=extra_allowed_dirs,
47
+ host_home=host_home,
48
+ extra_baseline=[("--rwx", taskdir.rstrip("/"))],
49
+ )
@@ -28,6 +28,8 @@ from typing import TYPE_CHECKING, Callable
28
28
 
29
29
  from optio_host.host import ProcessHandle
30
30
 
31
+ from optio_agents import claustrum
32
+
31
33
  from optio_opencode.info import AGENT_INFO
32
34
 
33
35
  if TYPE_CHECKING:
@@ -71,6 +73,72 @@ async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
71
73
  return path.rstrip("/")
72
74
 
73
75
 
76
+ # --- claustrum provisioning -------------------------------------------------
77
+ #
78
+ # opencode is wrapped in claustrum (Landlock, fail-closed) so the whole
79
+ # ``opencode web`` server tree — the server and every tool subprocess it spawns
80
+ # — is confined to an explicit filesystem allowlist. The provisioning logic
81
+ # (engine cross-compile, ELF-guarded build cache, functional wrap+exec
82
+ # validation, fail-closed placement) is the shared ``optio_agents.claustrum``
83
+ # module; this wrapper contributes only the opencode-owned cache-dir resolution
84
+ # and, in session.py, the grant set (``fs_allowlist.build_grant_flags``).
85
+
86
+
87
+ async def ensure_claustrum_installed(
88
+ hook_ctx,
89
+ *,
90
+ install_dir: str | None = None,
91
+ ) -> str:
92
+ """Ensure a functioning claustrum binary is on the host; return its path.
93
+
94
+ Thin wrapper over :func:`optio_agents.claustrum.ensure_claustrum_installed`:
95
+ resolves the opencode-owned target cache dir (on the worker, beside the
96
+ opencode binary cache), pins the engine build cache to
97
+ ``~/.cache/optio-opencode``, and forwards the UI progress callback. All the
98
+ real work (cross-compile, ELF-guarded engine cache, functional wrap+exec
99
+ validation) lives in the shared module. Fail-closed — any failure RAISES, so
100
+ the caller never proceeds to an unconfined launch.
101
+ """
102
+ host = hook_ctx._host
103
+ cache_dir = await _resolve_install_dir(host, install_dir)
104
+ return await claustrum.ensure_claustrum_installed(
105
+ host,
106
+ cache_dir=cache_dir,
107
+ engine_cache_dir=os.path.expanduser("~/.cache/optio-opencode"),
108
+ report_progress=hook_ctx.report_progress,
109
+ )
110
+
111
+
112
+ async def claustrum_newer_tag() -> str | None:
113
+ """Return the newest claustrum tag if it is newer than the pinned one, else None.
114
+
115
+ Engine-side egress only. Best-effort: network failure returns None (no notice).
116
+ """
117
+ try:
118
+ p = await asyncio.create_subprocess_exec(
119
+ "git", "ls-remote", "--tags", "--refs", claustrum.CLAUSTRUM_REPO,
120
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
121
+ )
122
+ out, _ = await p.communicate()
123
+ if p.returncode != 0:
124
+ return None
125
+ except Exception: # noqa: BLE001
126
+ return None
127
+ tags = []
128
+ for line in out.decode().splitlines():
129
+ ref = line.rsplit("/", 1)[-1].strip()
130
+ if ref.startswith("v"):
131
+ tags.append(ref)
132
+
133
+ def key(t: str) -> tuple:
134
+ return tuple(int(x) for x in t.lstrip("v").split(".") if x.isdigit())
135
+
136
+ if not tags:
137
+ return None
138
+ newest = max(tags, key=key)
139
+ return newest if key(newest) > key(claustrum.CLAUSTRUM_PINNED_TAG) else None
140
+
141
+
74
142
  def _isolation_env(host: "Host") -> dict[str, str]:
75
143
  """Per-task HOME/XDG isolation env, derived from ``host.workdir``.
76
144
 
@@ -437,6 +505,7 @@ async def launch_opencode(
437
505
  hostname: str = "127.0.0.1",
438
506
  extra_env: dict[str, str] | None = None,
439
507
  env_remove: list[str] | None = None,
508
+ claustrum_wrap: list[str] | None = None,
440
509
  ) -> tuple[ProcessHandle, int]:
441
510
  """Launch ``opencode web`` on ``host``; wait for the listening URL.
442
511
 
@@ -475,10 +544,18 @@ async def launch_opencode(
475
544
  # opencode succeeds at opening a real browser window. opencode_executable
476
545
  # is an absolute path (resolved by ensure_opencode_installed), so
477
546
  # login-shell PATH lookup is not needed to find the binary.
547
+ #
548
+ # claustrum_wrap (fs isolation) is spliced BETWEEN the ``env …`` password
549
+ # assignment and the opencode executable: ``exec env VAR=… claustrum … --
550
+ # opencode web``. The ``$(cat …)`` runs in the outer shell (before exec), so
551
+ # the password read happens outside the Landlock confinement; ``env`` then
552
+ # execs claustrum, which Landlock-confines itself and execs opencode, so the
553
+ # server and every tool subprocess inherit the allowlist. None → no wrap.
554
+ wrap_prefix = f"{shlex.join(claustrum_wrap)} " if claustrum_wrap else ""
478
555
  cmd = (
479
556
  f"exec env "
480
557
  f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
481
- f"{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
558
+ f"{wrap_prefix}{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
482
559
  )
483
560
 
484
561
  # OPENCODE_DB must point at the same per-task db file used by the
@@ -507,19 +584,40 @@ async def launch_opencode(
507
584
  cmd, env=env, cwd=host.workdir, env_remove=env_remove,
508
585
  )
509
586
 
587
+ # Retain the tail of the launch stream so a failed start surfaces its REASON.
588
+ # `opencode web` is a plain server (not a TUI), so its startup diagnostics —
589
+ # a bind error, an auth/config crash, a claustrum "operation not permitted"
590
+ # denial — arrive as ordinary lines on the merged stdout+stderr the loop
591
+ # below already consumes. Without retaining them, they are scanned by
592
+ # _READY_RE, discarded, and the operator sees only "exited before printing a
593
+ # URL". A bounded deque keeps memory flat on a chatty-but-successful start.
594
+ # No ANSI strip (unlike the tmux engines' pipe-pane mirror): server logs are
595
+ # plain text, not PTY-painted TUI frames.
596
+ from collections import deque
597
+
598
+ tail: "deque[str]" = deque(maxlen=40)
599
+
600
+ def _reason_suffix() -> str:
601
+ recent = "\n".join(tail).strip()
602
+ return f"; last output:\n{recent}" if recent else ""
603
+
510
604
  async def _read_url() -> int:
511
605
  async for raw in handle.stdout:
512
606
  if isinstance(raw, bytes):
513
607
  line = raw.decode("utf-8", errors="replace").rstrip()
514
608
  else:
515
609
  line = str(raw).rstrip()
610
+ if line:
611
+ tail.append(line)
516
612
  m = _READY_RE.search(line)
517
613
  if m:
518
614
  m2 = re.search(r":(\d+)", m.group(1))
519
615
  if not m2:
520
616
  raise RuntimeError(f"could not find port in URL: {line}")
521
617
  return int(m2.group(1))
522
- raise RuntimeError("opencode exited before printing a URL")
618
+ raise RuntimeError(
619
+ f"opencode exited before printing a URL{_reason_suffix()}"
620
+ )
523
621
 
524
622
  try:
525
623
  port = await asyncio.wait_for(_read_url(), timeout=ready_timeout_s)
@@ -527,6 +625,7 @@ async def launch_opencode(
527
625
  await host.terminate_subprocess(handle, aggressive=True)
528
626
  raise TimeoutError(
529
627
  f"opencode did not print a listening URL within {ready_timeout_s}s"
628
+ f"{_reason_suffix()}"
530
629
  )
531
630
  except BaseException:
532
631
  await host.terminate_subprocess(handle, aggressive=True)
@@ -106,16 +106,33 @@ def _fold_tool_permissions(
106
106
  return cfg
107
107
 
108
108
 
109
- def _warn_if_fs_isolation_unenforced(config: "OpencodeTaskConfig") -> None:
110
- """Emit a runtime warning on the worker console when ``fs_isolation`` is
111
- requested. opencode has no claustrum sandbox wired yet, so the flag is
112
- INERT (see OpencodeTaskConfig.fs_isolation): honour the operator's signal
113
- by making the no-op loud rather than silent."""
114
- if config.fs_isolation:
115
- _LOG.warning(
116
- "opencode fs_isolation requested but not yet enforced "
117
- "(claustrum pending); the agent is NOT filesystem-confined."
118
- )
109
+ async def _build_claustrum_wrap(
110
+ host: Host, config: OpencodeTaskConfig, claustrum_path: str | None,
111
+ ) -> list[str] | None:
112
+ """claustrum argv prefix that Landlock-confines the ``opencode web`` server
113
+ tree, or None when fs_isolation is off.
114
+
115
+ Grants: the workdir (rwx; opencode's isolated HOME/XDG live under it), the
116
+ taskdir (rwx; the live ``opencode.db`` is a sibling of the workdir), the
117
+ opencode binary cache (rox), the system baseline, and caller extras. ``~/``
118
+ extras expand against the REAL host home (opencode runs under an isolated
119
+ $HOME; grants reach claustrum verbatim)."""
120
+ if not config.fs_isolation:
121
+ return None
122
+ from optio_opencode import fs_allowlist
123
+
124
+ cache_dir = await host_actions._resolve_install_dir(host, config.install_dir)
125
+ host_home = (
126
+ await host.resolve_host_home() if config.extra_allowed_dirs else None
127
+ )
128
+ grants = fs_allowlist.build_grant_flags(
129
+ workdir=host.workdir,
130
+ taskdir=host.taskdir,
131
+ opencode_cache_dir=cache_dir,
132
+ extra_allowed_dirs=config.extra_allowed_dirs,
133
+ host_home=host_home,
134
+ )
135
+ return host_actions.claustrum.build_claustrum_wrap(claustrum_path, grants)
119
136
 
120
137
 
121
138
  def conversation_widget_data(config: "OpencodeTaskConfig", *, session_id: str, directory: str) -> dict:
@@ -182,6 +199,12 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
182
199
  cred_watch_task: "asyncio.Task | None" = None
183
200
  resolved_seed_id: str | None = None
184
201
  lease_holder: str | None = None
202
+ # Path to the claustrum Landlock binary on the (possibly remote) host, set by
203
+ # _prepare when fs_isolation is on; read by the body to wrap the opencode
204
+ # launch. ``claustrum_newer`` carries a newer-than-pinned tag (if any) so the
205
+ # body can route the update notice. Both stay None when fs_isolation is off.
206
+ claustrum_path: str | None = None
207
+ claustrum_newer: str | None = None
185
208
 
186
209
  await host.connect()
187
210
 
@@ -196,6 +219,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
196
219
  before the tail can re-emit its stale DELIVERABLE/DONE/ERROR lines.
197
220
  """
198
221
  nonlocal opencode_exec, resuming, preserved_session_id
222
+ nonlocal claustrum_path, claustrum_newer
199
223
  opencode_exec = await host_actions.ensure_opencode_installed(
200
224
  hook_ctx._host,
201
225
  download=hook_ctx.download_file,
@@ -204,6 +228,16 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
204
228
  install_dir=config.install_dir,
205
229
  )
206
230
 
231
+ # Filesystem isolation: provision the claustrum Landlock binary BEFORE
232
+ # launch. Any failure raises here — fail-closed: the task never proceeds
233
+ # to an unconfined launch. Skipped when fs_isolation is off. Placed onto
234
+ # the same optio-opencode cache tree as the opencode binary.
235
+ if config.fs_isolation:
236
+ claustrum_path = await host_actions.ensure_claustrum_installed(
237
+ hook_ctx, install_dir=config.install_dir,
238
+ )
239
+ claustrum_newer = await host_actions.claustrum_newer_tag()
240
+
207
241
  resume_requested = bool(getattr(ctx, "resume", False))
208
242
  snapshot: dict | None = None
209
243
  if resume_requested:
@@ -392,7 +426,19 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
392
426
  # loopback; the SSH tunnel on the engine side handles exposure.
393
427
  opencode_hostname = bind_addr if isinstance(host, LocalHost) else "127.0.0.1"
394
428
 
395
- _warn_if_fs_isolation_unenforced(config)
429
+ # Filesystem isolation: route the "newer claustrum available" security
430
+ # notice (if any) through on_deliverable BEFORE launch, then build the
431
+ # Landlock wrap that confines the opencode server tree.
432
+ if config.fs_isolation and claustrum_newer:
433
+ await host_actions.claustrum.emit_claustrum_update_notice(
434
+ host, hook_ctx,
435
+ delivery_type=config.delivery_type,
436
+ on_deliverable=config.on_deliverable,
437
+ newer=claustrum_newer,
438
+ pinned=host_actions.claustrum.CLAUSTRUM_PINNED_TAG,
439
+ )
440
+ claustrum_wrap = await _build_claustrum_wrap(host, config, claustrum_path)
441
+
396
442
  ctx.report_progress(None, f"Launching {AGENT_INFO.name}{version_suffix}…")
397
443
  handle, opencode_port = await host_actions.launch_opencode(
398
444
  host, password,
@@ -401,6 +447,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
401
447
  hostname=opencode_hostname,
402
448
  extra_env={**(config.env or {}), **hook_ctx.browser_launch_env},
403
449
  env_remove=config.scrub_env,
450
+ claustrum_wrap=claustrum_wrap,
404
451
  )
405
452
  launched_handle = handle
406
453
 
@@ -17,6 +17,7 @@ from optio_agents import (
17
17
  ThinkingVerbosity,
18
18
  ToolVerbosity,
19
19
  )
20
+ from optio_agents.config_types import ClaustrumConfigMixin
20
21
  from optio_agents.protocol.session import (
21
22
  CallerMessageCallback,
22
23
  DeliverableCallback,
@@ -70,9 +71,16 @@ def _identity_resume_refresh(config: "OpencodeTaskConfig") -> "OpencodeTaskConfi
70
71
  return config
71
72
 
72
73
 
73
- @dataclass
74
- class OpencodeTaskConfig:
75
- """Configuration for one optio-opencode task instance."""
74
+ @dataclass(frozen=True, kw_only=True)
75
+ class OpencodeTaskConfig(ClaustrumConfigMixin):
76
+ """Configuration for one optio-opencode task instance.
77
+
78
+ Inherits the claustrum filesystem-isolation triad (``fs_isolation`` /
79
+ ``extra_allowed_dirs`` / ``delivery_type``) from ``ClaustrumConfigMixin``;
80
+ those fields stay top-level here (callers write ``fs_isolation=`` /
81
+ ``delivery_type=`` verbatim). Frozen because the mixin is frozen; ``kw_only``
82
+ so the required ``consumer_instructions`` can follow the mixin's defaulted
83
+ triad without a field-ordering conflict (all callers pass by keyword)."""
76
84
  consumer_instructions: str
77
85
  agent_type: Literal["opencode"] = "opencode"
78
86
  opencode_config: dict[str, Any] = field(default_factory=dict)
@@ -194,18 +202,17 @@ class OpencodeTaskConfig:
194
202
  file_download: bool = False
195
203
  max_download_bytes: int = 10_000_000
196
204
 
197
- # --- filesystem isolation (INERT — claustrum port pending) ---
198
- # NOT YET ENFORCED. opencode has no claustrum sandbox wired yet, so these
199
- # two fields are a known no-op today: they ship now for cross-engine
200
- # config parity (and so consumers can pin the intended policy), but the
201
- # launch path only emits a runtime warning when fs_isolation is True — it
202
- # does NOT restrict the filesystem. When claustrum lands for opencode,
203
- # fs_isolation will confine the agent to the workdir and extra_allowed_dirs
204
- # will widen that grant. Until then, treat both as advisory.
205
- fs_isolation: bool = True
206
- # Extra filesystem grants beyond the workdir (shared AllowedDir; ro/rw/
207
- # rox/rwx). INERT until claustrum lands (see fs_isolation above).
208
- extra_allowed_dirs: list[AllowedDir] | None = None
205
+ # --- filesystem isolation (claustrum) -------------------------------
206
+ # fs_isolation / extra_allowed_dirs / delivery_type are inherited from
207
+ # ClaustrumConfigMixin. When fs_isolation is True (default), the whole
208
+ # ``opencode web`` server tree runs confined to an explicit filesystem
209
+ # allowlist (task workdir + taskdir-local opencode.db + opencode binary
210
+ # cache + explicit grants), kernel-enforced via the claustrum Landlock
211
+ # sandbox. Fail-closed: if claustrum cannot be provisioned or the kernel
212
+ # lacks Landlock, the task refuses to launch rather than run unconfined.
213
+ # ``delivery_type`` is MANDATORY when fs_isolation is on (routes the
214
+ # "newer claustrum release available" security notice via on_deliverable);
215
+ # validated by _validate_claustrum(). Set fs_isolation=False to opt out.
209
216
 
210
217
  # --- tool allow/deny (folded into opencode.json's ``permission`` map) ---
211
218
  # Convenience fields that fold into opencode.json's per-tool ``permission``
@@ -219,6 +226,9 @@ class OpencodeTaskConfig:
219
226
  disallowed_tools: list[str] | None = None
220
227
 
221
228
  def __post_init__(self) -> None:
229
+ # Claustrum triad first: a missing delivery_type (fs_isolation on)
230
+ # fails fast before the opencode-specific checks below.
231
+ self._validate_claustrum()
222
232
  e = self.session_blob_encrypt is not None
223
233
  d = self.session_blob_decrypt is not None
224
234
  if e != d:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
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
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  src/optio_opencode/__init__.py
4
4
  src/optio_opencode/conversation.py
5
5
  src/optio_opencode/cred_watcher.py
6
+ src/optio_opencode/fs_allowlist.py
6
7
  src/optio_opencode/host_actions.py
7
8
  src/optio_opencode/info.py
8
9
  src/optio_opencode/model_probe.py
@@ -19,6 +20,7 @@ src/optio_opencode.egg-info/requires.txt
19
20
  src/optio_opencode.egg-info/top_level.txt
20
21
  tests/test_agent_info.py
21
22
  tests/test_agent_sender_opencode.py
23
+ tests/test_claustrum.py
22
24
  tests/test_config_harmonization.py
23
25
  tests/test_conversation_config.py
24
26
  tests/test_conversation_driver.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,149 @@
1
+ """Claustrum filesystem-isolation wiring for opencode (Task 10).
2
+
3
+ opencode is wrapped in claustrum for the first time: the ``opencode web`` server
4
+ tree is Landlock-confined. These tests cover the config triad rule, the grant-set
5
+ shape (including the taskdir grant that the live opencode.db needs), and the
6
+ argv splice inside ``launch_opencode``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shlex
12
+
13
+ import pytest
14
+
15
+ from optio_host.host import ProcessHandle, RunResult
16
+
17
+ from optio_opencode import fs_allowlist, host_actions
18
+ from optio_opencode.types import OpencodeTaskConfig
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # (a) fs_isolation on (default) requires delivery_type — inherited from the
23
+ # shared ClaustrumConfigMixin.
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def test_fs_isolation_on_requires_delivery_type():
28
+ with pytest.raises(ValueError, match="delivery_type"):
29
+ OpencodeTaskConfig(consumer_instructions="x", fs_isolation=True)
30
+
31
+
32
+ def test_delivery_type_satisfies_the_rule():
33
+ cfg = OpencodeTaskConfig(
34
+ consumer_instructions="x", fs_isolation=True, delivery_type="audit",
35
+ )
36
+ assert cfg.delivery_type == "audit"
37
+
38
+
39
+ def test_fs_isolation_off_allows_missing_delivery_type():
40
+ cfg = OpencodeTaskConfig(consumer_instructions="x", fs_isolation=False)
41
+ assert cfg.fs_isolation is False
42
+ assert cfg.delivery_type is None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # (b) grant set: --rwx <taskdir> (the live opencode.db is a sibling of the
47
+ # workdir) AND --rox <opencode cache> AND --rwx <workdir>.
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def test_grant_flags_cover_taskdir_workdir_and_cache():
52
+ flags = fs_allowlist.build_grant_flags(
53
+ workdir="/td/workdir",
54
+ taskdir="/td",
55
+ opencode_cache_dir="/cache/optio-opencode/bin",
56
+ extra_allowed_dirs=None,
57
+ )
58
+ # system baseline present
59
+ assert "/usr" in flags
60
+ # taskdir rwx (OPENCODE_DB = <taskdir>/opencode.db lives one level above workdir)
61
+ assert ["--rwx", "/td"] == _pair_before(flags, "/td")
62
+ # workdir rwx
63
+ assert ["--rwx", "/td/workdir"] == _pair_before(flags, "/td/workdir")
64
+ # opencode binary cache rox
65
+ assert ["--rox", "/cache/optio-opencode/bin"] == _pair_before(
66
+ flags, "/cache/optio-opencode/bin"
67
+ )
68
+
69
+
70
+ def test_grant_flags_expand_extra_home_tilde():
71
+ flags = fs_allowlist.build_grant_flags(
72
+ workdir="/td/workdir",
73
+ taskdir="/td",
74
+ opencode_cache_dir="/cache",
75
+ extra_allowed_dirs=[fs_allowlist.AllowedDir("~/data", "ro")],
76
+ host_home="/home/u",
77
+ )
78
+ assert flags[-2:] == ["--ro", "/home/u/data"]
79
+
80
+
81
+ def _pair_before(flags: list[str], path: str) -> list[str]:
82
+ i = flags.index(path)
83
+ return [flags[i - 1], flags[i]]
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # (c) launch_opencode splices the claustrum wrap immediately before the
88
+ # opencode executable and AFTER the env/password assignment.
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ class _RecordingLaunchHost:
93
+ def __init__(self, *, workdir: str = "/wd", taskdir: str = "/td") -> None:
94
+ self.workdir = workdir
95
+ self.taskdir = taskdir
96
+ self.launch_cmd: str | None = None
97
+ self.launch_env: dict[str, str] | None = None
98
+
99
+ async def write_text(self, relpath: str, content: str) -> None:
100
+ pass
101
+
102
+ async def run_command(self, cmd: str, *, cwd=None, env=None) -> RunResult:
103
+ return RunResult(stdout="", stderr="", exit_code=0)
104
+
105
+ async def launch_subprocess(
106
+ self, command, *, env=None, cwd=None, merge_stderr=True, stdin=False,
107
+ env_remove=None,
108
+ ) -> ProcessHandle:
109
+ self.launch_cmd = command
110
+ self.launch_env = env
111
+
112
+ async def _stdout():
113
+ yield b"server listening on http://127.0.0.1:54321\n"
114
+
115
+ return ProcessHandle(pid_like=object(), stdout=_stdout())
116
+
117
+ async def terminate_subprocess(self, handle, *, aggressive=False) -> None:
118
+ pass
119
+
120
+
121
+ async def test_launch_opencode_splices_claustrum_wrap_before_executable():
122
+ host = _RecordingLaunchHost(workdir="/wd", taskdir="/td")
123
+ wrap = [
124
+ "/c/claustrum", "--best-effort", "--abi-min", "1",
125
+ "--rwx", "/td/workdir", "--",
126
+ ]
127
+ handle, port = await host_actions.launch_opencode(
128
+ host, "pw", claustrum_wrap=wrap,
129
+ )
130
+ assert port == 54321
131
+ cmd = host.launch_cmd
132
+ assert cmd is not None
133
+ joined = shlex.join(wrap)
134
+ # The wrap appears immediately before the opencode executable.
135
+ assert f"{joined} opencode web" in cmd
136
+ # And AFTER the password assignment (localhost bind + URL scrape survive).
137
+ assert "OPENCODE_SERVER_PASSWORD" in cmd
138
+ assert cmd.index("OPENCODE_SERVER_PASSWORD") < cmd.index("/c/claustrum")
139
+
140
+
141
+ async def test_launch_opencode_without_wrap_is_unchanged():
142
+ host = _RecordingLaunchHost(workdir="/wd", taskdir="/td")
143
+ await host_actions.launch_opencode(host, "pw")
144
+ cmd = host.launch_cmd
145
+ assert cmd is not None
146
+ assert "claustrum" not in cmd
147
+ # opencode executable still launched right after the password assignment.
148
+ assert "OPENCODE_SERVER_PASSWORD" in cmd
149
+ assert "opencode web" in cmd
@@ -1,37 +1,15 @@
1
1
  """Session-level tests for the config-harmonization additions:
2
2
 
3
- - the inert ``fs_isolation`` runtime warning (change 6: NOT-YET-ENFORCED), and
4
3
  - the ``allowed_tools``/``disallowed_tools`` fold into opencode.json's
5
4
  ``permission`` map (T2: REACHABLE → wired).
6
5
 
6
+ (The former ``fs_isolation`` inert-warning tests were removed when claustrum
7
+ was wired for opencode; the fs-isolation triad is covered by test_claustrum.py.)
8
+
7
9
  Pure/unit — no subprocess, no host, xdist-safe.
8
10
  """
9
11
 
10
- import logging
11
-
12
- from optio_opencode.session import (
13
- _fold_tool_permissions,
14
- _warn_if_fs_isolation_unenforced,
15
- )
16
- from optio_opencode.types import OpencodeTaskConfig
17
-
18
-
19
- # --- fs_isolation inert warning ----------------------------------------------
20
-
21
-
22
- def test_fs_isolation_true_emits_not_enforced_warning(caplog):
23
- cfg = OpencodeTaskConfig(consumer_instructions="x", fs_isolation=True)
24
- with caplog.at_level(logging.WARNING, logger="optio_opencode.session"):
25
- _warn_if_fs_isolation_unenforced(cfg)
26
- msgs = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
27
- assert any("fs_isolation" in m and "not yet enforced" in m for m in msgs), msgs
28
-
29
-
30
- def test_fs_isolation_false_is_silent(caplog):
31
- cfg = OpencodeTaskConfig(consumer_instructions="x", fs_isolation=False)
32
- with caplog.at_level(logging.WARNING, logger="optio_opencode.session"):
33
- _warn_if_fs_isolation_unenforced(cfg)
34
- assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
12
+ from optio_opencode.session import _fold_tool_permissions
35
13
 
36
14
 
37
15
  # --- allowed_tools / disallowed_tools fold -----------------------------------