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.
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/PKG-INFO +2 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/pyproject.toml +2 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/__init__.py +10 -0
- optio_opencode-0.2.2/src/optio_opencode/conversation.py +357 -0
- optio_opencode-0.2.2/src/optio_opencode/cred_watcher.py +129 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/host_actions.py +84 -16
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/prompt.py +43 -6
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/seed_manifest.py +12 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/session.py +250 -70
- optio_opencode-0.2.2/src/optio_opencode/types.py +182 -0
- optio_opencode-0.2.2/src/optio_opencode/verify.py +153 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/PKG-INFO +2 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/SOURCES.txt +14 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/requires.txt +1 -0
- optio_opencode-0.2.2/tests/test_conversation_config.py +49 -0
- optio_opencode-0.2.2/tests/test_conversation_driver.py +295 -0
- optio_opencode-0.2.2/tests/test_conversation_session.py +243 -0
- optio_opencode-0.2.2/tests/test_conversation_ui_model.py +77 -0
- optio_opencode-0.2.2/tests/test_conversation_ui_session.py +206 -0
- optio_opencode-0.2.2/tests/test_cred_watcher.py +185 -0
- optio_opencode-0.2.2/tests/test_file_download.py +80 -0
- optio_opencode-0.2.2/tests/test_file_upload.py +24 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_prompt.py +38 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_hooks.py +1 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_local.py +5 -6
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_remote.py +1 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_resume.py +94 -1
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_seed.py +8 -4
- optio_opencode-0.2.2/tests/test_session_seed_saveback.py +214 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_smart_install.py +16 -7
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_types.py +15 -2
- optio_opencode-0.2.2/tests/test_verify_seed.py +165 -0
- optio_opencode-0.2.1/src/optio_opencode/types.py +0 -80
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/README.md +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/setup.cfg +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_actions.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_local.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_sanity.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_seed_config.py +0 -0
- {optio_opencode-0.2.1 → optio_opencode-0.2.2}/tests/test_session_blob_hooks.py +0 -0
- {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.
|
|
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.
|
|
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
|