703 lines
27 KiB
Python
703 lines
27 KiB
Python
"""Bridge state for native OpenCode (``opencode serve``) sessions.
|
|
|
|
The OpenCode native harness mirrors the Codex native bridge, but the
|
|
transport is HTTP + SSE instead of WebSocket JSON-RPC. The runner owns
|
|
the ``opencode serve`` process and the SSE forwarder; the harness-side
|
|
executor (spawned as a separate FastAPI process) reads this bridge state
|
|
to learn the loopback server URL, auth secret, and OpenCode session id so
|
|
it can inject web turns over REST.
|
|
|
|
Layout (per bridge id):
|
|
|
|
~/.omnigent/opencode-native/<sha256(bridge_id)[:32]>/
|
|
state.json # runtime state (mutates each turn)
|
|
auth.secret # OPENCODE_SERVER_PASSWORD for this server
|
|
xdg-data/ # XDG_DATA_HOME for the per-session opencode
|
|
xdg-config/ # XDG_CONFIG_HOME for the per-session opencode
|
|
|
|
State (server URL, opencode session id, active message) is written by the
|
|
runner-owned server manager / forwarder and read by the harness executor;
|
|
the XDG dirs are preserved across runner restarts so a local resume keeps
|
|
OpenCode's persisted session history.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
# Env var the runner stamps on the harness process so the executor can
|
|
# locate its bridge directory. Mirrors ``HARNESS_CODEX_NATIVE_BRIDGE_DIR``.
|
|
OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR = "HARNESS_OPENCODE_NATIVE_BRIDGE_DIR"
|
|
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR = "HARNESS_OPENCODE_NATIVE_REQUEST_SESSION_ID"
|
|
# Label key recording the bridge id on the conversation, mirroring the
|
|
# codex-native ``omnigent.codex_native.bridge_id`` label.
|
|
OPENCODE_NATIVE_BRIDGE_ID_LABEL_KEY = "omnigent.opencode_native.bridge_id"
|
|
|
|
# OpenCode server basic-auth env vars (see opencode ``attach``/``serve``).
|
|
OPENCODE_SERVER_PASSWORD_ENV_VAR = "OPENCODE_SERVER_PASSWORD"
|
|
OPENCODE_SERVER_USERNAME_ENV_VAR = "OPENCODE_SERVER_USERNAME"
|
|
# Default basic-auth username opencode falls back to when unset.
|
|
OPENCODE_DEFAULT_USERNAME = "opencode"
|
|
|
|
_STATE_FILE = "state.json"
|
|
_AUTH_SECRET_FILE = "auth.secret"
|
|
_XDG_DATA_DIR = "xdg-data"
|
|
_XDG_CONFIG_DIR = "xdg-config"
|
|
# Token file the shared ``omnigent.claude_native_bridge serve-mcp`` reads to
|
|
# boot (filename MUST match ``claude_native_bridge._CONFIG_FILE``). opencode
|
|
# launches that serve-mcp as a ``{type:"local"}`` MCP server which relays the
|
|
# Omnigent builtin tools (``sys_*``/``load_skill``/``web_fetch``) advertised in
|
|
# ``tool_relay.json`` by the runner's comment relay.
|
|
_MCP_BRIDGE_CONFIG_FILE = "bridge.json"
|
|
# AP-routing snapshot the detached cost-approval popup process reads to resolve
|
|
# the elicitation against the Omnigent server (mirrors codex-native's
|
|
# ``policy_hook.json``; consumed by ``omnigent.native_cost_popup``).
|
|
_COST_POPUP_CONFIG_FILE = "cost_popup.json"
|
|
# Filename of the opencode plugin that bridges opencode's lifecycle hooks to the
|
|
# Omnigent policy engine (REQUEST + TOOL_RESULT phases the reactive
|
|
# ``permission.asked`` path can't reach).
|
|
_POLICY_PLUGIN_FILE = "omnigent-policy.js"
|
|
|
|
# The plugin source. opencode loads it (registered by absolute path in the
|
|
# synthesized ``opencode.json`` ``plugin`` field) and iterates the module's
|
|
# function exports as plugins (legacy shape). It reads its Omnigent coordinates
|
|
# from env the runner stamps on ``opencode serve`` and POSTs each hook to
|
|
# ``/v1/sessions/{id}/policies/evaluate`` — the SAME endpoint + ``PHASE_*``
|
|
# contract claude-native's ``UserPromptSubmit`` / ``PostToolUse`` hooks use.
|
|
# Best-effort: any transport error fails OPEN (never locks the session); only an
|
|
# explicit ``POLICY_ACTION_DENY`` blocks a prompt (throw) or withholds a tool
|
|
# result (redact). Raw string so the JS ``\n`` / regex escapes survive verbatim.
|
|
_OPENCODE_POLICY_PLUGIN_JS = r"""
|
|
// Omnigent policy bridge for opencode-native (generated; do not edit).
|
|
// Forwards opencode lifecycle hooks to the Omnigent policy engine so
|
|
// REQUEST-phase (prompt-submit) and TOOL_RESULT-phase policies enforce — the
|
|
// phases the reactive permission.asked path cannot reach.
|
|
const BASE = (process.env.OMNIGENT_POLICY_URL || "").replace(/\/+$/, "");
|
|
const SESSION = process.env.OMNIGENT_SESSION_ID || "";
|
|
// Full routing header map (Authorization + workspace / deployment routing
|
|
// selectors) baked by the runner so this out-of-process plugin's POSTs reach the
|
|
// SAME server instance as the runner.
|
|
let POLICY_HEADERS = {};
|
|
try {
|
|
POLICY_HEADERS = JSON.parse(process.env.OMNIGENT_POLICY_HEADERS || "{}") || {};
|
|
} catch (e) {
|
|
POLICY_HEADERS = {};
|
|
}
|
|
const TIMEOUT_MS = 600000;
|
|
|
|
async function evaluate(type, target, data) {
|
|
// Returns {result, reason}. Not wired (no server/session) -> no-op allow.
|
|
if (!BASE || !SESSION) return { result: "ALLOW" };
|
|
const url = BASE + "/v1/sessions/" + encodeURIComponent(SESSION) + "/policies/evaluate";
|
|
const headers = { "content-type": "application/json", ...POLICY_HEADERS };
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method: "POST",
|
|
headers: headers,
|
|
body: JSON.stringify({ event: { type: type, target: target || "", data: data } }),
|
|
signal: controller.signal,
|
|
});
|
|
if (!resp.ok) return { result: "ALLOW" };
|
|
const body = await resp.json();
|
|
return body && typeof body === "object" ? body : { result: "ALLOW" };
|
|
} catch (e) {
|
|
// Server unreachable / timeout: fail OPEN so a transient blip can't lock
|
|
// the session. The web approval card (if any) stays parked server-side.
|
|
return { result: "ALLOW" };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function joinText(parts) {
|
|
if (!Array.isArray(parts)) return "";
|
|
const out = [];
|
|
for (const p of parts) {
|
|
if (p && p.type === "text" && typeof p.text === "string") out.push(p.text);
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
|
|
export const OmnigentPolicyPlugin = async () => ({
|
|
// REQUEST phase: gate the prompt before the model sees it. A DENY throws,
|
|
// which opencode surfaces as an aborted turn (true block). On a web-injected
|
|
// prompt the server auto-allows (it was gated at injection), so this only
|
|
// gates TUI-typed prompts.
|
|
"chat.message": async (_input, output) => {
|
|
const text = output ? joinText(output.parts) : "";
|
|
if (!text) return;
|
|
// ``data`` is the {"text": ...} dict the server's _build_evaluation_context
|
|
// expects for REQUEST (same shape claude's UserPromptSubmit hook sends);
|
|
// a bare string 500s the evaluate endpoint and fails the gate open.
|
|
const verdict = await evaluate("PHASE_REQUEST", "", { text: text });
|
|
if (verdict.result === "POLICY_ACTION_DENY") {
|
|
// opencode renders any thrown chat.message error as a generic 500 in the
|
|
// TUI ("Unexpected server error") — its middleware hardcodes that. We
|
|
// can't change the TUI text from a plugin, but the thrown message is
|
|
// written to opencode's session log, so carry the policy reason there.
|
|
throw new Error(
|
|
"Omnigent policy blocked this prompt: " + (verdict.reason || "request denied"),
|
|
);
|
|
}
|
|
},
|
|
// TOOL_RESULT phase: gate/redact the tool output before the model sees it.
|
|
// The tool already ran; a DENY withholds its output (the TOOL_RESULT-phase
|
|
// suppress semantics) rather than aborting the turn.
|
|
"tool.execute.after": async (input, output) => {
|
|
if (!output) return;
|
|
const verdict = await evaluate(
|
|
"PHASE_TOOL_RESULT",
|
|
input && input.tool,
|
|
{ result: output.output },
|
|
);
|
|
if (verdict.result === "POLICY_ACTION_DENY") {
|
|
output.output = "[Omnigent policy withheld this tool result: " +
|
|
(verdict.reason || "denied") + "]";
|
|
}
|
|
},
|
|
});
|
|
"""
|
|
|
|
|
|
def write_opencode_policy_plugin(bridge_dir: Path) -> Path:
|
|
"""
|
|
Write the Omnigent policy-bridge plugin into *bridge_dir* and return its path.
|
|
|
|
The runner registers the returned path in the synthesized ``opencode.json``
|
|
``plugin`` field and stamps ``OMNIGENT_POLICY_URL`` / ``OMNIGENT_SESSION_ID``
|
|
/ ``OMNIGENT_POLICY_HEADERS`` on the ``opencode serve`` process so the plugin
|
|
can reach ``/policies/evaluate`` with workspace / deployment routing.
|
|
Overwritten each launch so a code update ships without stale plugin files.
|
|
|
|
:param bridge_dir: OpenCode-native bridge directory.
|
|
:returns: The written plugin file path (absolute).
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = bridge_dir / _POLICY_PLUGIN_FILE
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_POLICY_PLUGIN_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write(_OPENCODE_POLICY_PLUGIN_JS)
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
return path
|
|
|
|
|
|
_STATE_VERSION = 1
|
|
_BRIDGE_ROOT = Path.home() / ".omnigent" / "opencode-native"
|
|
_ID_HASH_CHARS = 32
|
|
|
|
|
|
def bridge_root() -> Path:
|
|
"""
|
|
Return the configured OpenCode-native bridge root.
|
|
|
|
Tests may monkeypatch :data:`_BRIDGE_ROOT` to isolate bridge files.
|
|
|
|
:returns: Absolute root for OpenCode-native bridge directories, e.g.
|
|
``Path("~/.omnigent/opencode-native")``.
|
|
"""
|
|
return _BRIDGE_ROOT
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpenCodeNativeBridgeState:
|
|
"""
|
|
Runtime state shared by the native OpenCode wrapper and harness.
|
|
|
|
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
|
|
:param server_base_url: Loopback base URL of ``opencode serve``, e.g.
|
|
``"http://127.0.0.1:49231"``.
|
|
:param opencode_session_id: OpenCode session id, e.g. ``"ses_abc123"``.
|
|
:param auth_secret: ``OPENCODE_SERVER_PASSWORD`` for basic auth, or
|
|
``None`` when the server runs without auth.
|
|
:param xdg_data_home: ``XDG_DATA_HOME`` the server runs with.
|
|
:param xdg_config_home: ``XDG_CONFIG_HOME`` the server runs with.
|
|
:param active_message_id: OpenCode assistant message id of the active
|
|
turn, or ``None`` when idle.
|
|
:param status: Coarse status, ``"idle"`` or ``"busy"``.
|
|
:param model_override: Persisted model override, e.g.
|
|
``"anthropic/claude-opus-4"``, or ``None``.
|
|
:param workspace: Workspace cwd the session runs in.
|
|
:param last_event_id: Last SSE event id seen, for resume/debug.
|
|
"""
|
|
|
|
session_id: str
|
|
server_base_url: str
|
|
opencode_session_id: str
|
|
auth_secret: str | None = None
|
|
xdg_data_home: str | None = None
|
|
xdg_config_home: str | None = None
|
|
active_message_id: str | None = None
|
|
status: str = "idle"
|
|
model_override: str | None = None
|
|
workspace: str | None = None
|
|
last_event_id: str | None = None
|
|
|
|
def auth_headers(self) -> dict[str, str]:
|
|
"""
|
|
Build basic-auth headers for the OpenCode server.
|
|
|
|
:returns: ``{"Authorization": "Basic ..."}`` when an auth secret
|
|
is set, otherwise an empty dict.
|
|
"""
|
|
return auth_headers_for_secret(self.auth_secret)
|
|
|
|
|
|
def auth_headers_for_secret(secret: str | None) -> dict[str, str]:
|
|
"""
|
|
Build OpenCode basic-auth headers for a server password.
|
|
|
|
:param secret: The ``OPENCODE_SERVER_PASSWORD`` value, or ``None``.
|
|
:returns: ``{"Authorization": "Basic <b64(user:secret)>"}`` or ``{}``.
|
|
"""
|
|
if not secret:
|
|
return {}
|
|
raw = f"{OPENCODE_DEFAULT_USERNAME}:{secret}".encode()
|
|
token = base64.b64encode(raw).decode("ascii")
|
|
return {"Authorization": f"Basic {token}"}
|
|
|
|
|
|
def bridge_dir_for_bridge_id(bridge_id: str) -> Path:
|
|
"""
|
|
Return the bridge directory for an OpenCode-native bridge id.
|
|
|
|
:param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``.
|
|
:returns: Absolute bridge directory under
|
|
``~/.omnigent/opencode-native``.
|
|
"""
|
|
digest = hashlib.sha256(bridge_id.encode("utf-8")).hexdigest()[:_ID_HASH_CHARS]
|
|
return _BRIDGE_ROOT / digest
|
|
|
|
|
|
def build_opencode_native_spawn_env(
|
|
conversation_id: str,
|
|
*,
|
|
bridge_id: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""
|
|
Build spawn env for the ``opencode-native`` harness process.
|
|
|
|
:param conversation_id: Omnigent conversation id, e.g.
|
|
``"conv_abc123"``.
|
|
:param bridge_id: Opaque bridge id; ``None`` uses *conversation_id*.
|
|
:returns: Environment variables the OpenCode-native executor needs.
|
|
"""
|
|
resolved_bridge_id = bridge_id or conversation_id
|
|
return {
|
|
OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR: str(bridge_dir_for_bridge_id(resolved_bridge_id)),
|
|
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR: conversation_id,
|
|
}
|
|
|
|
|
|
def prepare_bridge_dir(bridge_id: str) -> Path:
|
|
"""
|
|
Create the bridge directory (and XDG roots) for *bridge_id*.
|
|
|
|
:param bridge_id: Opaque bridge id, e.g. ``"conv_abc123"``.
|
|
:returns: Prepared absolute bridge directory.
|
|
"""
|
|
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
os.chmod(bridge_dir, 0o700)
|
|
xdg_data_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
xdg_config_home_for_bridge_dir(bridge_dir).mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
return bridge_dir
|
|
|
|
|
|
def write_relay_bridge_config(bridge_dir: Path) -> None:
|
|
"""
|
|
Write a minimal ``bridge.json`` so the shared ``serve-mcp`` can boot.
|
|
|
|
The shared ``omnigent.claude_native_bridge serve-mcp`` stdio server (which
|
|
opencode launches as a ``{type:"local"}`` MCP server) reads this file for an
|
|
auth token at startup; the relay tools themselves come from
|
|
``tool_relay.json`` (written by the runner's comment relay), so this carries
|
|
only a token — no ``workspace`` key, so no ``sys_os_*`` tools are served
|
|
(opencode owns its own filesystem tools). Mirrors
|
|
``codex_native_bridge.write_mcp_bridge_config``.
|
|
|
|
Idempotent: skips if a config already exists so a relaunch never rotates a
|
|
token the relay HTTP server was already started with.
|
|
|
|
:param bridge_dir: OpenCode-native bridge directory.
|
|
"""
|
|
config_path = bridge_dir / _MCP_BRIDGE_CONFIG_FILE
|
|
if config_path.exists():
|
|
return
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
payload = {"token": secrets.token_urlsafe(32)}
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_MCP_BRIDGE_CONFIG_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, sort_keys=True)
|
|
handle.write("\n")
|
|
os.replace(tmp_name, config_path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
|
|
|
|
def write_cost_popup_config(
|
|
bridge_dir: Path, *, ap_server_url: str, ap_auth_headers: dict[str, str]
|
|
) -> Path:
|
|
"""
|
|
Write the AP-routing snapshot the cost-approval popup reads.
|
|
|
|
The cost-budget approval modal runs as a detached
|
|
``omnigent.native_cost_popup`` subprocess inside a ``tmux display-popup`` on
|
|
the opencode pane; it must POST the verdict to the Omnigent server but cannot
|
|
inherit the forwarder's in-memory client, so the base URL + a one-shot auth
|
|
header snapshot are persisted here (same contract as codex-native's
|
|
``policy_hook.json``). Rewritten on each checkpoint so the token is fresh.
|
|
|
|
:param bridge_dir: OpenCode-native bridge directory.
|
|
:param ap_server_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
|
|
:param ap_auth_headers: Outbound auth headers, e.g.
|
|
``{"Authorization": "Bearer <token>"}``; empty for no-auth local mode.
|
|
:returns: The written config file path (passed to ``launch_cost_popup``).
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = bridge_dir / _COST_POPUP_CONFIG_FILE
|
|
payload = {"ap_server_url": ap_server_url, "ap_auth_headers": ap_auth_headers}
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_COST_POPUP_CONFIG_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, sort_keys=True)
|
|
handle.write("\n")
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
return path
|
|
|
|
|
|
def xdg_data_home_for_bridge_dir(bridge_dir: Path) -> Path:
|
|
"""
|
|
Return the per-session ``XDG_DATA_HOME`` for *bridge_dir*.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: Absolute ``XDG_DATA_HOME`` directory.
|
|
"""
|
|
return bridge_dir / _XDG_DATA_DIR
|
|
|
|
|
|
def xdg_config_home_for_bridge_dir(bridge_dir: Path) -> Path:
|
|
"""
|
|
Return the per-session ``XDG_CONFIG_HOME`` for *bridge_dir*.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: Absolute ``XDG_CONFIG_HOME`` directory.
|
|
"""
|
|
return bridge_dir / _XDG_CONFIG_DIR
|
|
|
|
|
|
def user_opencode_auth_path() -> Path:
|
|
"""
|
|
Return the user's real OpenCode ``auth.json`` path (not the per-session one).
|
|
|
|
Honors ``XDG_DATA_HOME`` (the runner's own env, which is the user's real
|
|
data home — the per-session override is set only on the spawned server),
|
|
defaulting to ``~/.local/share/opencode/auth.json``.
|
|
"""
|
|
xdg = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
|
|
return base / "opencode" / "auth.json"
|
|
|
|
|
|
def user_opencode_config_path() -> Path | None:
|
|
"""
|
|
Return the user's real OpenCode config path (not the per-session one).
|
|
|
|
Honors ``XDG_CONFIG_HOME`` (the runner's own env, which is the user's real
|
|
config home — the per-session override is set only on the spawned server),
|
|
defaulting to ``~/.config/opencode/opencode.jsonc``.
|
|
|
|
OpenCode accepts both ``.jsonc`` (with comments) and ``.json`` extensions;
|
|
the ``.jsonc`` variant is checked first.
|
|
"""
|
|
xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
cfg_dir = base / "opencode"
|
|
for name in ("opencode.jsonc", "opencode.json"):
|
|
path = cfg_dir / name
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def seed_opencode_auth(bridge_dir: Path) -> Path | None:
|
|
"""
|
|
Copy the user's OpenCode ``auth.json`` into the per-session ``XDG_DATA_HOME``.
|
|
|
|
The runner spawns ``opencode serve`` with a per-session ``XDG_DATA_HOME``
|
|
that isolates session state — but it also hides the user's
|
|
``opencode auth login`` credentials (in their real
|
|
``~/.local/share/opencode/auth.json``). Without those, the server can only
|
|
reach OpenCode's no-auth default model (``opencode/big-pickle``), so a
|
|
user-selected provider/model never takes effect. Copy the credentials in
|
|
(best-effort, ``0600``) so the user's providers — and any pinned model that
|
|
needs them — work. Refreshed on every spawn so re-logins propagate.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: The destination path written, or ``None`` when there is no
|
|
source ``auth.json`` or the copy fails.
|
|
"""
|
|
src = user_opencode_auth_path()
|
|
if not src.is_file():
|
|
return None
|
|
dest_dir = xdg_data_home_for_bridge_dir(bridge_dir) / "opencode"
|
|
try:
|
|
dest_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
dest = dest_dir / "auth.json"
|
|
shutil.copyfile(src, dest)
|
|
os.chmod(dest, 0o600)
|
|
except OSError:
|
|
return None
|
|
return dest
|
|
|
|
|
|
def auth_secret_path(bridge_dir: Path) -> Path:
|
|
"""
|
|
Return the auth-secret file path for *bridge_dir*.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: Absolute path of the ``auth.secret`` file.
|
|
"""
|
|
return bridge_dir / _AUTH_SECRET_FILE
|
|
|
|
|
|
def ensure_auth_secret(bridge_dir: Path) -> str:
|
|
"""
|
|
Read or mint the per-session OpenCode server password.
|
|
|
|
The secret is reused across server restarts for one bridge dir so a
|
|
resumed server keeps the same basic-auth credential the TUI/executor
|
|
were configured with. Written ``0600``.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: The server password (``OPENCODE_SERVER_PASSWORD``).
|
|
"""
|
|
path = auth_secret_path(bridge_dir)
|
|
try:
|
|
existing = path.read_text(encoding="utf-8").strip()
|
|
if existing:
|
|
return existing
|
|
except FileNotFoundError:
|
|
# No secret on disk yet: fall through to mint a fresh one below.
|
|
pass
|
|
except OSError:
|
|
# Secret exists but is unreadable: ignore and regenerate it below.
|
|
pass
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
secret = secrets.token_urlsafe(32)
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_AUTH_SECRET_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
handle.write(secret)
|
|
handle.write("\n")
|
|
os.chmod(tmp_name, 0o600)
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
return secret
|
|
|
|
|
|
def state_path(bridge_dir: Path) -> Path:
|
|
"""
|
|
Return the bridge state file path for *bridge_dir*.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: Absolute path of the ``state.json`` file.
|
|
"""
|
|
return bridge_dir / _STATE_FILE
|
|
|
|
|
|
def write_bridge_state(bridge_dir: Path, state: OpenCodeNativeBridgeState) -> None:
|
|
"""
|
|
Persist shared native OpenCode state atomically.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:param state: State payload to persist.
|
|
:returns: None.
|
|
"""
|
|
bridge_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
path = state_path(bridge_dir)
|
|
fd, tmp_name = tempfile.mkstemp(prefix=f"{_STATE_FILE}.", dir=str(bridge_dir))
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump(
|
|
{
|
|
"version": _STATE_VERSION,
|
|
"session_id": state.session_id,
|
|
"server_base_url": state.server_base_url,
|
|
"opencode_session_id": state.opencode_session_id,
|
|
"auth_secret": state.auth_secret,
|
|
"xdg_data_home": state.xdg_data_home,
|
|
"xdg_config_home": state.xdg_config_home,
|
|
"active_message_id": state.active_message_id,
|
|
"status": state.status,
|
|
"model_override": state.model_override,
|
|
"workspace": state.workspace,
|
|
"last_event_id": state.last_event_id,
|
|
},
|
|
handle,
|
|
sort_keys=True,
|
|
)
|
|
handle.write("\n")
|
|
os.replace(tmp_name, path)
|
|
finally:
|
|
if os.path.exists(tmp_name):
|
|
os.unlink(tmp_name)
|
|
|
|
|
|
def clear_bridge_state(bridge_dir: Path) -> None:
|
|
"""
|
|
Remove stale native OpenCode runtime state for a bridge directory.
|
|
|
|
New server launches reuse the same bridge directory for a conversation
|
|
id, but the old ``state.json`` may point at a server URL from a
|
|
previous process. Clear it before starting the new server so web
|
|
message forwarding waits for the new launch to publish its current URL
|
|
and session instead of injecting into stale state.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: None.
|
|
"""
|
|
try:
|
|
state_path(bridge_dir).unlink()
|
|
except FileNotFoundError:
|
|
return
|
|
|
|
|
|
def read_bridge_state(bridge_dir: Path) -> OpenCodeNativeBridgeState | None:
|
|
"""
|
|
Read shared native OpenCode bridge state.
|
|
|
|
Corrupt / partial JSON is treated as absent (returns ``None``) so a
|
|
half-written file never crashes a turn.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:returns: Parsed state, or ``None`` when no valid state exists.
|
|
"""
|
|
path = state_path(bridge_dir)
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
session_id = raw.get("session_id")
|
|
server_base_url = raw.get("server_base_url")
|
|
opencode_session_id = raw.get("opencode_session_id")
|
|
required = (session_id, server_base_url, opencode_session_id)
|
|
if not all(isinstance(value, str) and value for value in required):
|
|
return None
|
|
|
|
def _opt_str(key: str) -> str | None:
|
|
value = raw.get(key)
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
status = raw.get("status")
|
|
return OpenCodeNativeBridgeState(
|
|
session_id=session_id,
|
|
server_base_url=server_base_url,
|
|
opencode_session_id=opencode_session_id,
|
|
auth_secret=_opt_str("auth_secret"),
|
|
xdg_data_home=_opt_str("xdg_data_home"),
|
|
xdg_config_home=_opt_str("xdg_config_home"),
|
|
active_message_id=_opt_str("active_message_id"),
|
|
status=status if isinstance(status, str) and status else "idle",
|
|
model_override=_opt_str("model_override"),
|
|
workspace=_opt_str("workspace"),
|
|
last_event_id=_opt_str("last_event_id"),
|
|
)
|
|
|
|
|
|
def update_active_message_id(
|
|
bridge_dir: Path,
|
|
active_message_id: str | None,
|
|
*,
|
|
status: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Update the active OpenCode message id (and optionally status).
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:param active_message_id: Active assistant message id, or ``None``.
|
|
:param status: New coarse status (``"idle"`` / ``"busy"``); ``None``
|
|
leaves the existing status untouched.
|
|
:returns: None.
|
|
"""
|
|
state = read_bridge_state(bridge_dir)
|
|
if state is None:
|
|
return
|
|
import dataclasses
|
|
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
dataclasses.replace(
|
|
state,
|
|
active_message_id=active_message_id,
|
|
status=status if status is not None else state.status,
|
|
),
|
|
)
|
|
|
|
|
|
def update_last_event_id(bridge_dir: Path, last_event_id: str) -> None:
|
|
"""
|
|
Record the last SSE event id seen by the forwarder.
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:param last_event_id: Last SSE event id, e.g. ``"evt_..."``.
|
|
:returns: None.
|
|
"""
|
|
state = read_bridge_state(bridge_dir)
|
|
if state is None:
|
|
return
|
|
import dataclasses
|
|
|
|
write_bridge_state(bridge_dir, dataclasses.replace(state, last_event_id=last_event_id))
|
|
|
|
|
|
def update_model_override(bridge_dir: Path, model_override: str | None) -> bool:
|
|
"""
|
|
Persist a new per-session model override (Omnigent→opencode model switch).
|
|
|
|
opencode has no session-level model setting — the model is a per-prompt
|
|
field — so the executor reads ``model_override`` from this bridge state on
|
|
every web-injected prompt (see
|
|
``OpenCodeNativeExecutor._build_prompt_with_model_override``). Updating it
|
|
here makes the NEXT injected turn use the new model. A blank/whitespace
|
|
value clears the override (fall back to opencode's own default).
|
|
|
|
:param bridge_dir: Native OpenCode bridge directory.
|
|
:param model_override: New qualified model id (``provider/model``), or
|
|
``None`` / blank to clear.
|
|
:returns: ``True`` when the state existed and was updated, ``False`` when
|
|
no bridge state is present (server not launched yet).
|
|
"""
|
|
state = read_bridge_state(bridge_dir)
|
|
if state is None:
|
|
return False
|
|
import dataclasses
|
|
|
|
normalized = model_override.strip() if isinstance(model_override, str) else None
|
|
write_bridge_state(bridge_dir, dataclasses.replace(state, model_override=normalized or None))
|
|
return True
|