optio-opencode 0.1.7__tar.gz → 0.1.9__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.1.7 → optio_opencode-0.1.9}/PKG-INFO +1 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/pyproject.toml +1 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/host_actions.py +4 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/session.py +19 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/types.py +4 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/PKG-INFO +1 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_actions.py +1 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_local.py +2 -2
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_seed_config.py +5 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_blob_hooks.py +5 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_hooks.py +1 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_local.py +20 -2
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_resume.py +45 -3
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_seed.py +6 -1
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/README.md +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/setup.cfg +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/__init__.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/SOURCES.txt +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/requires.txt +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_prompt.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_sanity.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_remote.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_snapshots.py +0 -0
- {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_types.py +0 -0
|
@@ -366,6 +366,7 @@ async def launch_opencode(
|
|
|
366
366
|
opencode_executable: str = "opencode",
|
|
367
367
|
hostname: str = "127.0.0.1",
|
|
368
368
|
extra_env: dict[str, str] | None = None,
|
|
369
|
+
env_remove: list[str] | None = None,
|
|
369
370
|
) -> tuple[ProcessHandle, int]:
|
|
370
371
|
"""Launch ``opencode web`` on ``host``; wait for the listening URL.
|
|
371
372
|
|
|
@@ -425,7 +426,9 @@ async def launch_opencode(
|
|
|
425
426
|
**(extra_env or {}),
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
handle = await host.launch_subprocess(
|
|
429
|
+
handle = await host.launch_subprocess(
|
|
430
|
+
cmd, env=env, cwd=host.workdir, env_remove=env_remove,
|
|
431
|
+
)
|
|
429
432
|
|
|
430
433
|
async def _read_url() -> int:
|
|
431
434
|
async for raw in handle.stdout:
|
|
@@ -268,6 +268,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
|
|
|
268
268
|
opencode_executable=opencode_exec,
|
|
269
269
|
hostname=opencode_hostname,
|
|
270
270
|
extra_env=hook_ctx.browser_launch_env,
|
|
271
|
+
env_remove=config.scrub_env,
|
|
271
272
|
)
|
|
272
273
|
launched_handle = handle
|
|
273
274
|
|
|
@@ -482,6 +483,24 @@ async def _capture_snapshot(
|
|
|
482
483
|
opencode_executable: str = "opencode",
|
|
483
484
|
session_blob_encrypt: "Callable[[bytes], bytes] | None" = None,
|
|
484
485
|
) -> None:
|
|
486
|
+
# Defense-in-depth (guard #2): refuse to capture a resumable snapshot
|
|
487
|
+
# unless opencode's auth.json exists and is non-empty on the host. A
|
|
488
|
+
# credential-less workdir is degenerate — restoring it would relaunch
|
|
489
|
+
# opencode with no auth, so marking it resumable is worse than useless.
|
|
490
|
+
# (Live-reach is already covered by the session_id is not None gate at
|
|
491
|
+
# the call site; this covers the bad/empty-seed edge.)
|
|
492
|
+
workdir = host.workdir.rstrip("/")
|
|
493
|
+
chk = await host.run_command(
|
|
494
|
+
f"test -s {shlex.quote(workdir)}/home/.local/share/opencode/auth.json "
|
|
495
|
+
f"&& echo OK || true"
|
|
496
|
+
)
|
|
497
|
+
if "OK" not in chk.stdout:
|
|
498
|
+
_LOG.warning(
|
|
499
|
+
"snapshot capture skipped: opencode auth.json absent/empty; "
|
|
500
|
+
"refusing to mark resumable"
|
|
501
|
+
)
|
|
502
|
+
return
|
|
503
|
+
|
|
485
504
|
session_json = await host_actions.opencode_export(
|
|
486
505
|
host, opencode_db, session_id,
|
|
487
506
|
opencode_executable=opencode_executable,
|
|
@@ -64,6 +64,10 @@ class OpencodeTaskConfig:
|
|
|
64
64
|
# (POST /api/session/<id>/prompt "Read AGENTS.md and execute the task it
|
|
65
65
|
# describes"); suppressed on resume.
|
|
66
66
|
auto_start: bool = False
|
|
67
|
+
# Glob patterns (fnmatch) of env var NAMES to strip from the opencode
|
|
68
|
+
# subprocess, so inherited provider creds don't override the seed. e.g.
|
|
69
|
+
# ["*_API_KEY", "*_TOKEN"].
|
|
70
|
+
scrub_env: list[str] | None = None
|
|
67
71
|
|
|
68
72
|
def __post_init__(self) -> None:
|
|
69
73
|
e = self.session_blob_encrypt is not None
|
|
@@ -71,7 +71,7 @@ async def test_launch_opencode_passes_hostname_into_cmd(local_host, monkeypatch)
|
|
|
71
71
|
--hostname=`` argument."""
|
|
72
72
|
captured: dict[str, str] = {}
|
|
73
73
|
|
|
74
|
-
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None):
|
|
74
|
+
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None, env_remove=None):
|
|
75
75
|
captured["cmd"] = cmd
|
|
76
76
|
raise RuntimeError("stop before waiting on stdout")
|
|
77
77
|
|
|
@@ -92,7 +92,7 @@ async def test_launch_opencode_default_hostname_is_loopback(local_host, monkeypa
|
|
|
92
92
|
"""Default keeps single-host / RemoteHost-over-SSH behaviour intact."""
|
|
93
93
|
captured: dict[str, str] = {}
|
|
94
94
|
|
|
95
|
-
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None):
|
|
95
|
+
async def fake_launch_subprocess(self, cmd, *, env=None, cwd=None, env_remove=None):
|
|
96
96
|
captured["cmd"] = cmd
|
|
97
97
|
raise RuntimeError("stop")
|
|
98
98
|
|
|
@@ -32,6 +32,11 @@ def test_seed_config_defaults_none():
|
|
|
32
32
|
assert cfg.auto_start is False
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def test_opencode_config_scrub_env_default_none():
|
|
36
|
+
from optio_opencode.types import OpencodeTaskConfig
|
|
37
|
+
assert OpencodeTaskConfig(consumer_instructions="hi").scrub_env is None
|
|
38
|
+
|
|
39
|
+
|
|
35
40
|
def test_manifest_shape():
|
|
36
41
|
assert OPENCODE_SEED_SUFFIX == "_opencode_seeds"
|
|
37
42
|
assert OPENCODE_SEED_MANIFEST.home_subdir == "home"
|
|
@@ -99,6 +99,11 @@ async def test_capture_writes_through_session_blob_encrypt(monkeypatch):
|
|
|
99
99
|
yield b"workdir-bytes"
|
|
100
100
|
fake_host = MagicMock()
|
|
101
101
|
fake_host.archive_workdir = _fake_archive
|
|
102
|
+
# Satisfy the snapshot-capture defense-in-depth guard: it runs a
|
|
103
|
+
# `test -s .../auth.json && echo OK` probe on the host and refuses to
|
|
104
|
+
# capture unless the output contains "OK".
|
|
105
|
+
fake_host.workdir = "/work"
|
|
106
|
+
fake_host.run_command = AsyncMock(return_value=MagicMock(stdout="OK\n"))
|
|
102
107
|
|
|
103
108
|
await _capture_snapshot(
|
|
104
109
|
fake_ctx, fake_host,
|
|
@@ -118,7 +118,7 @@ def _patch_host_actions(monkeypatch, host):
|
|
|
118
118
|
async def _version(_host, *, opencode_executable="opencode"):
|
|
119
119
|
return None
|
|
120
120
|
|
|
121
|
-
async def _launch(_host, _password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None):
|
|
121
|
+
async def _launch(_host, _password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None, env_remove=None):
|
|
122
122
|
host.timeline.append("launch_opencode")
|
|
123
123
|
raise RuntimeError("test never gets past launch")
|
|
124
124
|
|
|
@@ -92,6 +92,21 @@ def _config(scenario: str, deliverable_cb=None, raises: bool = False) -> Opencod
|
|
|
92
92
|
)
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
async def _plant_auth_json(hook_ctx) -> None:
|
|
96
|
+
"""before_execute hook: plant a non-empty opencode auth.json in the workdir.
|
|
97
|
+
|
|
98
|
+
The snapshot-capture defense-in-depth guard refuses to mark a session
|
|
99
|
+
resumable unless ``home/.local/share/opencode/auth.json`` exists and is
|
|
100
|
+
non-empty on the host, so capture-expecting tests must plant credentials
|
|
101
|
+
the same way a real seeded launch would.
|
|
102
|
+
"""
|
|
103
|
+
await hook_ctx.run_on_host(
|
|
104
|
+
"mkdir -p home/.local/share/opencode && "
|
|
105
|
+
"printf '{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-test\"}}' "
|
|
106
|
+
"> home/.local/share/opencode/auth.json"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
95
110
|
@pytest.fixture(autouse=True)
|
|
96
111
|
def _supply_scenario(monkeypatch):
|
|
97
112
|
"""Substitute fake_opencode.py for the real opencode binary.
|
|
@@ -105,7 +120,7 @@ def _supply_scenario(monkeypatch):
|
|
|
105
120
|
orig_launch = host_actions.launch_opencode
|
|
106
121
|
scenario_holder: dict = {"name": "happy"}
|
|
107
122
|
|
|
108
|
-
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None):
|
|
123
|
+
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None, env_remove=None):
|
|
109
124
|
del opencode_executable # we substitute fully
|
|
110
125
|
return await orig_launch(
|
|
111
126
|
host, password,
|
|
@@ -596,11 +611,14 @@ async def test_session_local_supports_resume_true_captures_snapshot(
|
|
|
596
611
|
ctx_and_captures, _supply_scenario, tmp_workdir,
|
|
597
612
|
):
|
|
598
613
|
"""With supports_resume=True (default), a snapshot IS captured."""
|
|
614
|
+
import dataclasses
|
|
599
615
|
from optio_opencode.snapshots import SESSION_SNAPSHOT_COLLECTION_SUFFIX
|
|
600
616
|
ctx, _, _ = ctx_and_captures
|
|
601
617
|
_supply_scenario["name"] = "happy"
|
|
602
618
|
|
|
603
|
-
|
|
619
|
+
# Plant auth.json so the snapshot-capture defense-in-depth guard permits
|
|
620
|
+
# capture; without credentials it would refuse to mark resumable.
|
|
621
|
+
cfg = dataclasses.replace(_config("happy"), before_execute=_plant_auth_json)
|
|
604
622
|
await run_opencode_session(ctx, cfg)
|
|
605
623
|
|
|
606
624
|
coll_name = f"{ctx._prefix}{SESSION_SNAPSHOT_COLLECTION_SUFFIX}"
|
|
@@ -53,7 +53,7 @@ def _supply_scenario(monkeypatch):
|
|
|
53
53
|
orig_launch = host_actions.launch_opencode
|
|
54
54
|
holder = {"name": "happy"}
|
|
55
55
|
|
|
56
|
-
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None):
|
|
56
|
+
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None, env_remove=None):
|
|
57
57
|
del opencode_executable
|
|
58
58
|
return await orig_launch(
|
|
59
59
|
host, password,
|
|
@@ -121,9 +121,30 @@ async def _make_ctx(mongo_db, process_id: str, *, resume: bool):
|
|
|
121
121
|
return ctx, proc["_id"]
|
|
122
122
|
|
|
123
123
|
|
|
124
|
-
async def
|
|
124
|
+
async def _plant_auth_json(hook_ctx) -> None:
|
|
125
|
+
"""before_execute hook: plant a non-empty opencode auth.json in the workdir.
|
|
126
|
+
|
|
127
|
+
The snapshot-capture defense-in-depth guard refuses to mark a session
|
|
128
|
+
resumable unless ``home/.local/share/opencode/auth.json`` exists and is
|
|
129
|
+
non-empty on the host. Capture-expecting tests must therefore plant a
|
|
130
|
+
credentials file the same way a real seeded launch would, mirroring how
|
|
131
|
+
claudecode tests plant ``.credentials.json``.
|
|
132
|
+
"""
|
|
133
|
+
await hook_ctx.run_on_host(
|
|
134
|
+
"mkdir -p home/.local/share/opencode && "
|
|
135
|
+
"printf '{\"anthropic\": {\"type\": \"api\", \"key\": \"sk-test\"}}' "
|
|
136
|
+
"> home/.local/share/opencode/auth.json"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _run_one_cycle(
|
|
141
|
+
mongo_db, process_id: str, resume: bool, *, plant_auth: bool = True,
|
|
142
|
+
) -> None:
|
|
125
143
|
ctx, _ = await _make_ctx(mongo_db, process_id, resume=resume)
|
|
126
|
-
cfg = OpencodeTaskConfig(
|
|
144
|
+
cfg = OpencodeTaskConfig(
|
|
145
|
+
consumer_instructions=f"(scenario: happy {process_id})",
|
|
146
|
+
before_execute=_plant_auth_json if plant_auth else None,
|
|
147
|
+
)
|
|
127
148
|
await run_opencode_session(ctx, cfg)
|
|
128
149
|
|
|
129
150
|
|
|
@@ -142,6 +163,27 @@ async def test_terminal_flow_captures_snapshot_and_wipes_workdir(mongo_db, task_
|
|
|
142
163
|
assert not wd.exists() or not any(wd.iterdir())
|
|
143
164
|
|
|
144
165
|
|
|
166
|
+
async def test_no_auth_json_refuses_to_capture_snapshot(mongo_db, task_root):
|
|
167
|
+
"""Defense-in-depth: a normal launch (session_id created) but with NO
|
|
168
|
+
opencode auth.json on the host must NOT capture a snapshot or mark the
|
|
169
|
+
process resumable. Guards against marking a degenerate, credential-less
|
|
170
|
+
workdir as resumable.
|
|
171
|
+
"""
|
|
172
|
+
pid = "oc_no_auth_1"
|
|
173
|
+
await _run_one_cycle(mongo_db, pid, resume=False, plant_auth=False)
|
|
174
|
+
|
|
175
|
+
snap = await load_latest_snapshot(mongo_db, prefix="test", process_id=pid)
|
|
176
|
+
assert snap is None
|
|
177
|
+
|
|
178
|
+
count = await mongo_db[f"test{SESSION_SNAPSHOT_COLLECTION_SUFFIX}"].count_documents(
|
|
179
|
+
{"processId": pid}
|
|
180
|
+
)
|
|
181
|
+
assert count == 0
|
|
182
|
+
|
|
183
|
+
proc = await mongo_db["test_processes"].find_one({"processId": pid})
|
|
184
|
+
assert proc.get("hasSavedState") is not True
|
|
185
|
+
|
|
186
|
+
|
|
145
187
|
async def test_resume_creates_second_snapshot(mongo_db, task_root):
|
|
146
188
|
pid = "oc_resume_1"
|
|
147
189
|
await _run_one_cycle(mongo_db, pid, resume=False)
|
|
@@ -71,7 +71,7 @@ def _supply_scenario(monkeypatch):
|
|
|
71
71
|
orig_launch = host_actions.launch_opencode
|
|
72
72
|
holder = {"name": "happy"}
|
|
73
73
|
|
|
74
|
-
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None):
|
|
74
|
+
async def _launch(host, password, *, ready_timeout_s=30.0, opencode_executable="opencode", hostname="127.0.0.1", extra_env=None, env_remove=None):
|
|
75
75
|
del opencode_executable
|
|
76
76
|
return await orig_launch(
|
|
77
77
|
host, password,
|
|
@@ -330,6 +330,10 @@ async def test_auto_start_posts_on_fresh_and_not_on_resume(
|
|
|
330
330
|
await run_opencode_session(ctx_fresh, OpencodeTaskConfig(
|
|
331
331
|
consumer_instructions="(scenario: happy)",
|
|
332
332
|
auto_start=True,
|
|
333
|
+
# Plant auth.json so the snapshot-capture defense-in-depth guard does
|
|
334
|
+
# not refuse to mark this resumable; otherwise the resume leg would
|
|
335
|
+
# fall back to a fresh launch and POST the kickoff prompt again.
|
|
336
|
+
before_execute=_plant_env,
|
|
333
337
|
))
|
|
334
338
|
assert len(posts) == 1
|
|
335
339
|
posted_session_id, posted_message = posts[0]
|
|
@@ -341,6 +345,7 @@ async def test_auto_start_posts_on_fresh_and_not_on_resume(
|
|
|
341
345
|
await run_opencode_session(ctx_resume, OpencodeTaskConfig(
|
|
342
346
|
consumer_instructions="(scenario: happy)",
|
|
343
347
|
auto_start=True,
|
|
348
|
+
before_execute=_plant_env,
|
|
344
349
|
))
|
|
345
350
|
assert len(posts) == 1, posts
|
|
346
351
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|