optio-opencode 0.2.0__py3-none-any.whl → 0.2.2__py3-none-any.whl
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/__init__.py +10 -0
- optio_opencode/conversation.py +357 -0
- optio_opencode/cred_watcher.py +129 -0
- optio_opencode/host_actions.py +84 -16
- optio_opencode/prompt.py +43 -6
- optio_opencode/seed_manifest.py +12 -0
- optio_opencode/session.py +315 -132
- optio_opencode/types.py +113 -11
- optio_opencode/verify.py +153 -0
- {optio_opencode-0.2.0.dist-info → optio_opencode-0.2.2.dist-info}/METADATA +2 -1
- optio_opencode-0.2.2.dist-info/RECORD +14 -0
- optio_opencode-0.2.0.dist-info/RECORD +0 -11
- {optio_opencode-0.2.0.dist-info → optio_opencode-0.2.2.dist-info}/WHEEL +0 -0
- {optio_opencode-0.2.0.dist-info → optio_opencode-0.2.2.dist-info}/top_level.txt +0 -0
optio_opencode/__init__.py
CHANGED
|
@@ -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
|
optio_opencode/host_actions.py
CHANGED
|
@@ -145,14 +145,20 @@ async def _smart_install_check(
|
|
|
145
145
|
|
|
146
146
|
|
|
147
147
|
async def _install_opencode_from_zip(
|
|
148
|
-
|
|
148
|
+
host: "Host",
|
|
149
|
+
download: "Callable[[str, str], Awaitable[None]]",
|
|
150
|
+
url: str,
|
|
151
|
+
*,
|
|
152
|
+
install_dir: str | None = None,
|
|
149
153
|
) -> str:
|
|
150
154
|
"""Download the opencode release archive from ``url`` and install it.
|
|
151
155
|
|
|
152
156
|
Uniform for LocalHost and RemoteHost:
|
|
153
157
|
1. mktemp -d on the host.
|
|
154
|
-
2. ``
|
|
155
|
-
child download task —
|
|
158
|
+
2. ``download(url, <tmpdir>/opencode.zip)`` (engine callers pass
|
|
159
|
+
``hook_ctx.download_file``, which spawns the child download task —
|
|
160
|
+
emits its own progress on the child ctx; engine-less callers pass
|
|
161
|
+
``curl_downloader(host)``).
|
|
156
162
|
3. unzip on the host (archive layout: ``bin/opencode`` + sidecars).
|
|
157
163
|
4. mkdir -p ``install_dir``; move binary there; chmod +x.
|
|
158
164
|
5. Remove the tempdir.
|
|
@@ -162,7 +168,6 @@ async def _install_opencode_from_zip(
|
|
|
162
168
|
|
|
163
169
|
Returns the absolute install path on the host.
|
|
164
170
|
"""
|
|
165
|
-
host = hook_ctx._host
|
|
166
171
|
resolved_install_dir = await _resolve_install_dir(host, install_dir)
|
|
167
172
|
r = await host.run_command("mktemp -d -t optio-opencode-XXXXXX")
|
|
168
173
|
if r.exit_code != 0:
|
|
@@ -172,7 +177,7 @@ async def _install_opencode_from_zip(
|
|
|
172
177
|
tmpdir = r.stdout.strip()
|
|
173
178
|
zip_path = f"{tmpdir}/opencode.zip"
|
|
174
179
|
try:
|
|
175
|
-
await
|
|
180
|
+
await download(url, zip_path)
|
|
176
181
|
|
|
177
182
|
r = await host.run_command(
|
|
178
183
|
f"unzip -o -q {shlex.quote(zip_path)} -d {shlex.quote(tmpdir)}"
|
|
@@ -211,20 +216,23 @@ async def _install_opencode_from_zip(
|
|
|
211
216
|
|
|
212
217
|
|
|
213
218
|
async def ensure_opencode_installed(
|
|
214
|
-
|
|
215
|
-
install_if_missing: bool = True,
|
|
219
|
+
host: "Host",
|
|
216
220
|
*,
|
|
221
|
+
download: "Callable[[str, str], Awaitable[None]]",
|
|
222
|
+
report_progress: "Callable | None" = None,
|
|
223
|
+
install_if_missing: bool = True,
|
|
217
224
|
install_dir: str | None = None,
|
|
218
225
|
) -> str:
|
|
219
|
-
"""Ensure opencode is available on
|
|
226
|
+
"""Ensure opencode is available on ``host``.
|
|
220
227
|
|
|
221
228
|
Uniform local + remote: runs the upstream smart-install.sh in
|
|
222
229
|
``--check`` mode via ``host.run_command``. If the host already has the
|
|
223
230
|
latest opencode, returns the absolute path that ``command -v opencode``
|
|
224
|
-
resolves to. Otherwise — when ``install_if_missing`` is True —
|
|
225
|
-
the release zip
|
|
226
|
-
|
|
227
|
-
|
|
231
|
+
resolves to. Otherwise — when ``install_if_missing`` is True — fetches
|
|
232
|
+
the release zip via ``download`` (engine callers pass
|
|
233
|
+
``hook_ctx.download_file``, so progress shows up in the UI as an optio
|
|
234
|
+
child task; engine-less callers pass ``curl_downloader(host)``),
|
|
235
|
+
unpacks it, and installs the binary at ``<install_dir>/opencode``.
|
|
228
236
|
|
|
229
237
|
``install_dir`` is the absolute path of the directory that holds (or
|
|
230
238
|
will hold) the ``opencode`` binary on the host. When None (default),
|
|
@@ -235,17 +243,23 @@ async def ensure_opencode_installed(
|
|
|
235
243
|
smart-install's PATH lookup, and for the post-"ok" ``command -v``
|
|
236
244
|
resolution, so all three stay in agreement.
|
|
237
245
|
|
|
246
|
+
INVARIANT: install-dir resolution (_resolve_install_dir) runs against the
|
|
247
|
+
host's REAL environment, never under _isolation_env. If the per-task
|
|
248
|
+
isolation env leaked in, XDG_CACHE_HOME would point inside the (possibly
|
|
249
|
+
throwaway) workdir: the binary would re-download per run and be deleted
|
|
250
|
+
at teardown. The shared worker cache must stay outside every workdir.
|
|
251
|
+
|
|
238
252
|
Returns the absolute path of the opencode binary on the host.
|
|
239
253
|
|
|
240
254
|
Raises RuntimeError when the check is unparseable, when an install is
|
|
241
255
|
needed but ``install_if_missing`` is False, or when any sub-step fails.
|
|
242
256
|
"""
|
|
243
|
-
host = hook_ctx._host
|
|
244
257
|
resolved_install_dir = await _resolve_install_dir(host, install_dir)
|
|
245
258
|
# Mark the parent task indeterminate-active before any host I/O so the
|
|
246
259
|
# dashboard shows it working rather than stuck at 0% while the install
|
|
247
260
|
# check (and any subsequent download child task) runs.
|
|
248
|
-
|
|
261
|
+
if report_progress is not None:
|
|
262
|
+
report_progress(None, "Checking opencode installation…")
|
|
249
263
|
kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
|
|
250
264
|
if kind == "ok":
|
|
251
265
|
# Resolve the on-PATH path. Login shell so ``$HOME``-relative
|
|
@@ -271,10 +285,64 @@ async def ensure_opencode_installed(
|
|
|
271
285
|
"install_if_missing=False was requested."
|
|
272
286
|
)
|
|
273
287
|
assert url is not None # _smart_install_check guarantees
|
|
274
|
-
|
|
288
|
+
if report_progress is not None:
|
|
289
|
+
report_progress(None, "Installing opencode…")
|
|
275
290
|
return await _install_opencode_from_zip(
|
|
276
|
-
|
|
291
|
+
host, download, url, install_dir=resolved_install_dir,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def curl_downloader(host: "Host") -> "Callable[[str, str], Awaitable[None]]":
|
|
296
|
+
"""Context-free downloader for engine-less callers (verify): fetch a URL
|
|
297
|
+
to a host path via curl on the host itself, vs the engine's child-task
|
|
298
|
+
download_file."""
|
|
299
|
+
async def download(url: str, dest: str) -> None:
|
|
300
|
+
r = await host.run_command(
|
|
301
|
+
f"curl -fsSL {shlex.quote(url)} -o {shlex.quote(dest)}"
|
|
302
|
+
)
|
|
303
|
+
if r.exit_code != 0:
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
f"curl download failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
|
|
306
|
+
)
|
|
307
|
+
return download
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def build_host(ssh, taskdir: str) -> "Host":
|
|
311
|
+
"""ssh_config + taskdir -> LocalHost/RemoteHost. Lifted from
|
|
312
|
+
session._build_host so engine-less callers (verify) share it."""
|
|
313
|
+
import os as _os
|
|
314
|
+
from optio_host.host import LocalHost, RemoteHost
|
|
315
|
+
|
|
316
|
+
if ssh is None:
|
|
317
|
+
_os.makedirs(taskdir, exist_ok=True)
|
|
318
|
+
host = LocalHost(taskdir=taskdir)
|
|
319
|
+
_os.makedirs(host.workdir, exist_ok=True)
|
|
320
|
+
return host
|
|
321
|
+
return RemoteHost(ssh_config=ssh, taskdir=taskdir)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def run_opencode_probe(
|
|
325
|
+
host: "Host",
|
|
326
|
+
*,
|
|
327
|
+
opencode_executable: str,
|
|
328
|
+
model: str,
|
|
329
|
+
prompt: str,
|
|
330
|
+
wrap: "list[str] | None" = None,
|
|
331
|
+
timeout_s: float = 180.0,
|
|
332
|
+
) -> "tuple[str, int]":
|
|
333
|
+
"""Headless one-shot `opencode run` under the per-task isolation env.
|
|
334
|
+
Returns (stdout, exit_code). `wrap` is an argv prefix seam (future
|
|
335
|
+
claustrum fs-isolation). Plain output — the caller's verdict is a
|
|
336
|
+
challenge-answer match on stdout; exit code is diagnostics only."""
|
|
337
|
+
import asyncio as _asyncio
|
|
338
|
+
|
|
339
|
+
argv = [*(wrap or []), opencode_executable, "run", "--model", model, prompt]
|
|
340
|
+
cmd = " ".join(shlex.quote(a) for a in argv)
|
|
341
|
+
result = await _asyncio.wait_for(
|
|
342
|
+
host.run_command(f"bash -lc {shlex.quote(cmd)}", env=_isolation_env(host)),
|
|
343
|
+
timeout=timeout_s,
|
|
277
344
|
)
|
|
345
|
+
return (result.stdout or "", result.exit_code)
|
|
278
346
|
|
|
279
347
|
|
|
280
348
|
async def opencode_version(
|