optio-opencode 0.3.1__tar.gz → 0.5.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.
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/PKG-INFO +7 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/pyproject.toml +10 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/__init__.py +3 -0
- optio_opencode-0.5.0/src/optio_opencode/account.py +93 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/cred_watcher.py +63 -0
- optio_opencode-0.5.0/src/optio_opencode/fs_allowlist.py +49 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/host_actions.py +101 -2
- optio_opencode-0.5.0/src/optio_opencode/providers/__init__.py +13 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/anthropic.py +33 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/deepseek.py +91 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/openai.py +33 -0
- optio_opencode-0.5.0/src/optio_opencode/providers/xai.py +100 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/session.py +130 -44
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/types.py +41 -31
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/verify.py +46 -10
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/PKG-INFO +7 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/SOURCES.txt +11 -0
- optio_opencode-0.5.0/src/optio_opencode.egg-info/requires.txt +13 -0
- optio_opencode-0.5.0/tests/test_account.py +160 -0
- optio_opencode-0.5.0/tests/test_account_xai_apikey.py +115 -0
- optio_opencode-0.5.0/tests/test_claustrum.py +149 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_config_harmonization.py +4 -26
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_config.py +6 -1
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_driver.py +95 -31
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_session.py +11 -11
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_ui_model.py +10 -10
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_ui_session.py +40 -15
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_upload.py +4 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_cred_watcher.py +79 -5
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_file_download.py +5 -5
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_file_upload.py +3 -3
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_actions.py +61 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_local.py +1 -1
- optio_opencode-0.5.0/tests/test_provider_deepseek.py +93 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_sanity.py +4 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_seed_config.py +2 -2
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_blob_hooks.py +4 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_hooks.py +6 -6
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_local.py +14 -4
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_remote.py +1 -1
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_resume.py +43 -6
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_seed.py +236 -7
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_seed_saveback.py +83 -3
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_types.py +20 -16
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_verify_seed.py +61 -2
- optio_opencode-0.3.1/src/optio_opencode.egg-info/requires.txt +0 -10
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/README.md +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/setup.cfg +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/conversation.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/info.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/model_probe.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/prompt.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/seed_manifest.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/snapshots.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_agent_info.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_agent_sender_opencode.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_primitives_local.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_primitives_remote.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_remote_resume.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_resume.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_model_probe.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_model_probe_wiring.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_prompt.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_purge_seed.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_resume_sentence_opencode.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_smart_install.py +0 -0
- {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: optio-opencode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -20,9 +20,12 @@ Classifier: Topic :: Software Development :: Code Generators
|
|
|
20
20
|
Classifier: Framework :: AsyncIO
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
|
-
Requires-Dist: optio-core<0.
|
|
24
|
-
Requires-Dist: optio-host<0.
|
|
25
|
-
Requires-Dist: optio-agents<0.
|
|
23
|
+
Requires-Dist: optio-core<0.5,>=0.4
|
|
24
|
+
Requires-Dist: optio-host<0.4,>=0.3
|
|
25
|
+
Requires-Dist: optio-agents<0.7,>=0.6
|
|
26
|
+
Requires-Dist: optio-claudecode<0.6,>=0.5
|
|
27
|
+
Requires-Dist: optio-codex<0.4,>=0.3
|
|
28
|
+
Requires-Dist: optio-grok<0.4,>=0.3
|
|
26
29
|
Requires-Dist: asyncssh>=2.14
|
|
27
30
|
Requires-Dist: aiohttp>=3.9
|
|
28
31
|
Provides-Extra: dev
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "optio-opencode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.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"
|
|
@@ -26,11 +26,17 @@ classifiers = [
|
|
|
26
26
|
"Framework :: AsyncIO",
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
|
-
"optio-core>=0.
|
|
29
|
+
"optio-core>=0.4,<0.5",
|
|
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
|
-
"optio-host>=0.
|
|
33
|
-
"optio-agents>=0.
|
|
32
|
+
"optio-host>=0.3,<0.4",
|
|
33
|
+
"optio-agents>=0.6,<0.7",
|
|
34
|
+
# The account meta-analyzer's per-provider handlers reuse the extracted
|
|
35
|
+
# vendor map helpers (account_from_oauth_token / account_from_openai /
|
|
36
|
+
# account_from_xai) rather than reimplement each vendor's fetch+map.
|
|
37
|
+
"optio-claudecode>=0.5,<0.6",
|
|
38
|
+
"optio-codex>=0.3,<0.4",
|
|
39
|
+
"optio-grok>=0.3,<0.4",
|
|
34
40
|
"asyncssh>=2.14",
|
|
35
41
|
"aiohttp>=3.9",
|
|
36
42
|
]
|
|
@@ -8,6 +8,7 @@ from optio_host import (
|
|
|
8
8
|
RunResult,
|
|
9
9
|
SSHConfig,
|
|
10
10
|
)
|
|
11
|
+
from optio_opencode.account import analyze_accounts, resolve_capture_accounts
|
|
11
12
|
from optio_opencode.info import AGENT_INFO
|
|
12
13
|
from optio_opencode.session import create_opencode_task, run_opencode_session
|
|
13
14
|
from optio_opencode.types import (
|
|
@@ -58,4 +59,6 @@ __all__ = [
|
|
|
58
59
|
"ConversationMode",
|
|
59
60
|
"ToolVerbosity",
|
|
60
61
|
"verify_and_refresh_seed",
|
|
62
|
+
"analyze_accounts",
|
|
63
|
+
"resolve_capture_accounts",
|
|
61
64
|
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""opencode account meta-analyzer.
|
|
2
|
+
|
|
3
|
+
opencode is a model-selection TUI over third-party providers: a seed's
|
|
4
|
+
``auth.json`` holds credentials for whichever provider(s) the operator logged
|
|
5
|
+
into. So an opencode seed's "account" is really **one account per configured
|
|
6
|
+
provider**. Unlike the single-vendor wrappers, opencode needs a meta-analyzer:
|
|
7
|
+
dispatch each provider entry to its handler (which reuses the vendor map
|
|
8
|
+
helpers), aggregate into a ``list[AccountInfo]``.
|
|
9
|
+
|
|
10
|
+
Every configured provider yields exactly one ``AccountInfo`` — an analyzed one
|
|
11
|
+
when a handler resolves it, else a placeholder (``"Unknown account · <provider>"``)
|
|
12
|
+
so nothing silently disappears from the pool popover. Fail-soft throughout: a
|
|
13
|
+
provider handler never breaks the others; empty/unreadable auth → ``[]``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import replace
|
|
22
|
+
|
|
23
|
+
from optio_agents.account import EMPTY, AccountInfo
|
|
24
|
+
from optio_opencode.providers import anthropic, deepseek, openai, xai
|
|
25
|
+
|
|
26
|
+
_LOG = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
# models.dev provider id → per-provider handler. Providers absent here (the
|
|
29
|
+
# ~146 remaining) yield a placeholder AccountInfo.
|
|
30
|
+
_REGISTRY = {
|
|
31
|
+
"anthropic": anthropic.handle,
|
|
32
|
+
"openai": openai.handle,
|
|
33
|
+
"xai": xai.handle, # oauth (grok reuse) + api-key (/v1/api-key) branches
|
|
34
|
+
"deepseek": deepseek.handle, # api-key: /user/balance
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_AUTH_RELPATH = "home/.local/share/opencode/auth.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _placeholder(provider_id: str, entry: dict) -> AccountInfo:
|
|
41
|
+
"""A stand-in for a provider we do not (yet) analyze. Carries the ``provider``
|
|
42
|
+
so the frame's summary renders ``"<provider> · unknown account"`` — attributed
|
|
43
|
+
to its provider even though nothing was fetched."""
|
|
44
|
+
return AccountInfo(
|
|
45
|
+
provider=provider_id,
|
|
46
|
+
account_id=entry.get("accountId"),
|
|
47
|
+
raw={"provider": provider_id, "unanalyzed": True},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def analyze_accounts(auth: dict) -> "list[AccountInfo]":
|
|
52
|
+
"""Map an opencode ``auth.json`` dict → one ``AccountInfo`` per provider.
|
|
53
|
+
|
|
54
|
+
For each ``provider_id -> entry``: skip non-dict entries; dispatch to the
|
|
55
|
+
registered handler (fail-soft); append the analyzed account, or a
|
|
56
|
+
placeholder when there is no handler / it declines / it fails / it returns
|
|
57
|
+
the ``EMPTY`` sentinel. Empty or non-dict auth → ``[]``."""
|
|
58
|
+
if not isinstance(auth, dict):
|
|
59
|
+
return []
|
|
60
|
+
items = [(pid, e) for pid, e in auth.items() if isinstance(e, dict)]
|
|
61
|
+
|
|
62
|
+
async def _one(provider_id: str, entry: dict) -> AccountInfo:
|
|
63
|
+
handler = _REGISTRY.get(provider_id)
|
|
64
|
+
info: AccountInfo | None = None
|
|
65
|
+
if handler is not None:
|
|
66
|
+
try:
|
|
67
|
+
info = await handler(entry)
|
|
68
|
+
except Exception: # noqa: BLE001 — one bad provider never drops the rest
|
|
69
|
+
_LOG.exception("opencode provider handler failed: %s", provider_id)
|
|
70
|
+
info = None
|
|
71
|
+
if info is not None and info != EMPTY:
|
|
72
|
+
# Attribute every analyzed account to its provider so the summary is
|
|
73
|
+
# unambiguous across a multi-provider seed ("openai · Plan: …").
|
|
74
|
+
return replace(info, provider=provider_id)
|
|
75
|
+
return _placeholder(provider_id, entry)
|
|
76
|
+
|
|
77
|
+
# Providers analyzed CONCURRENTLY — each hits a different vendor with its own
|
|
78
|
+
# (up to 15s) HTTP timeout, so a multi-provider seed must not pay the SUM of
|
|
79
|
+
# those; the verify/verify-free RPC deadline can't absorb it (see the
|
|
80
|
+
# asyncio.wait_for budget in verify.py).
|
|
81
|
+
return list(await asyncio.gather(*(_one(pid, e) for pid, e in items)))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def resolve_capture_accounts(host) -> "list[AccountInfo]":
|
|
85
|
+
"""Live-host capture variant: read the isolated HOME's ``auth.json`` and
|
|
86
|
+
analyze it. Fail-soft → ``[]`` on any read/parse failure."""
|
|
87
|
+
path = f"{host.workdir.rstrip('/')}/{_AUTH_RELPATH}"
|
|
88
|
+
try:
|
|
89
|
+
raw = await host.fetch_bytes_from_host(path)
|
|
90
|
+
auth = json.loads(raw.decode("utf-8"))
|
|
91
|
+
except Exception: # noqa: BLE001 — missing/unreadable/malformed auth → []
|
|
92
|
+
return []
|
|
93
|
+
return await analyze_accounts(auth)
|
|
@@ -69,6 +69,64 @@ async def capture_gate_ok(host: Host) -> bool:
|
|
|
69
69
|
return isinstance(cfg, dict) and bool(cfg.get("model"))
|
|
70
70
|
|
|
71
71
|
|
|
72
|
+
class UnsliceableSeed(Exception):
|
|
73
|
+
"""The seed's auth.json holds several providers but cannot be safely
|
|
74
|
+
reduced to the one backing the configured default model."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _provider_of(model: str | None) -> str | None:
|
|
78
|
+
"""The provider id of a `provider/model` string, or None if it carries no
|
|
79
|
+
`provider/` prefix."""
|
|
80
|
+
if not model or "/" not in model:
|
|
81
|
+
return None
|
|
82
|
+
return model.split("/", 1)[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _read_json(host: Host, relpath: str) -> dict | None:
|
|
86
|
+
path = f"{host.workdir.rstrip('/')}/{relpath}"
|
|
87
|
+
try:
|
|
88
|
+
raw = await host.fetch_bytes_from_host(path)
|
|
89
|
+
data = json.loads(raw.decode("utf-8"))
|
|
90
|
+
except (FileNotFoundError, ValueError, UnicodeDecodeError):
|
|
91
|
+
return None
|
|
92
|
+
return data if isinstance(data, dict) else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def slim_auth_to_selected_provider(host: Host) -> bool:
|
|
96
|
+
"""Enforce one-provider-per-seed: prune the live auth.json to the single
|
|
97
|
+
provider backing the configured default model (`small_model || model`),
|
|
98
|
+
dropping the rest. Returns True if it rewrote auth.json, False on no-op
|
|
99
|
+
(auth absent/invalid, or already one provider).
|
|
100
|
+
|
|
101
|
+
Raises UnsliceableSeed when the seed cannot be reduced to one provider:
|
|
102
|
+
the selected model has no `provider/` prefix, `model` and `small_model`
|
|
103
|
+
resolve to different providers, or the selected provider is absent from
|
|
104
|
+
auth.json. The caller decides what an un-sliceable seed means (capture
|
|
105
|
+
refuses it; save-back leaves the seed untouched)."""
|
|
106
|
+
auth = await _read_json(host, _CRED_RELPATH)
|
|
107
|
+
if not auth: # missing/invalid/empty -> nothing to slim
|
|
108
|
+
return False
|
|
109
|
+
if len(auth) <= 1: # already single-provider
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
cfg = await _read_json(host, _MODEL_RELPATH) or {}
|
|
113
|
+
selected = _provider_of(cfg.get("model"))
|
|
114
|
+
if selected is None:
|
|
115
|
+
raise UnsliceableSeed("no provider-qualified model in opencode.json")
|
|
116
|
+
small = _provider_of(cfg.get("small_model"))
|
|
117
|
+
if small is not None and small != selected:
|
|
118
|
+
raise UnsliceableSeed(
|
|
119
|
+
f"model provider {selected!r} != small_model provider {small!r}")
|
|
120
|
+
if selected not in auth:
|
|
121
|
+
raise UnsliceableSeed(
|
|
122
|
+
f"selected provider {selected!r} not in auth.json {sorted(auth)}")
|
|
123
|
+
|
|
124
|
+
dropped = sorted(k for k in auth if k != selected)
|
|
125
|
+
await host.write_text(_CRED_RELPATH, json.dumps({selected: auth[selected]}))
|
|
126
|
+
_LOG.info("slimmed seed auth to provider %r; dropped %s", selected, dropped)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
72
130
|
async def save_back_if_changed(
|
|
73
131
|
ctx,
|
|
74
132
|
host: Host,
|
|
@@ -81,6 +139,11 @@ async def save_back_if_changed(
|
|
|
81
139
|
"""If the live auth.json differs from `baseline` and is valid, save it
|
|
82
140
|
back into the seed and return the new fingerprint. Otherwise return
|
|
83
141
|
`baseline` unchanged. Never raises — save-back is best-effort."""
|
|
142
|
+
try:
|
|
143
|
+
await slim_auth_to_selected_provider(host)
|
|
144
|
+
except UnsliceableSeed as e:
|
|
145
|
+
_LOG.warning("seed %s: save-back skipped, un-sliceable auth (%s)", seed_id, e)
|
|
146
|
+
return baseline
|
|
84
147
|
fp = await cred_fingerprint(host)
|
|
85
148
|
if fp is None or fp == baseline:
|
|
86
149
|
return baseline
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Build the claustrum filesystem-allowlist flags for an ``opencode web`` launch.
|
|
2
|
+
|
|
3
|
+
Thin delegator over the shared ``optio_agents.fs_grants`` builder (the system
|
|
4
|
+
baseline + workdir + engine-cache + caller-extras logic lives there, one SSOT
|
|
5
|
+
across every wrapper). opencode contributes ONE engine-specific grant on top:
|
|
6
|
+
|
|
7
|
+
* opencode's home/config/data/cache all live under ``<workdir>/home`` — already
|
|
8
|
+
covered by ``--rwx <workdir>``.
|
|
9
|
+
* BUT ``OPENCODE_DB`` = ``<taskdir>/opencode.db`` sits ONE LEVEL ABOVE the
|
|
10
|
+
workdir (the ``taskdir/workdir`` layout). Under-granting there breaks
|
|
11
|
+
opencode's live DB, so the whole taskdir is granted rwx via ``extra_baseline``.
|
|
12
|
+
* the opencode binary cache (``_resolve_install_dir``, outside every workdir)
|
|
13
|
+
is granted read+exec as the engine cache.
|
|
14
|
+
|
|
15
|
+
Non-existent paths are harmless: claustrum ignores missing paths.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from optio_agents import fs_grants
|
|
21
|
+
|
|
22
|
+
# Re-exported so tests / callers can build extra grants without reaching into
|
|
23
|
+
# optio_agents directly (mirrors the other wrappers' fs_allowlist surface).
|
|
24
|
+
from .types import AllowedDir # noqa: F401
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_grant_flags(
|
|
28
|
+
*,
|
|
29
|
+
workdir: str,
|
|
30
|
+
taskdir: str,
|
|
31
|
+
opencode_cache_dir: str,
|
|
32
|
+
extra_allowed_dirs: "list[AllowedDir] | None",
|
|
33
|
+
host_home: str | None = None,
|
|
34
|
+
) -> list[str]:
|
|
35
|
+
"""Return the ordered claustrum grant flags for an opencode launch.
|
|
36
|
+
|
|
37
|
+
``workdir`` (the per-task tree incl. the isolated ``home``) is granted rwx.
|
|
38
|
+
``taskdir`` (the workdir's parent, holding the live ``opencode.db``) is
|
|
39
|
+
granted rwx as an engine ``extra_baseline`` so the server can write its DB.
|
|
40
|
+
``opencode_cache_dir`` (where the real opencode binary lives, outside every
|
|
41
|
+
workdir) is granted read+exec. ``~``/``~/`` caller extras expand against
|
|
42
|
+
``host_home`` (the REAL host home)."""
|
|
43
|
+
return fs_grants.build_grant_flags(
|
|
44
|
+
workdir=workdir,
|
|
45
|
+
engine_cache_dir=opencode_cache_dir,
|
|
46
|
+
extra_allowed_dirs=extra_allowed_dirs,
|
|
47
|
+
host_home=host_home,
|
|
48
|
+
extra_baseline=[("--rwx", taskdir.rstrip("/"))],
|
|
49
|
+
)
|
|
@@ -28,6 +28,8 @@ from typing import TYPE_CHECKING, Callable
|
|
|
28
28
|
|
|
29
29
|
from optio_host.host import ProcessHandle
|
|
30
30
|
|
|
31
|
+
from optio_agents import claustrum
|
|
32
|
+
|
|
31
33
|
from optio_opencode.info import AGENT_INFO
|
|
32
34
|
|
|
33
35
|
if TYPE_CHECKING:
|
|
@@ -71,6 +73,72 @@ async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
|
|
|
71
73
|
return path.rstrip("/")
|
|
72
74
|
|
|
73
75
|
|
|
76
|
+
# --- claustrum provisioning -------------------------------------------------
|
|
77
|
+
#
|
|
78
|
+
# opencode is wrapped in claustrum (Landlock, fail-closed) so the whole
|
|
79
|
+
# ``opencode web`` server tree — the server and every tool subprocess it spawns
|
|
80
|
+
# — is confined to an explicit filesystem allowlist. The provisioning logic
|
|
81
|
+
# (engine cross-compile, ELF-guarded build cache, functional wrap+exec
|
|
82
|
+
# validation, fail-closed placement) is the shared ``optio_agents.claustrum``
|
|
83
|
+
# module; this wrapper contributes only the opencode-owned cache-dir resolution
|
|
84
|
+
# and, in session.py, the grant set (``fs_allowlist.build_grant_flags``).
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def ensure_claustrum_installed(
|
|
88
|
+
hook_ctx,
|
|
89
|
+
*,
|
|
90
|
+
install_dir: str | None = None,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Ensure a functioning claustrum binary is on the host; return its path.
|
|
93
|
+
|
|
94
|
+
Thin wrapper over :func:`optio_agents.claustrum.ensure_claustrum_installed`:
|
|
95
|
+
resolves the opencode-owned target cache dir (on the worker, beside the
|
|
96
|
+
opencode binary cache), pins the engine build cache to
|
|
97
|
+
``~/.cache/optio-opencode``, and forwards the UI progress callback. All the
|
|
98
|
+
real work (cross-compile, ELF-guarded engine cache, functional wrap+exec
|
|
99
|
+
validation) lives in the shared module. Fail-closed — any failure RAISES, so
|
|
100
|
+
the caller never proceeds to an unconfined launch.
|
|
101
|
+
"""
|
|
102
|
+
host = hook_ctx._host
|
|
103
|
+
cache_dir = await _resolve_install_dir(host, install_dir)
|
|
104
|
+
return await claustrum.ensure_claustrum_installed(
|
|
105
|
+
host,
|
|
106
|
+
cache_dir=cache_dir,
|
|
107
|
+
engine_cache_dir=os.path.expanduser("~/.cache/optio-opencode"),
|
|
108
|
+
report_progress=hook_ctx.report_progress,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def claustrum_newer_tag() -> str | None:
|
|
113
|
+
"""Return the newest claustrum tag if it is newer than the pinned one, else None.
|
|
114
|
+
|
|
115
|
+
Engine-side egress only. Best-effort: network failure returns None (no notice).
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
p = await asyncio.create_subprocess_exec(
|
|
119
|
+
"git", "ls-remote", "--tags", "--refs", claustrum.CLAUSTRUM_REPO,
|
|
120
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
121
|
+
)
|
|
122
|
+
out, _ = await p.communicate()
|
|
123
|
+
if p.returncode != 0:
|
|
124
|
+
return None
|
|
125
|
+
except Exception: # noqa: BLE001
|
|
126
|
+
return None
|
|
127
|
+
tags = []
|
|
128
|
+
for line in out.decode().splitlines():
|
|
129
|
+
ref = line.rsplit("/", 1)[-1].strip()
|
|
130
|
+
if ref.startswith("v"):
|
|
131
|
+
tags.append(ref)
|
|
132
|
+
|
|
133
|
+
def key(t: str) -> tuple:
|
|
134
|
+
return tuple(int(x) for x in t.lstrip("v").split(".") if x.isdigit())
|
|
135
|
+
|
|
136
|
+
if not tags:
|
|
137
|
+
return None
|
|
138
|
+
newest = max(tags, key=key)
|
|
139
|
+
return newest if key(newest) > key(claustrum.CLAUSTRUM_PINNED_TAG) else None
|
|
140
|
+
|
|
141
|
+
|
|
74
142
|
def _isolation_env(host: "Host") -> dict[str, str]:
|
|
75
143
|
"""Per-task HOME/XDG isolation env, derived from ``host.workdir``.
|
|
76
144
|
|
|
@@ -437,6 +505,7 @@ async def launch_opencode(
|
|
|
437
505
|
hostname: str = "127.0.0.1",
|
|
438
506
|
extra_env: dict[str, str] | None = None,
|
|
439
507
|
env_remove: list[str] | None = None,
|
|
508
|
+
claustrum_wrap: list[str] | None = None,
|
|
440
509
|
) -> tuple[ProcessHandle, int]:
|
|
441
510
|
"""Launch ``opencode web`` on ``host``; wait for the listening URL.
|
|
442
511
|
|
|
@@ -475,10 +544,18 @@ async def launch_opencode(
|
|
|
475
544
|
# opencode succeeds at opening a real browser window. opencode_executable
|
|
476
545
|
# is an absolute path (resolved by ensure_opencode_installed), so
|
|
477
546
|
# login-shell PATH lookup is not needed to find the binary.
|
|
547
|
+
#
|
|
548
|
+
# claustrum_wrap (fs isolation) is spliced BETWEEN the ``env …`` password
|
|
549
|
+
# assignment and the opencode executable: ``exec env VAR=… claustrum … --
|
|
550
|
+
# opencode web``. The ``$(cat …)`` runs in the outer shell (before exec), so
|
|
551
|
+
# the password read happens outside the Landlock confinement; ``env`` then
|
|
552
|
+
# execs claustrum, which Landlock-confines itself and execs opencode, so the
|
|
553
|
+
# server and every tool subprocess inherit the allowlist. None → no wrap.
|
|
554
|
+
wrap_prefix = f"{shlex.join(claustrum_wrap)} " if claustrum_wrap else ""
|
|
478
555
|
cmd = (
|
|
479
556
|
f"exec env "
|
|
480
557
|
f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
|
|
481
|
-
f"{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
|
|
558
|
+
f"{wrap_prefix}{opencode_executable} web --port=0 --hostname={shlex.quote(hostname)}"
|
|
482
559
|
)
|
|
483
560
|
|
|
484
561
|
# OPENCODE_DB must point at the same per-task db file used by the
|
|
@@ -507,19 +584,40 @@ async def launch_opencode(
|
|
|
507
584
|
cmd, env=env, cwd=host.workdir, env_remove=env_remove,
|
|
508
585
|
)
|
|
509
586
|
|
|
587
|
+
# Retain the tail of the launch stream so a failed start surfaces its REASON.
|
|
588
|
+
# `opencode web` is a plain server (not a TUI), so its startup diagnostics —
|
|
589
|
+
# a bind error, an auth/config crash, a claustrum "operation not permitted"
|
|
590
|
+
# denial — arrive as ordinary lines on the merged stdout+stderr the loop
|
|
591
|
+
# below already consumes. Without retaining them, they are scanned by
|
|
592
|
+
# _READY_RE, discarded, and the operator sees only "exited before printing a
|
|
593
|
+
# URL". A bounded deque keeps memory flat on a chatty-but-successful start.
|
|
594
|
+
# No ANSI strip (unlike the tmux engines' pipe-pane mirror): server logs are
|
|
595
|
+
# plain text, not PTY-painted TUI frames.
|
|
596
|
+
from collections import deque
|
|
597
|
+
|
|
598
|
+
tail: "deque[str]" = deque(maxlen=40)
|
|
599
|
+
|
|
600
|
+
def _reason_suffix() -> str:
|
|
601
|
+
recent = "\n".join(tail).strip()
|
|
602
|
+
return f"; last output:\n{recent}" if recent else ""
|
|
603
|
+
|
|
510
604
|
async def _read_url() -> int:
|
|
511
605
|
async for raw in handle.stdout:
|
|
512
606
|
if isinstance(raw, bytes):
|
|
513
607
|
line = raw.decode("utf-8", errors="replace").rstrip()
|
|
514
608
|
else:
|
|
515
609
|
line = str(raw).rstrip()
|
|
610
|
+
if line:
|
|
611
|
+
tail.append(line)
|
|
516
612
|
m = _READY_RE.search(line)
|
|
517
613
|
if m:
|
|
518
614
|
m2 = re.search(r":(\d+)", m.group(1))
|
|
519
615
|
if not m2:
|
|
520
616
|
raise RuntimeError(f"could not find port in URL: {line}")
|
|
521
617
|
return int(m2.group(1))
|
|
522
|
-
raise RuntimeError(
|
|
618
|
+
raise RuntimeError(
|
|
619
|
+
f"opencode exited before printing a URL{_reason_suffix()}"
|
|
620
|
+
)
|
|
523
621
|
|
|
524
622
|
try:
|
|
525
623
|
port = await asyncio.wait_for(_read_url(), timeout=ready_timeout_s)
|
|
@@ -527,6 +625,7 @@ async def launch_opencode(
|
|
|
527
625
|
await host.terminate_subprocess(handle, aggressive=True)
|
|
528
626
|
raise TimeoutError(
|
|
529
627
|
f"opencode did not print a listening URL within {ready_timeout_s}s"
|
|
628
|
+
f"{_reason_suffix()}"
|
|
530
629
|
)
|
|
531
630
|
except BaseException:
|
|
532
631
|
await host.terminate_subprocess(handle, aggressive=True)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Per-provider account handlers for the opencode meta-analyzer.
|
|
2
|
+
|
|
3
|
+
opencode has no native account — a seed's ``auth.json`` holds credentials for
|
|
4
|
+
whichever third-party provider(s) the operator logged into. Each handler
|
|
5
|
+
``async def handle(entry: dict) -> AccountInfo | None`` maps one provider's
|
|
6
|
+
auth entry into the shared ``AccountInfo`` (or ``None`` → the meta-analyzer
|
|
7
|
+
emits a placeholder). This phase ships the three OAuth reuse handlers
|
|
8
|
+
(anthropic, openai, xai), each delegating to the extracted vendor map helper.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from optio_opencode.providers import anthropic, openai, xai
|
|
12
|
+
|
|
13
|
+
__all__ = ["anthropic", "openai", "xai"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""anthropic provider handler — reuses claudecode's OAuth map helper.
|
|
2
|
+
|
|
3
|
+
opencode's anthropic-oauth entry stores the same shape of OAuth access token
|
|
4
|
+
claudecode drives, so we delegate to ``account_from_oauth_token`` rather than
|
|
5
|
+
reimplement the profile+usage fetch. Imported at module scope so tests can
|
|
6
|
+
monkeypatch it here (patch-where-used)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from optio_agents.account import EMPTY, AccountInfo
|
|
13
|
+
from optio_claudecode.account import account_from_oauth_token
|
|
14
|
+
|
|
15
|
+
_LOG = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def handle(entry: dict) -> "AccountInfo | None":
|
|
19
|
+
"""Map an anthropic auth entry → AccountInfo, or None (→ placeholder).
|
|
20
|
+
|
|
21
|
+
This phase handles ``oauth`` only; ``api`` (raw keys expose ~no account)
|
|
22
|
+
and any other type decline. Fail-soft: never raises."""
|
|
23
|
+
if entry.get("type") != "oauth":
|
|
24
|
+
return None
|
|
25
|
+
access = entry.get("access")
|
|
26
|
+
if not access:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
info = await account_from_oauth_token(access)
|
|
30
|
+
except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
|
|
31
|
+
_LOG.exception("anthropic account analysis failed")
|
|
32
|
+
return None
|
|
33
|
+
return info if info != EMPTY else None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""deepseek provider handler — api-key balance → AccountInfo.
|
|
2
|
+
|
|
3
|
+
DeepSeek is an api-key-only provider with no identity endpoint: the account's
|
|
4
|
+
only distinguishing surface is its prepaid balance. So this handler fetches
|
|
5
|
+
``GET https://api.deepseek.com/user/balance`` (Bearer <key>) and maps the first
|
|
6
|
+
``balance_infos`` row into ``plan`` — ``"DeepSeek · <total> <currency>"`` — so
|
|
7
|
+
the pool popover shows the balance as the account label. Identity-less
|
|
8
|
+
(name/email/account_id all None), no usage windows.
|
|
9
|
+
|
|
10
|
+
The urllib GET runs in an executor via the ``_fetch`` seam (mirroring the reuse
|
|
11
|
+
handlers' vendor helpers); tests monkeypatch ``_fetch`` — never the network.
|
|
12
|
+
Fail-soft throughout: any decline/error → None so the meta-analyzer emits a
|
|
13
|
+
placeholder instead of dropping the provider."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import urllib.request
|
|
21
|
+
from urllib.error import HTTPError, URLError
|
|
22
|
+
|
|
23
|
+
from optio_agents.account import AccountInfo
|
|
24
|
+
|
|
25
|
+
_LOG = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_BALANCE_URL = "https://api.deepseek.com/user/balance"
|
|
28
|
+
_USER_AGENT = "optio-opencode/account (external)"
|
|
29
|
+
_HTTP_TIMEOUT_S = 15
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- synchronous HTTP (run in an executor; no host) --------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_sync(url: str, key: str) -> "dict | None":
|
|
36
|
+
"""Bearer-authed read-only GET → parsed JSON dict, or None on any HTTP/parse
|
|
37
|
+
error (fail-soft)."""
|
|
38
|
+
req = urllib.request.Request(
|
|
39
|
+
url,
|
|
40
|
+
headers={
|
|
41
|
+
"Authorization": f"Bearer {key}",
|
|
42
|
+
"Accept": "application/json",
|
|
43
|
+
"User-Agent": _USER_AGENT,
|
|
44
|
+
},
|
|
45
|
+
method="GET",
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_S) as resp:
|
|
49
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
50
|
+
return data if isinstance(data, dict) else None
|
|
51
|
+
except (HTTPError, URLError, OSError, ValueError):
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def _fetch(key: str) -> "dict | None":
|
|
56
|
+
"""Async wrapper around the sync Bearer GET (urllib in an executor). This is
|
|
57
|
+
the monkeypatch seam the tests replace to avoid network."""
|
|
58
|
+
return await asyncio.get_event_loop().run_in_executor(
|
|
59
|
+
None, _get_sync, _BALANCE_URL, key
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def handle(entry: dict) -> "AccountInfo | None":
|
|
64
|
+
"""Map a deepseek auth entry → AccountInfo, or None (→ placeholder).
|
|
65
|
+
|
|
66
|
+
Handles ``api`` only (deepseek has no oauth). Fail-soft: never raises. None
|
|
67
|
+
when the type is not ``api``, the key is missing, the fetch fails, or the
|
|
68
|
+
balance list is empty."""
|
|
69
|
+
if entry.get("type") != "api":
|
|
70
|
+
return None
|
|
71
|
+
key = entry.get("key")
|
|
72
|
+
if not key:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
body = await _fetch(key)
|
|
76
|
+
except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
|
|
77
|
+
_LOG.exception("deepseek account analysis failed")
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(body, dict):
|
|
80
|
+
return None
|
|
81
|
+
infos = body.get("balance_infos")
|
|
82
|
+
if not isinstance(infos, list) or not infos:
|
|
83
|
+
return None
|
|
84
|
+
first = infos[0]
|
|
85
|
+
total_balance = first.get("total_balance")
|
|
86
|
+
currency = first.get("currency")
|
|
87
|
+
return AccountInfo(
|
|
88
|
+
plan=f"Balance {total_balance} {currency}",
|
|
89
|
+
windows=(),
|
|
90
|
+
raw={"balance": body},
|
|
91
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""openai provider handler — reuses codex's OAuth map helper.
|
|
2
|
+
|
|
3
|
+
opencode's openai-oauth entry stores ``{access, accountId}`` with **no**
|
|
4
|
+
``id_token``, so identity comes from codex's ``account_from_openai`` (which
|
|
5
|
+
fetches ``GET /backend-api/me`` rather than decoding an id_token). Imported at
|
|
6
|
+
module scope so tests can monkeypatch it here (patch-where-used)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from optio_agents.account import EMPTY, AccountInfo
|
|
13
|
+
from optio_codex.account import account_from_openai
|
|
14
|
+
|
|
15
|
+
_LOG = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def handle(entry: dict) -> "AccountInfo | None":
|
|
19
|
+
"""Map an openai auth entry → AccountInfo, or None (→ placeholder).
|
|
20
|
+
|
|
21
|
+
This phase handles ``oauth`` only (raw ``api`` keys are admin-gated for
|
|
22
|
+
account/usage). Fail-soft: never raises."""
|
|
23
|
+
if entry.get("type") != "oauth":
|
|
24
|
+
return None
|
|
25
|
+
access = entry.get("access")
|
|
26
|
+
if not access:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
info = await account_from_openai(access, entry.get("accountId"))
|
|
30
|
+
except Exception: # noqa: BLE001 — analysis must never break the meta-analyzer
|
|
31
|
+
_LOG.exception("openai account analysis failed")
|
|
32
|
+
return None
|
|
33
|
+
return info if info != EMPTY else None
|