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