optio-opencode 0.1.8__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/PKG-INFO +2 -2
  2. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/pyproject.toml +2 -2
  3. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/prompt.py +3 -0
  4. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/session.py +35 -0
  5. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode.egg-info/PKG-INFO +2 -2
  6. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode.egg-info/SOURCES.txt +2 -0
  7. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode.egg-info/requires.txt +1 -1
  8. optio_opencode-0.2.0/tests/test_agent_sender_opencode.py +29 -0
  9. optio_opencode-0.2.0/tests/test_resume_sentence_opencode.py +7 -0
  10. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_blob_hooks.py +5 -0
  11. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_local.py +19 -1
  12. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_resume.py +44 -2
  13. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_seed.py +26 -10
  14. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/README.md +0 -0
  15. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/setup.cfg +0 -0
  16. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/__init__.py +0 -0
  17. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/host_actions.py +0 -0
  18. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/seed_manifest.py +0 -0
  19. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/snapshots.py +0 -0
  20. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode/types.py +0 -0
  21. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  22. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
  23. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_actions.py +0 -0
  24. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_local.py +0 -0
  25. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_primitives_local.py +0 -0
  26. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_primitives_remote.py +0 -0
  27. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_remote_resume.py +0 -0
  28. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_host_resume.py +0 -0
  29. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_prompt.py +0 -0
  30. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_purge_seed.py +0 -0
  31. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_sanity.py +0 -0
  32. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_seed_config.py +0 -0
  33. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_hooks.py +0 -0
  34. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_session_remote.py +0 -0
  35. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_smart_install.py +0 -0
  36. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/tests/test_snapshots.py +0 -0
  37. {optio_opencode-0.1.8 → optio_opencode-0.2.0}/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.8
3
+ Version: 0.2.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.3,>=0.2
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.2,>=0.1
25
+ Requires-Dist: optio-agents<0.3,>=0.2
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.1.8"
7
+ version = "0.2.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.1,<0.2",
33
+ "optio-agents>=0.2,<0.3",
34
34
  "asyncssh>=2.14",
35
35
  ]
36
36
 
@@ -81,6 +81,9 @@ the situation as a resume:
81
81
 
82
82
  If a resume slips past unnoticed, a failing tool call is the
83
83
  next-best signal — re-check `./resume.log` then.
84
+
85
+ You may also be notified of a resume by a `System:` message on your input
86
+ channel; when you see one, follow the `resume.log` procedure above.
84
87
  """
85
88
 
86
89
 
@@ -31,6 +31,7 @@ from optio_core.context import ProcessContext
31
31
  from optio_core.models import BasicAuth, TaskInstance
32
32
 
33
33
  from optio_agents import HookContext
34
+ from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
34
35
  from optio_host.host import Host, LocalHost, ProcessHandle, RemoteHost
35
36
  from optio_host.paths import task_dir
36
37
  from optio_agents.protocol.session import _SessionFailed, run_log_protocol_session
@@ -327,6 +328,13 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
327
328
  await _post_opencode_prompt(
328
329
  worker_port, password, session_id, AUTO_START_PROMPT,
329
330
  )
331
+ elif resuming and config.supports_resume:
332
+ # Push notification: make the resumed agent NOTICE the resume
333
+ # promptly (resume.log remains the pull-based source of truth).
334
+ await _post_opencode_prompt(
335
+ worker_port, password, session_id,
336
+ f"{SYSTEM_MESSAGE_PREFIX}{RESUME_NOTICE}",
337
+ )
330
338
 
331
339
  # --- await opencode subprocess exit -----------------------------
332
340
  # The protocol driver runs this body alongside the tail dispatcher
@@ -343,6 +351,14 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
343
351
  # --- run the protocol session -----------------------------------------
344
352
  # host.connect() already happened up-front (before install + resume).
345
353
  session_error: BaseException | None = None
354
+
355
+ async def _agent_sender(message: str) -> None:
356
+ # worker_port / session_id are set by _opencode_body at launch;
357
+ # password is established at function scope. _post_opencode_prompt
358
+ # raises on a non-2xx / unreachable worker, which
359
+ # send_to_agent converts to False.
360
+ await _post_opencode_prompt(worker_port, password, session_id, message)
361
+
346
362
  try:
347
363
  # before_execute is wired manually inside _opencode_body (after
348
364
  # install, before launch) per opencode's documented timing.
@@ -355,6 +371,7 @@ async def run_opencode_session(ctx: ProcessContext, config: OpencodeTaskConfig)
355
371
  on_deliverable=config.on_deliverable,
356
372
  after_execute=config.after_execute,
357
373
  protocol=protocol,
374
+ agent_sender=_agent_sender,
358
375
  )
359
376
  except _SessionFailed as fail:
360
377
  session_error = fail
@@ -483,6 +500,24 @@ async def _capture_snapshot(
483
500
  opencode_executable: str = "opencode",
484
501
  session_blob_encrypt: "Callable[[bytes], bytes] | None" = None,
485
502
  ) -> None:
503
+ # Defense-in-depth (guard #2): refuse to capture a resumable snapshot
504
+ # unless opencode's auth.json exists and is non-empty on the host. A
505
+ # credential-less workdir is degenerate — restoring it would relaunch
506
+ # opencode with no auth, so marking it resumable is worse than useless.
507
+ # (Live-reach is already covered by the session_id is not None gate at
508
+ # the call site; this covers the bad/empty-seed edge.)
509
+ workdir = host.workdir.rstrip("/")
510
+ chk = await host.run_command(
511
+ f"test -s {shlex.quote(workdir)}/home/.local/share/opencode/auth.json "
512
+ f"&& echo OK || true"
513
+ )
514
+ if "OK" not in chk.stdout:
515
+ _LOG.warning(
516
+ "snapshot capture skipped: opencode auth.json absent/empty; "
517
+ "refusing to mark resumable"
518
+ )
519
+ return
520
+
486
521
  session_json = await host_actions.opencode_export(
487
522
  host, opencode_db, session_id,
488
523
  opencode_executable=opencode_executable,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.1.8
3
+ Version: 0.2.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.3,>=0.2
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.2,>=0.1
25
+ Requires-Dist: optio-agents<0.3,>=0.2
26
26
  Requires-Dist: asyncssh>=2.14
27
27
  Provides-Extra: dev
28
28
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -12,6 +12,7 @@ src/optio_opencode.egg-info/SOURCES.txt
12
12
  src/optio_opencode.egg-info/dependency_links.txt
13
13
  src/optio_opencode.egg-info/requires.txt
14
14
  src/optio_opencode.egg-info/top_level.txt
15
+ tests/test_agent_sender_opencode.py
15
16
  tests/test_host_actions.py
16
17
  tests/test_host_local.py
17
18
  tests/test_host_primitives_local.py
@@ -20,6 +21,7 @@ tests/test_host_remote_resume.py
20
21
  tests/test_host_resume.py
21
22
  tests/test_prompt.py
22
23
  tests/test_purge_seed.py
24
+ tests/test_resume_sentence_opencode.py
23
25
  tests/test_sanity.py
24
26
  tests/test_seed_config.py
25
27
  tests/test_session_blob_hooks.py
@@ -1,6 +1,6 @@
1
1
  optio-core<0.3,>=0.2
2
2
  optio-host<0.3,>=0.2
3
- optio-agents<0.2,>=0.1
3
+ optio-agents<0.3,>=0.2
4
4
  asyncssh>=2.14
5
5
 
6
6
  [dev]
@@ -0,0 +1,29 @@
1
+ import pytest
2
+
3
+ import optio_opencode.session as S
4
+ from optio_agents import RESUME_NOTICE, SYSTEM_MESSAGE_PREFIX
5
+
6
+
7
+ @pytest.mark.asyncio
8
+ async def test_post_prompt_signature_used_by_sender(monkeypatch):
9
+ """The opencode sender forwards (port, password, session_id, message) to
10
+ _post_opencode_prompt verbatim. Mirrors the closure built in
11
+ run_opencode_session."""
12
+ calls = []
13
+
14
+ async def fake_post(port, password, session_id, message):
15
+ calls.append((port, password, session_id, message))
16
+
17
+ monkeypatch.setattr(S, "_post_opencode_prompt", fake_post)
18
+
19
+ worker_port, password, session_id = 4321, "pw", "sess-1"
20
+
21
+ async def _agent_sender(message: str) -> None:
22
+ await S._post_opencode_prompt(worker_port, password, session_id, message)
23
+
24
+ await _agent_sender("hello")
25
+ assert calls == [(4321, "pw", "sess-1", "hello")]
26
+
27
+
28
+ def test_resume_notice_string():
29
+ assert f"{SYSTEM_MESSAGE_PREFIX}{RESUME_NOTICE}" == "System: you have been resumed"
@@ -0,0 +1,7 @@
1
+ from optio_opencode.prompt import _render_resume_section
2
+
3
+
4
+ def test_resume_section_mentions_system_notification():
5
+ section = _render_resume_section(None)
6
+ assert "`System:` message" in section
7
+ assert "resume.log" in section
@@ -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,
@@ -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.
@@ -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}"
@@ -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)
@@ -308,9 +308,10 @@ async def test_second_session_consumes_seed(
308
308
  async def test_auto_start_posts_on_fresh_and_not_on_resume(
309
309
  mongo_db, task_root, _supply_scenario, monkeypatch,
310
310
  ):
311
- """auto_start=True POSTs the kickoff prompt on a fresh launch and is
312
- suppressed on resume (the restored session already carries its
313
- conversation)."""
311
+ """auto_start=True POSTs the kickoff prompt on a fresh launch; on resume
312
+ the kickoff is suppressed (the restored session already carries its
313
+ conversation) but a System: resume notice is POSTed so the agent notices
314
+ the resume."""
314
315
  import optio_opencode.session as session_mod
315
316
 
316
317
  _supply_scenario["name"] = "happy"
@@ -330,19 +331,34 @@ async def test_auto_start_posts_on_fresh_and_not_on_resume(
330
331
  await run_opencode_session(ctx_fresh, OpencodeTaskConfig(
331
332
  consumer_instructions="(scenario: happy)",
332
333
  auto_start=True,
334
+ # Plant auth.json so the snapshot-capture defense-in-depth guard does
335
+ # not refuse to mark this resumable; otherwise the resume leg would
336
+ # fall back to a fresh launch and POST the kickoff prompt again.
337
+ before_execute=_plant_env,
333
338
  ))
334
- assert len(posts) == 1
335
- posted_session_id, posted_message = posts[0]
336
- assert posted_message == session_mod.AUTO_START_PROMPT
337
- assert posted_session_id # a real (pre-created) session id
338
-
339
- # resume the same process must NOT POST again
339
+ # The 'happy' scenario also emits a DELIVERABLE, which the mandatory
340
+ # acknowledgment POSTs as a "deliverable ...: accepted" ack via the same
341
+ # _post_opencode_prompt. This test is about the kickoff / resume-notice
342
+ # posts, so filter those out.
343
+ notice = f"{session_mod.SYSTEM_MESSAGE_PREFIX}{session_mod.RESUME_NOTICE}"
344
+ kickoffs = [m for _sid, m in posts if m == session_mod.AUTO_START_PROMPT]
345
+ notices = [m for _sid, m in posts if m == notice]
346
+ assert kickoffs == [session_mod.AUTO_START_PROMPT] # kickoff posted exactly once
347
+ assert notices == [] # no resume notice on fresh
348
+ assert all(sid for sid, _m in posts) # real (pre-created) session ids
349
+
350
+ # resume the same process → the kickoff is suppressed, but a System:
351
+ # resume notice is POSTed so the agent notices the resume.
340
352
  ctx_resume = await _make_ctx(mongo_db, pid, resume=True)
341
353
  await run_opencode_session(ctx_resume, OpencodeTaskConfig(
342
354
  consumer_instructions="(scenario: happy)",
343
355
  auto_start=True,
356
+ before_execute=_plant_env,
344
357
  ))
345
- assert len(posts) == 1, posts
358
+ kickoffs = [m for _sid, m in posts if m == session_mod.AUTO_START_PROMPT]
359
+ notices = [m for _sid, m in posts if m == notice]
360
+ assert kickoffs == [session_mod.AUTO_START_PROMPT] # no NEW kickoff on resume
361
+ assert notices == [notice] # resume notice posted once
346
362
 
347
363
 
348
364
  def test_post_opencode_prompt_uses_prompt_async_parts_body(monkeypatch):
File without changes
File without changes