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.
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/PKG-INFO +2 -2
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/pyproject.toml +2 -2
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/__init__.py +2 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/conversation.py +4 -0
- optio_opencode-0.4.0/src/optio_opencode/fs_allowlist.py +49 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/host_actions.py +105 -4
- optio_opencode-0.4.0/src/optio_opencode/info.py +7 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/session.py +62 -14
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/types.py +25 -15
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/PKG-INFO +2 -2
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/SOURCES.txt +4 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/requires.txt +1 -1
- optio_opencode-0.4.0/tests/test_agent_info.py +14 -0
- optio_opencode-0.4.0/tests/test_claustrum.py +149 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_config_harmonization.py +4 -26
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_config.py +6 -1
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_driver.py +95 -31
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_session.py +11 -11
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_ui_model.py +10 -10
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_ui_session.py +40 -15
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_conversation_upload.py +4 -4
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_cred_watcher.py +17 -5
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_file_download.py +5 -5
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_file_upload.py +3 -3
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_actions.py +61 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_local.py +1 -1
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_sanity.py +4 -4
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_seed_config.py +2 -2
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_blob_hooks.py +4 -4
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_hooks.py +6 -6
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_local.py +14 -4
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_remote.py +1 -1
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_resume.py +6 -6
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_seed.py +7 -7
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_session_seed_saveback.py +3 -3
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_types.py +20 -16
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/README.md +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/setup.cfg +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/cred_watcher.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/model_probe.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode/verify.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_model_probe.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_model_probe_wiring.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_prompt.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.3.0 → optio_opencode-0.4.0}/tests/test_snapshots.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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)
|
|
@@ -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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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, "
|
|
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, "
|
|
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 (
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
fs_isolation
|
|
206
|
-
#
|
|
207
|
-
#
|
|
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
|
+
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.
|
|
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
|
|
@@ -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
|