optio-opencode 0.2.1__tar.gz → 0.2.2__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 (51) hide show
  1. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/PKG-INFO +2 -1
  2. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/pyproject.toml +2 -1
  3. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/__init__.py +10 -0
  4. optio_opencode-0.2.2/src/optio_opencode/conversation.py +357 -0
  5. optio_opencode-0.2.2/src/optio_opencode/cred_watcher.py +129 -0
  6. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/host_actions.py +84 -16
  7. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/prompt.py +43 -6
  8. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/seed_manifest.py +12 -0
  9. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/session.py +250 -70
  10. optio_opencode-0.2.2/src/optio_opencode/types.py +182 -0
  11. optio_opencode-0.2.2/src/optio_opencode/verify.py +153 -0
  12. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/PKG-INFO +2 -1
  13. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/SOURCES.txt +14 -1
  14. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/requires.txt +1 -0
  15. optio_opencode-0.2.2/tests/test_conversation_config.py +49 -0
  16. optio_opencode-0.2.2/tests/test_conversation_driver.py +295 -0
  17. optio_opencode-0.2.2/tests/test_conversation_session.py +243 -0
  18. optio_opencode-0.2.2/tests/test_conversation_ui_model.py +77 -0
  19. optio_opencode-0.2.2/tests/test_conversation_ui_session.py +206 -0
  20. optio_opencode-0.2.2/tests/test_cred_watcher.py +185 -0
  21. optio_opencode-0.2.2/tests/test_file_download.py +80 -0
  22. optio_opencode-0.2.2/tests/test_file_upload.py +24 -0
  23. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_prompt.py +38 -0
  24. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_hooks.py +1 -1
  25. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_local.py +5 -6
  26. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_remote.py +1 -1
  27. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_resume.py +94 -1
  28. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_seed.py +8 -4
  29. optio_opencode-0.2.2/tests/test_session_seed_saveback.py +214 -0
  30. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_smart_install.py +16 -7
  31. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_types.py +15 -2
  32. optio_opencode-0.2.2/tests/test_verify_seed.py +165 -0
  33. optio_opencode-0.2.1/src/optio_opencode/types.py +0 -80
  34. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/README.md +0 -0
  35. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/setup.cfg +0 -0
  36. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/snapshots.py +0 -0
  37. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  38. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/top_level.txt +0 -0
  39. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_agent_sender_opencode.py +0 -0
  40. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_actions.py +0 -0
  41. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_local.py +0 -0
  42. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_primitives_local.py +0 -0
  43. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_primitives_remote.py +0 -0
  44. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_remote_resume.py +0 -0
  45. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_resume.py +0 -0
  46. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_purge_seed.py +0 -0
  47. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_resume_sentence_opencode.py +0 -0
  48. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_sanity.py +0 -0
  49. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_seed_config.py +0 -0
  50. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_blob_hooks.py +0 -0
  51. {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_snapshots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optio-opencode
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -24,6 +24,7 @@ Requires-Dist: optio-core<0.3,>=0.2
24
24
  Requires-Dist: optio-host<0.3,>=0.2
25
25
  Requires-Dist: optio-agents<0.3,>=0.2
26
26
  Requires-Dist: asyncssh>=2.14
27
+ Requires-Dist: aiohttp>=3.9
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest>=8.0; extra == "dev"
29
30
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optio-opencode"
7
- version = "0.2.1"
7
+ version = "0.2.2"
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"
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "optio-host>=0.2,<0.3",
33
33
  "optio-agents>=0.2,<0.3",
34
34
  "asyncssh>=2.14",
35
+ "aiohttp>=3.9",
35
36
  ]
36
37
 
37
38
  [project.optional-dependencies]
@@ -10,17 +10,22 @@ from optio_host import (
10
10
  )
11
11
  from optio_opencode.session import create_opencode_task, run_opencode_session
12
12
  from optio_opencode.types import (
13
+ ConversationMode,
13
14
  DeliverableCallback,
14
15
  HookCallback,
15
16
  OpencodeTaskConfig,
17
+ SeedProvider,
18
+ ToolVerbosity,
16
19
  )
17
20
  from optio_opencode.seed_manifest import (
21
+ OPENCODE_CRED_MANIFEST,
18
22
  OPENCODE_SEED_MANIFEST,
19
23
  OPENCODE_SEED_SUFFIX,
20
24
  delete_seed,
21
25
  list_seeds,
22
26
  purge_seed,
23
27
  )
28
+ from optio_opencode.verify import verify_and_refresh_seed
24
29
 
25
30
  # asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
26
31
  # connection...", "Received channel close", etc.) that flood the worker's
@@ -46,4 +51,9 @@ __all__ = [
46
51
  "delete_seed",
47
52
  "list_seeds",
48
53
  "purge_seed",
54
+ "OPENCODE_CRED_MANIFEST",
55
+ "SeedProvider",
56
+ "ConversationMode",
57
+ "ToolVerbosity",
58
+ "verify_and_refresh_seed",
49
59
  ]
@@ -0,0 +1,357 @@
1
+ """OpencodeConversation — engine-side driver for one opencode session over
2
+ the spawned server's native HTTP+SSE API.
3
+
4
+ The session body launches the opencode server (``launch_opencode``),
5
+ pre-creates a session, constructs this object with the same
6
+ ``(worker_port, password, session_id)`` it already produces, publishes it via
7
+ ``ctx.publish_result``, and runs ``run_reader()`` until teardown.
8
+
9
+ Live events come from ``GET /global/event`` — the per-instance
10
+ ``/event?directory=…`` endpoint closes immediately after ``server.connected``
11
+ on the shipped server (verified empirically against 1.14.45, Task 8 fixtures).
12
+ Each ``/global/event`` frame wraps the event as
13
+ ``{"directory"?, "project"?, "payload": {"id", "type", "properties"}}``
14
+ (``server.connected``/``server.heartbeat`` carry no ``directory``); the driver
15
+ drops frames for other directories and fans the unwrapped payload out to
16
+ ``on_event`` subscribers as a dict, unmodified (``{"id", "type",
17
+ "properties"}``). Synthetic events use the ``x-optio-`` type prefix.
18
+ Permission requests are event-driven (``permission.asked``) with a
19
+ list-endpoint sweep on every SSE (re)connect, so requests that fired during a
20
+ stream gap are never lost.
21
+
22
+ See docs/2026-06-11-opencode-conversation-mode-design.md.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import logging
30
+ import os
31
+
32
+ import aiohttp
33
+
34
+ from optio_agents.conversation import (
35
+ ConversationClosed,
36
+ PermissionDecision,
37
+ PermissionRequest,
38
+ )
39
+
40
+ _LOG = logging.getLogger(__name__)
41
+
42
+ # Reconnect backoff for the SSE reader (capped; the session body cancels the
43
+ # reader at teardown, so there is no give-up path while the task is alive).
44
+ _RECONNECT_DELAYS = (0.2, 0.5, 1.0, 2.0, 5.0)
45
+
46
+
47
+ class OpencodeConversation:
48
+ """Implements optio_agents.conversation.Conversation for opencode."""
49
+
50
+ def __init__(
51
+ self, *, port: int, password: str, session_id: str, directory: str,
52
+ ) -> None:
53
+ self._base = f"http://127.0.0.1:{port}"
54
+ self._auth = aiohttp.BasicAuth("opencode", password)
55
+ self._session_id = session_id
56
+ self._directory = directory
57
+ # The server resolves instance directories (symlinks etc.) before
58
+ # stamping them onto /global/event frames; compare against realpath too.
59
+ self._directory_real = os.path.realpath(directory)
60
+ self._pending = False
61
+ self._closed = asyncio.Event()
62
+ self._close_reason: str | None = None
63
+ # Cooperative-shutdown request towards the owning task body.
64
+ self.close_requested = asyncio.Event()
65
+ self._event_queue: asyncio.Queue[dict] = asyncio.Queue()
66
+ self._event_handlers: list = []
67
+ self._message_handlers: list = []
68
+ self._permission_handler = None
69
+ self._queued_permission_requests: list[dict] = []
70
+ self._answered_permissions: set[str] = set()
71
+ # Text parts of the in-flight assistant message, keyed by part id —
72
+ # joined and fired via on_message when the message completes.
73
+ self._part_texts: dict[str, dict[str, str]] = {}
74
+ self._dispatcher_task: asyncio.Task | None = None
75
+ self._http: aiohttp.ClientSession | None = None
76
+
77
+ # -- wiring ------------------------------------------------------------
78
+
79
+ async def run_reader(self) -> None:
80
+ """Connect to /global/event and dispatch frames until cancelled (by
81
+ the session body at teardown) or closed. Reconnects with backoff."""
82
+ self._dispatcher_task = asyncio.create_task(self._dispatch_loop())
83
+ self._http = aiohttp.ClientSession(auth=self._auth)
84
+ attempt = 0
85
+ try:
86
+ while not self._closed.is_set():
87
+ try:
88
+ await self._consume_sse()
89
+ attempt = 0 # clean EOF: server still alive, reconnect fresh
90
+ except (aiohttp.ClientError, ConnectionError, asyncio.TimeoutError) as exc:
91
+ _LOG.info("conversation: SSE drop (%s); reconnecting", exc)
92
+ delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
93
+ attempt += 1
94
+ await asyncio.sleep(delay)
95
+ finally:
96
+ await self._finish("process ended")
97
+ await self._http.close()
98
+
99
+ def _url(self, path: str) -> str:
100
+ return f"{self._base}{path}"
101
+
102
+ def _params(self) -> dict:
103
+ return {"directory": self._directory}
104
+
105
+ async def _consume_sse(self) -> None:
106
+ timeout = aiohttp.ClientTimeout(total=None, sock_connect=10)
107
+ # /global/event, not /event?directory=…: the per-instance endpoint
108
+ # ends its stream right after server.connected (observed on 1.14.45),
109
+ # so we take the global firehose and filter by directory ourselves.
110
+ async with self._http.get(
111
+ self._url("/global/event"), timeout=timeout,
112
+ ) as resp:
113
+ resp.raise_for_status()
114
+ # A (re)connect can postdate permission.asked events we never saw.
115
+ await self._sweep_permissions()
116
+ data_lines: list[str] = []
117
+ async for raw in resp.content:
118
+ line = raw.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
119
+ if line.startswith("data:"):
120
+ data_lines.append(line[5:].lstrip())
121
+ continue
122
+ if line == "" and data_lines:
123
+ payload = "\n".join(data_lines)
124
+ data_lines = []
125
+ try:
126
+ obj = json.loads(payload)
127
+ except ValueError:
128
+ _LOG.warning("conversation: unparseable SSE data: %.200s", payload)
129
+ self._event_queue.put_nowait(
130
+ {"type": "x-optio-unparseable", "line": payload},
131
+ )
132
+ continue
133
+ self._route_frame(obj)
134
+
135
+ # -- event routing -------------------------------------------------------
136
+
137
+ def _route_frame(self, obj: dict) -> None:
138
+ """Unwrap one /global/event frame: drop other directories' events,
139
+ route the inner ``{"id", "type", "properties"}`` payload. Bare
140
+ (unwrapped) frames are routed as-is for fake/forward compatibility."""
141
+ frame_dir = obj.get("directory")
142
+ if (
143
+ frame_dir is not None
144
+ and frame_dir != self._directory
145
+ and os.path.realpath(frame_dir) != self._directory_real
146
+ ):
147
+ return
148
+ payload = obj.get("payload")
149
+ self._route(payload if isinstance(payload, dict) else obj)
150
+
151
+ def _for_this_session(self, props: dict) -> bool:
152
+ sid = (
153
+ props.get("sessionID")
154
+ or (props.get("info") or {}).get("sessionID")
155
+ or (props.get("part") or {}).get("sessionID")
156
+ )
157
+ return sid is None or sid == self._session_id
158
+
159
+ def _route(self, obj: dict) -> None:
160
+ t = obj.get("type") or ""
161
+ props = obj.get("properties") or {}
162
+ if t == "permission.asked" and self._for_this_session(props):
163
+ self._on_permission_asked(props)
164
+ elif t == "message.part.updated":
165
+ part = props.get("part") or {}
166
+ if part.get("type") == "text" and self._for_this_session(props):
167
+ mid, pid = str(part.get("messageID")), str(part.get("id"))
168
+ self._part_texts.setdefault(mid, {})[pid] = part.get("text") or ""
169
+ elif t == "message.updated":
170
+ info = props.get("info") or {}
171
+ if (
172
+ info.get("role") == "assistant"
173
+ and (info.get("time") or {}).get("completed")
174
+ and self._for_this_session(props)
175
+ ):
176
+ parts = self._part_texts.pop(str(info.get("id")), {})
177
+ if parts:
178
+ self._fire_message("\n\n".join(parts.values()))
179
+ elif t in ("session.status", "session.idle") and self._for_this_session(props):
180
+ status = props.get("status") or {}
181
+ if t == "session.idle" or status.get("type") == "idle":
182
+ self._pending = False
183
+ elif status.get("type") == "busy":
184
+ self._pending = True
185
+ self._event_queue.put_nowait(obj)
186
+
187
+ async def _dispatch_loop(self) -> None:
188
+ while True:
189
+ obj = await self._event_queue.get()
190
+ for handler in list(self._event_handlers):
191
+ await self._call_handler(handler, obj, "on_event")
192
+
193
+ async def _call_handler(self, handler, arg, label: str) -> None:
194
+ try:
195
+ result = handler(arg)
196
+ if asyncio.iscoroutine(result):
197
+ await result
198
+ except Exception: # noqa: BLE001 — subscriber bugs never kill the driver
199
+ _LOG.exception("conversation: %s handler raised", label)
200
+
201
+ def _fire_message(self, text: str) -> None:
202
+ for handler in list(self._message_handlers):
203
+ asyncio.ensure_future(self._call_handler(handler, text, "on_message"))
204
+
205
+ # -- permission gate -------------------------------------------------------
206
+
207
+ def _on_permission_asked(self, props: dict) -> None:
208
+ rid = str(props.get("id") or "")
209
+ if not rid or rid in self._answered_permissions:
210
+ return
211
+ if self._permission_handler is None:
212
+ # Queue until a handler is registered: opencode blocks the session
213
+ # on the unanswered ask, which closes the publish/registration
214
+ # race. Documented caller contract: register promptly.
215
+ self._queued_permission_requests.append(props)
216
+ return
217
+ asyncio.ensure_future(self._answer_permission(props))
218
+
219
+ async def _sweep_permissions(self) -> None:
220
+ """Fetch pending permission requests and feed unanswered ones for our
221
+ session to the gate. Gap-safety: covers asks fired while the SSE
222
+ stream was down (opencode's /global/event has no server-side replay)."""
223
+ try:
224
+ async with self._http.get(
225
+ self._url("/permission"), params=self._params(),
226
+ ) as resp:
227
+ resp.raise_for_status()
228
+ pending = await resp.json()
229
+ except (aiohttp.ClientError, ConnectionError, ValueError) as exc:
230
+ _LOG.warning("conversation: permission sweep failed: %s", exc)
231
+ return
232
+ for props in pending:
233
+ if props.get("sessionID") in (None, self._session_id):
234
+ self._on_permission_asked(props)
235
+
236
+ async def _answer_permission(self, props: dict) -> None:
237
+ rid = str(props.get("id"))
238
+ if rid in self._answered_permissions:
239
+ return
240
+ self._answered_permissions.add(rid)
241
+ request = PermissionRequest(
242
+ tool_name=str(props.get("permission") or ""),
243
+ input=props.get("metadata") or {},
244
+ raw=props,
245
+ )
246
+ try:
247
+ decision = await self._permission_handler(request)
248
+ except Exception: # noqa: BLE001
249
+ _LOG.exception("conversation: permission handler raised; denying")
250
+ decision = PermissionDecision(
251
+ behavior="deny", message="optio harness: permission handler failed",
252
+ )
253
+ # opencode reply vocabulary: allow → "once" (never "always": the optio
254
+ # gate decides per request); deny → "reject". updated_input has no
255
+ # opencode equivalent and is ignored.
256
+ body: dict = (
257
+ {"reply": "once"} if decision.behavior == "allow"
258
+ else {"reply": "reject", "message": decision.message or "Denied by the operator."}
259
+ )
260
+ try:
261
+ async with self._http.post(
262
+ self._url(f"/permission/{rid}/reply"),
263
+ params=self._params(), json=body,
264
+ ) as resp:
265
+ if resp.status >= 400:
266
+ _LOG.warning(
267
+ "conversation: permission reply %s → HTTP %s "
268
+ "(likely already answered elsewhere)", rid, resp.status,
269
+ )
270
+ except (aiohttp.ClientError, ConnectionError) as exc:
271
+ _LOG.warning("conversation: permission reply failed: %s", exc)
272
+ self._event_queue.put_nowait({
273
+ "type": "x-optio-permission-answered",
274
+ "request_id": rid,
275
+ "behavior": decision.behavior,
276
+ })
277
+
278
+ # -- Conversation protocol surface --------------------------------------
279
+
280
+ async def send(self, text: str) -> None:
281
+ if self._closed.is_set():
282
+ raise ConversationClosed(self._close_reason or "conversation closed")
283
+ self._pending = True
284
+ try:
285
+ async with self._http.post(
286
+ self._url(f"/session/{self._session_id}/prompt_async"),
287
+ params=self._params(),
288
+ json={"parts": [{"type": "text", "text": text}]},
289
+ ) as resp:
290
+ resp.raise_for_status()
291
+ except (aiohttp.ClientError, ConnectionError) as exc:
292
+ self._pending = False
293
+ raise ConversationClosed(f"send failed: {exc}") from exc
294
+
295
+ def on_event(self, handler):
296
+ self._event_handlers.append(handler)
297
+ return lambda: self._event_handlers.remove(handler)
298
+
299
+ def on_message(self, handler):
300
+ self._message_handlers.append(handler)
301
+ return lambda: self._message_handlers.remove(handler)
302
+
303
+ def on_permission_request(self, handler):
304
+ self._permission_handler = handler
305
+ queued, self._queued_permission_requests = (
306
+ self._queued_permission_requests, [],
307
+ )
308
+ for props in queued:
309
+ asyncio.ensure_future(self._answer_permission(props))
310
+
311
+ def _unsub() -> None:
312
+ if self._permission_handler is handler:
313
+ self._permission_handler = None
314
+ return _unsub
315
+
316
+ def is_pending(self) -> bool:
317
+ return self._pending
318
+
319
+ async def interrupt(self) -> None:
320
+ if self._closed.is_set():
321
+ raise ConversationClosed(self._close_reason or "conversation closed")
322
+ if not self._pending:
323
+ return
324
+ async with self._http.post(
325
+ self._url(f"/session/{self._session_id}/abort"),
326
+ params=self._params(), json={},
327
+ ) as resp:
328
+ resp.raise_for_status()
329
+
330
+ async def close(self) -> None:
331
+ self.close_requested.set()
332
+
333
+ @property
334
+ def closed(self) -> bool:
335
+ return self._closed.is_set()
336
+
337
+ # -- internals -----------------------------------------------------------
338
+
339
+ async def _finish(self, reason: str) -> None:
340
+ if self._closed.is_set():
341
+ return
342
+ self._closed.set()
343
+ self._close_reason = reason
344
+ self._event_queue.put_nowait({"type": "x-optio-closed", "reason": reason})
345
+ # Stop the dispatcher, then drain whatever it left in the queue so
346
+ # subscribers are guaranteed to see the final x-optio-closed event.
347
+ if self._dispatcher_task is not None:
348
+ self._dispatcher_task.cancel()
349
+ try:
350
+ await self._dispatcher_task
351
+ except asyncio.CancelledError:
352
+ pass
353
+ self._dispatcher_task = None
354
+ while not self._event_queue.empty():
355
+ obj = self._event_queue.get_nowait()
356
+ for handler in list(self._event_handlers):
357
+ await self._call_handler(handler, obj, "on_event")
@@ -0,0 +1,129 @@
1
+ """In-session credential save-back for opencode seeds.
2
+
3
+ OAuth providers with rotating refresh tokens (xAI, OpenAI/Codex) make
4
+ refresh tokens single-use: opencode's plugin loader() refreshes a token on
5
+ use, the provider rotates the refresh token, and opencode persists the
6
+ rotated pair to auth.json (best-effort). This watcher keeps the seed
7
+ current by writing the changed in-session auth.json back into the existing
8
+ seed, plus a final backstop at teardown. Provider-agnostic: opencode does
9
+ the refreshing; the watcher only persists the file.
10
+
11
+ The seed is the single source of truth for credentials; see
12
+ docs/2026-06-11-opencode-seed-save-back-design.md.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ from typing import Callable
22
+
23
+ from optio_agents import seeds
24
+ from optio_host.host import Host
25
+
26
+ from optio_opencode.seed_manifest import OPENCODE_CRED_MANIFEST, OPENCODE_SEED_SUFFIX
27
+
28
+ _LOG = logging.getLogger(__name__)
29
+
30
+ CRED_WATCH_INTERVAL_S = 10.0
31
+ _CRED_RELPATH = "home/.local/share/opencode/auth.json"
32
+ _MODEL_RELPATH = "home/.config/opencode/opencode.json"
33
+
34
+
35
+ async def cred_fingerprint(host: Host) -> str | None:
36
+ """SHA-256 of the live auth.json, or None when it is missing,
37
+ unparseable, or carries no provider entry (i.e. nothing worth saving
38
+ back). The multi-provider analog of claudecode's refresh-token gate —
39
+ guards against corrupting a seed with a half-written/logged-out file."""
40
+ path = f"{host.workdir.rstrip('/')}/{_CRED_RELPATH}"
41
+ try:
42
+ raw = await host.fetch_bytes_from_host(path)
43
+ except FileNotFoundError:
44
+ return None
45
+ try:
46
+ data = json.loads(raw.decode("utf-8"))
47
+ except (ValueError, UnicodeDecodeError):
48
+ return None
49
+ if not isinstance(data, dict) or not data:
50
+ return None
51
+ return hashlib.sha256(raw).hexdigest()
52
+
53
+
54
+ async def capture_gate_ok(host: Host) -> bool:
55
+ """Stricter gate for seed CAPTURE: valid auth.json (cred_fingerprint)
56
+ AND a non-empty `model` in the live opencode.json. A model-less seed is
57
+ unusable — a consuming task gets no default and verify has nothing to
58
+ probe. Save-back deliberately does NOT use this gate: save-back only
59
+ replaces auth.json (the seed's opencode.json is untouched), and blocking
60
+ it over an unrelated field would drop a rotated refresh token."""
61
+ if await cred_fingerprint(host) is None:
62
+ return False
63
+ path = f"{host.workdir.rstrip('/')}/{_MODEL_RELPATH}"
64
+ try:
65
+ raw = await host.fetch_bytes_from_host(path)
66
+ cfg = json.loads(raw.decode("utf-8"))
67
+ except (FileNotFoundError, ValueError, UnicodeDecodeError):
68
+ return False
69
+ return isinstance(cfg, dict) and bool(cfg.get("model"))
70
+
71
+
72
+ async def save_back_if_changed(
73
+ ctx,
74
+ host: Host,
75
+ *,
76
+ seed_id: str,
77
+ baseline: str | None,
78
+ encrypt: "Callable[[bytes], bytes] | None",
79
+ decrypt: "Callable[[bytes], bytes] | None",
80
+ ) -> str | None:
81
+ """If the live auth.json differs from `baseline` and is valid, save it
82
+ back into the seed and return the new fingerprint. Otherwise return
83
+ `baseline` unchanged. Never raises — save-back is best-effort."""
84
+ fp = await cred_fingerprint(host)
85
+ if fp is None or fp == baseline:
86
+ return baseline
87
+ try:
88
+ await seeds.refresh_seed(
89
+ ctx, host, seed_id=seed_id, manifest=OPENCODE_CRED_MANIFEST,
90
+ suffix=OPENCODE_SEED_SUFFIX, encrypt=encrypt, decrypt=decrypt,
91
+ )
92
+ _LOG.info("seed %s: auth.json saved back", seed_id)
93
+ return fp
94
+ except Exception:
95
+ _LOG.exception("seed %s: auth.json save-back failed", seed_id)
96
+ return baseline
97
+
98
+
99
+ async def run_credential_watcher(
100
+ ctx,
101
+ host: Host,
102
+ *,
103
+ seed_id: str,
104
+ baseline: str | None,
105
+ encrypt: "Callable[[bytes], bytes] | None",
106
+ decrypt: "Callable[[bytes], bytes] | None",
107
+ lease_holder: str | None = None,
108
+ ) -> None:
109
+ """Poll every CRED_WATCH_INTERVAL_S: save back rotated auth.json, and
110
+ (when `lease_holder` is set) renew the seed's lease. If the lease is
111
+ lost, signal the session to stop (set the cancellation flag) and exit —
112
+ continuing would mean a token-rotation collision with the new holder.
113
+ Runs until cancelled. Best-effort save-back; lease-loss is decisive."""
114
+ current = baseline
115
+ while True:
116
+ await asyncio.sleep(CRED_WATCH_INTERVAL_S)
117
+ current = await save_back_if_changed(
118
+ ctx, host, seed_id=seed_id, baseline=current,
119
+ encrypt=encrypt, decrypt=decrypt,
120
+ )
121
+ if lease_holder is not None:
122
+ ok = await seeds.renew_lease(
123
+ ctx._db, prefix=ctx._prefix, suffix=OPENCODE_SEED_SUFFIX,
124
+ seed_id=seed_id, holder=lease_holder,
125
+ )
126
+ if not ok:
127
+ _LOG.warning("seed %s: lease lost; aborting session", seed_id)
128
+ ctx.cancellation_flag.set()
129
+ return