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.
Files changed (69) hide show
  1. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/PKG-INFO +7 -4
  2. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/pyproject.toml +10 -4
  3. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/__init__.py +3 -0
  4. optio_opencode-0.5.0/src/optio_opencode/account.py +93 -0
  5. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/cred_watcher.py +63 -0
  6. optio_opencode-0.5.0/src/optio_opencode/fs_allowlist.py +49 -0
  7. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/host_actions.py +101 -2
  8. optio_opencode-0.5.0/src/optio_opencode/providers/__init__.py +13 -0
  9. optio_opencode-0.5.0/src/optio_opencode/providers/anthropic.py +33 -0
  10. optio_opencode-0.5.0/src/optio_opencode/providers/deepseek.py +91 -0
  11. optio_opencode-0.5.0/src/optio_opencode/providers/openai.py +33 -0
  12. optio_opencode-0.5.0/src/optio_opencode/providers/xai.py +100 -0
  13. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/session.py +130 -44
  14. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/types.py +41 -31
  15. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/verify.py +46 -10
  16. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/PKG-INFO +7 -4
  17. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/SOURCES.txt +11 -0
  18. optio_opencode-0.5.0/src/optio_opencode.egg-info/requires.txt +13 -0
  19. optio_opencode-0.5.0/tests/test_account.py +160 -0
  20. optio_opencode-0.5.0/tests/test_account_xai_apikey.py +115 -0
  21. optio_opencode-0.5.0/tests/test_claustrum.py +149 -0
  22. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_config_harmonization.py +4 -26
  23. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_config.py +6 -1
  24. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_driver.py +95 -31
  25. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_session.py +11 -11
  26. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_ui_model.py +10 -10
  27. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_ui_session.py +40 -15
  28. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_conversation_upload.py +4 -4
  29. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_cred_watcher.py +79 -5
  30. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_file_download.py +5 -5
  31. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_file_upload.py +3 -3
  32. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_actions.py +61 -0
  33. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_local.py +1 -1
  34. optio_opencode-0.5.0/tests/test_provider_deepseek.py +93 -0
  35. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_sanity.py +4 -4
  36. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_seed_config.py +2 -2
  37. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_blob_hooks.py +4 -4
  38. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_hooks.py +6 -6
  39. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_local.py +14 -4
  40. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_remote.py +1 -1
  41. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_resume.py +43 -6
  42. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_seed.py +236 -7
  43. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_session_seed_saveback.py +83 -3
  44. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_types.py +20 -16
  45. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_verify_seed.py +61 -2
  46. optio_opencode-0.3.1/src/optio_opencode.egg-info/requires.txt +0 -10
  47. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/README.md +0 -0
  48. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/setup.cfg +0 -0
  49. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/conversation.py +0 -0
  50. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/info.py +0 -0
  51. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/model_probe.py +0 -0
  52. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/prompt.py +0 -0
  53. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/seed_manifest.py +0 -0
  54. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode/snapshots.py +0 -0
  55. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/dependency_links.txt +0 -0
  56. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/src/optio_opencode.egg-info/top_level.txt +0 -0
  57. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_agent_info.py +0 -0
  58. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_agent_sender_opencode.py +0 -0
  59. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_primitives_local.py +0 -0
  60. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_primitives_remote.py +0 -0
  61. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_remote_resume.py +0 -0
  62. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_host_resume.py +0 -0
  63. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_model_probe.py +0 -0
  64. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_model_probe_wiring.py +0 -0
  65. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_prompt.py +0 -0
  66. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_purge_seed.py +0 -0
  67. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_resume_sentence_opencode.py +0 -0
  68. {optio_opencode-0.3.1 → optio_opencode-0.5.0}/tests/test_smart_install.py +0 -0
  69. {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.1
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.4,>=0.3
24
- Requires-Dist: optio-host<0.3,>=0.2
25
- Requires-Dist: optio-agents<0.5,>=0.4
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.3.1"
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.3,<0.4",
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.2,<0.3",
33
- "optio-agents>=0.4,<0.5",
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("opencode exited before printing a URL")
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