optio-opencode 0.3.0__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.0 → optio_opencode-0.4.0}/PKG-INFO +2 -2
  2. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/pyproject.toml +2 -2
  3. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/__init__.py +2 -0
  4. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/conversation.py +4 -0
  5. optio_opencode-0.4.0/src/optio_opencode/fs_allowlist.py +49 -0
  6. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/host_actions.py +105 -4
  7. optio_opencode-0.4.0/src/optio_opencode/info.py +7 -0
  8. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/session.py +62 -14
  9. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/types.py +25 -15
  10. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/PKG-INFO +2 -2
  11. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/SOURCES.txt +4 -0
  12. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/requires.txt +1 -1
  13. optio_opencode-0.4.0/tests/test_agent_info.py +14 -0
  14. optio_opencode-0.4.0/tests/test_claustrum.py +149 -0
  15. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_config_harmonization.py +4 -26
  16. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_config.py +6 -1
  17. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_driver.py +95 -31
  18. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_session.py +11 -11
  19. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_ui_model.py +10 -10
  20. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_ui_session.py +40 -15
  21. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_upload.py +4 -4
  22. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_cred_watcher.py +17 -5
  23. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_file_download.py +5 -5
  24. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_file_upload.py +3 -3
  25. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_actions.py +61 -0
  26. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_local.py +1 -1
  27. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_sanity.py +4 -4
  28. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_seed_config.py +2 -2
  29. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_blob_hooks.py +4 -4
  30. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_hooks.py +6 -6
  31. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_local.py +14 -4
  32. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_remote.py +1 -1
  33. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_resume.py +6 -6
  34. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_seed.py +7 -7
  35. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_seed_saveback.py +3 -3
  36. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_types.py +20 -16
  37. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/README.md +0 -0
  38. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/setup.cfg +0 -0
  39. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/cred_watcher.py +0 -0
  40. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/model_probe.py +0 -0
  41. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/prompt.py +0 -0
  42. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/seed_manifest.py +0 -0
  43. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/snapshots.py +0 -0
  44. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/verify.py +0 -0
  45. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  46. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
  47. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_agent_sender_opencode.py +0 -0
  48. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_primitives_local.py +0 -0
  49. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_primitives_remote.py +0 -0
  50. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_remote_resume.py +0 -0
  51. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_resume.py +0 -0
  52. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_model_probe.py +0 -0
  53. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_model_probe_wiring.py +0 -0
  54. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_prompt.py +0 -0
  55. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_purge_seed.py +0 -0
  56. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_resume_sentence_opencode.py +0 -0
  57. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_smart_install.py +0 -0
  58. {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_snapshots.py +0 -0
  59. {optio_opencode-0.3.0 → 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.0
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.0"
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
  ]
@@ -8,6 +8,7 @@ from optio_host import (
8
8
  RunResult,
9
9
  SSHConfig,
10
10
  )
11
+ from optio_opencode.info import AGENT_INFO
11
12
  from optio_opencode.session import create_opencode_task, run_opencode_session
12
13
  from optio_opencode.types import (
13
14
  ConversationMode,
@@ -36,6 +37,7 @@ from optio_opencode.verify import verify_and_refresh_seed
36
37
  _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
37
38
 
38
39
  __all__ = [
40
+ "AGENT_INFO",
39
41
  "create_opencode_task",
40
42
  "run_opencode_session",
41
43
  "DeliverableCallback",
@@ -37,6 +37,8 @@ from optio_agents.conversation import (
37
37
  PermissionRequest,
38
38
  )
39
39
 
40
+ from .info import AGENT_INFO
41
+
40
42
  _LOG = logging.getLogger(__name__)
41
43
 
42
44
  # Reconnect backoff for the SSE reader (capped; the session body cancels the
@@ -49,7 +51,9 @@ class OpencodeConversation:
49
51
 
50
52
  def __init__(
51
53
  self, *, port: int, password: str, session_id: str, directory: str,
54
+ agent_label: str = AGENT_INFO.slug,
52
55
  ) -> None:
56
+ self._agent_label = agent_label
53
57
  self._base = f"http://127.0.0.1:{port}"
54
58
  self._auth = aiohttp.BasicAuth("opencode", password)
55
59
  self._session_id = session_id
@@ -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,10 @@ 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
+
33
+ from optio_opencode.info import AGENT_INFO
34
+
31
35
  if TYPE_CHECKING:
32
36
  from optio_host.host import Host
33
37
 
@@ -69,6 +73,72 @@ async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
69
73
  return path.rstrip("/")
70
74
 
71
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
+
72
142
  def _isolation_env(host: "Host") -> dict[str, str]:
73
143
  """Per-task HOME/XDG isolation env, derived from ``host.workdir``.
74
144
 
@@ -259,7 +329,7 @@ async def ensure_opencode_installed(
259
329
  # dashboard shows it working rather than stuck at 0% while the install
260
330
  # check (and any subsequent download child task) runs.
261
331
  if report_progress is not None:
262
- report_progress(None, "Checking opencode installation…")
332
+ report_progress(None, f"Checking {AGENT_INFO.name} installation…")
263
333
  kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
264
334
  if kind == "ok":
265
335
  # Resolve the on-PATH path. Login shell so ``$HOME``-relative
@@ -286,7 +356,7 @@ async def ensure_opencode_installed(
286
356
  )
287
357
  assert url is not None # _smart_install_check guarantees
288
358
  if report_progress is not None:
289
- report_progress(None, "Installing opencode…")
359
+ report_progress(None, f"Installing {AGENT_INFO.name}…")
290
360
  return await _install_opencode_from_zip(
291
361
  host, download, url, install_dir=resolved_install_dir,
292
362
  )
@@ -435,6 +505,7 @@ async def launch_opencode(
435
505
  hostname: str = "127.0.0.1",
436
506
  extra_env: dict[str, str] | None = None,
437
507
  env_remove: list[str] | None = None,
508
+ claustrum_wrap: list[str] | None = None,
438
509
  ) -> tuple[ProcessHandle, int]:
439
510
  """Launch ``opencode web`` on ``host``; wait for the listening URL.
440
511
 
@@ -473,10 +544,18 @@ async def launch_opencode(
473
544
  # opencode succeeds at opening a real browser window. opencode_executable
474
545
  # is an absolute path (resolved by ensure_opencode_installed), so
475
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 ""
476
555
  cmd = (
477
556
  f"exec env "
478
557
  f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
479
- f"{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
558
+ f"{wrap_prefix}{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
480
559
  )
481
560
 
482
561
  # OPENCODE_DB must point at the same per-task db file used by the
@@ -505,19 +584,40 @@ async def launch_opencode(
505
584
  cmd, env=env, cwd=host.workdir, env_remove=env_remove,
506
585
  )
507
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
+
508
604
  async def _read_url() -> int:
509
605
  async for raw in handle.stdout:
510
606
  if isinstance(raw, bytes):
511
607
  line = raw.decode("utf-8", errors="replace").rstrip()
512
608
  else:
513
609
  line = str(raw).rstrip()
610
+ if line:
611
+ tail.append(line)
514
612
  m = _READY_RE.search(line)
515
613
  if m:
516
614
  m2 = re.search(r":(\d+)", m.group(1))
517
615
  if not m2:
518
616
  raise RuntimeError(f"could not find port in URL: {line}")
519
617
  return int(m2.group(1))
520
- raise RuntimeError("opencode exited before printing a URL")
618
+ raise RuntimeError(
619
+ f"opencode exited before printing a URL{_reason_suffix()}"
620
+ )
521
621
 
522
622
  try:
523
623
  port = await asyncio.wait_for(_read_url(), timeout=ready_timeout_s)
@@ -525,6 +625,7 @@ async def launch_opencode(
525
625
  await host.terminate_subprocess(handle, aggressive=True)
526
626
  raise TimeoutError(
527
627
  f"opencode did not print a listening URL within {ready_timeout_s}s"
628
+ f"{_reason_suffix()}"
528
629
  )
529
630
  except BaseException:
530
631
  await host.terminate_subprocess(handle, aggressive=True)
@@ -0,0 +1,7 @@
1
+ from optio_agents import AgentInfo
2
+
3
+ AGENT_INFO = AgentInfo(
4
+ slug="opencode",
5
+ name="OpenCode",
6
+ url="https://opencode.ai",
7
+ )
@@ -39,6 +39,7 @@ from optio_agents.uploads import materialize, upload_url_token
39
39
  from optio_agents import seeds as _seeds
40
40
  from optio_opencode import cred_watcher, host_actions
41
41
  from optio_opencode import model_probe
42
+ from optio_opencode.info import AGENT_INFO
42
43
  from optio_opencode.conversation import OpencodeConversation
43
44
  from optio_opencode.prompt import DEFAULT_CONVERSATION_INSTRUCTIONS, compose_agents_md
44
45
  from optio_opencode.seed_manifest import (
@@ -105,16 +106,33 @@ def _fold_tool_permissions(
105
106
  return cfg
106
107
 
107
108
 
108
- def _warn_if_fs_isolation_unenforced(config: "OpencodeTaskConfig") -> None:
109
- """Emit a runtime warning on the worker console when ``fs_isolation`` is
110
- requested. opencode has no claustrum sandbox wired yet, so the flag is
111
- INERT (see OpencodeTaskConfig.fs_isolation): honour the operator's signal
112
- by making the no-op loud rather than silent."""
113
- if config.fs_isolation:
114
- _LOG.warning(
115
- "opencode fs_isolation requested but not yet enforced "
116
- "(claustrum pending); the agent is NOT filesystem-confined."
117
- )
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)
118
136
 
119
137
 
120
138
  def conversation_widget_data(config: "OpencodeTaskConfig", *, session_id: str, directory: str) -> dict:
@@ -181,6 +199,12 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
181
199
  cred_watch_task: "asyncio.Task | None" = None
182
200
  resolved_seed_id: str | None = None
183
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
184
208
 
185
209
  await host.connect()
186
210
 
@@ -195,6 +219,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
195
219
  before the tail can re-emit its stale DELIVERABLE/DONE/ERROR lines.
196
220
  """
197
221
  nonlocal opencode_exec, resuming, preserved_session_id
222
+ nonlocal claustrum_path, claustrum_newer
198
223
  opencode_exec = await host_actions.ensure_opencode_installed(
199
224
  hook_ctx._host,
200
225
  download=hook_ctx.download_file,
@@ -203,6 +228,16 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
203
228
  install_dir=config.install_dir,
204
229
  )
205
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
+
206
241
  resume_requested = bool(getattr(ctx, "resume", False))
207
242
  snapshot: dict | None = None
208
243
  if resume_requested:
@@ -391,8 +426,20 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
391
426
  # loopback; the SSH tunnel on the engine side handles exposure.
392
427
  opencode_hostname = bind_addr if isinstance(host, LocalHost) else "127.0.0.1"
393
428
 
394
- _warn_if_fs_isolation_unenforced(config)
395
- ctx.report_progress(None, f"Launching opencode{version_suffix}…")
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
+
442
+ ctx.report_progress(None, f"Launching {AGENT_INFO.name}{version_suffix}…")
396
443
  handle, opencode_port = await host_actions.launch_opencode(
397
444
  host, password,
398
445
  ready_timeout_s=READY_TIMEOUT_S,
@@ -400,6 +447,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
400
447
  hostname=opencode_hostname,
401
448
  extra_env={**(config.env or {}), **hook_ctx.browser_launch_env},
402
449
  env_remove=config.scrub_env,
450
+ claustrum_wrap=claustrum_wrap,
403
451
  )
404
452
  launched_handle = handle
405
453
 
@@ -453,7 +501,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
453
501
  "opencode.global.dat:layout": '{"review": {"panelOpened": false}}',
454
502
  },
455
503
  })
456
- ctx.report_progress(None, "opencode is live")
504
+ ctx.report_progress(None, f"{AGENT_INFO.name} is live")
457
505
  elif config.conversation_ui:
458
506
  # Conversation widget: the opencode server itself is the upstream
459
507
  # (same proxy + inner-auth model as iframe mode); the widget talks
@@ -574,7 +622,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
574
622
  )
575
623
  reader_task = asyncio.create_task(conversation.run_reader())
576
624
  ctx.publish_result(conversation)
577
- ctx.report_progress(None, "opencode conversation is live")
625
+ ctx.report_progress(None, f"{AGENT_INFO.name} conversation is live")
578
626
 
579
627
  proc = launched_handle.pid_like
580
628
  wait_task = asyncio.create_task(proc.wait()) # type: ignore[union-attr]
@@ -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.0
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,7 +3,9 @@ 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
8
+ src/optio_opencode/info.py
7
9
  src/optio_opencode/model_probe.py
8
10
  src/optio_opencode/prompt.py
9
11
  src/optio_opencode/seed_manifest.py
@@ -16,7 +18,9 @@ src/optio_opencode.egg-info/SOURCES.txt
16
18
  src/optio_opencode.egg-info/dependency_links.txt
17
19
  src/optio_opencode.egg-info/requires.txt
18
20
  src/optio_opencode.egg-info/top_level.txt
21
+ tests/test_agent_info.py
19
22
  tests/test_agent_sender_opencode.py
23
+ tests/test_claustrum.py
20
24
  tests/test_config_harmonization.py
21
25
  tests/test_conversation_config.py
22
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,14 @@
1
+ from optio_opencode import AGENT_INFO
2
+ from optio_opencode.types import OpencodeTaskConfig
3
+
4
+
5
+ def test_agent_info_values():
6
+ assert AGENT_INFO.slug == "opencode"
7
+ assert AGENT_INFO.name == "OpenCode"
8
+ assert AGENT_INFO.url == "https://opencode.ai"
9
+
10
+
11
+ def test_agent_info_slug_matches_agent_type():
12
+ import dataclasses
13
+ fields = {f.name: f for f in dataclasses.fields(OpencodeTaskConfig)}
14
+ assert fields["agent_type"].default == AGENT_INFO.slug