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.
Files changed (35) hide show
  1. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/PKG-INFO +1 -1
  2. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/pyproject.toml +1 -1
  3. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/host_actions.py +4 -1
  4. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/session.py +19 -0
  5. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/types.py +4 -0
  6. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/PKG-INFO +1 -1
  7. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_actions.py +1 -0
  8. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_local.py +2 -2
  9. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_seed_config.py +5 -0
  10. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_blob_hooks.py +5 -0
  11. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_hooks.py +1 -1
  12. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_local.py +20 -2
  13. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_resume.py +45 -3
  14. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_seed.py +6 -1
  15. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/README.md +0 -0
  16. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/setup.cfg +0 -0
  17. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/__init__.py +0 -0
  18. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/prompt.py +0 -0
  19. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/seed_manifest.py +0 -0
  20. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode/snapshots.py +0 -0
  21. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/SOURCES.txt +0 -0
  22. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  23. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/requires.txt +0 -0
  24. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/src/optio_opencode.egg-info/top_level.txt +0 -0
  25. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_primitives_local.py +0 -0
  26. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_primitives_remote.py +0 -0
  27. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_remote_resume.py +0 -0
  28. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_host_resume.py +0 -0
  29. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_prompt.py +0 -0
  30. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_purge_seed.py +0 -0
  31. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_sanity.py +0 -0
  32. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_session_remote.py +0 -0
  33. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_smart_install.py +0 -0
  34. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_snapshots.py +0 -0
  35. {optio_opencode-0.1.7 → optio_opencode-0.1.9}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.7
3
+ Version: 0.1.9
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.1.7"
7
+ version = "0.1.9"
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"
@@ -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(cmd, env=env, cwd=host.workdir)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.7
3
+ Version: 0.1.9
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
@@ -128,6 +128,7 @@ class _RecordingLaunchHost:
128
128
 
129
129
  async def launch_subprocess(
130
130
  self, command, *, env=None, cwd=None, merge_stderr=True, stdin=False,
131
+ env_remove=None,
131
132
  ) -> ProcessHandle:
132
133
  self.launch_cmd = command
133
134
  self.launch_env = env
@@ -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
- cfg = _config("happy") # default supports_resume=True
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 _run_one_cycle(mongo_db, process_id: str, resume: bool) -> None:
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(consumer_instructions=f"(scenario: happy {process_id})")
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