coding-bridge-agent 2026.6.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coding_bridge_agent/__init__.py +8 -0
- coding_bridge_agent/__main__.py +7 -0
- coding_bridge_agent/cli.py +185 -0
- coding_bridge_agent/config.py +82 -0
- coding_bridge_agent/connection.py +227 -0
- coding_bridge_agent/pairing.py +52 -0
- coding_bridge_agent/permissions.py +47 -0
- coding_bridge_agent/protocol.py +78 -0
- coding_bridge_agent/providers/__init__.py +15 -0
- coding_bridge_agent/providers/base.py +30 -0
- coding_bridge_agent/providers/claude.py +161 -0
- coding_bridge_agent/session.py +113 -0
- coding_bridge_agent/store.py +37 -0
- coding_bridge_agent-2026.6.8.0.dist-info/METADATA +170 -0
- coding_bridge_agent-2026.6.8.0.dist-info/RECORD +18 -0
- coding_bridge_agent-2026.6.8.0.dist-info/WHEEL +4 -0
- coding_bridge_agent-2026.6.8.0.dist-info/entry_points.txt +2 -0
- coding_bridge_agent-2026.6.8.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Coding Bridge Agent — node daemon for AceDataCloud Coding Bridge.
|
|
2
|
+
|
|
3
|
+
Runs on a developer's own machine, connects out to the coding-bridge relay, and
|
|
4
|
+
drives local Claude Code sessions on behalf of an authenticated browser. All
|
|
5
|
+
code execution stays local; the bridge only relays messages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Command-line interface: ``pair``, ``run``, ``up``, ``status``, ``logout``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import store
|
|
11
|
+
from .config import Settings
|
|
12
|
+
from .connection import BridgeConnection
|
|
13
|
+
from .pairing import PairingError, poll_for_token, start_pairing
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_settings(args: argparse.Namespace) -> Settings:
|
|
17
|
+
settings = Settings.from_env()
|
|
18
|
+
if getattr(args, "bridge_url", None):
|
|
19
|
+
settings.bridge_url = args.bridge_url
|
|
20
|
+
if getattr(args, "name", None):
|
|
21
|
+
settings.node_name = args.name
|
|
22
|
+
if getattr(args, "config_dir", None):
|
|
23
|
+
settings.config_dir = Path(args.config_dir).expanduser()
|
|
24
|
+
if getattr(args, "model", None):
|
|
25
|
+
settings.default_model = args.model
|
|
26
|
+
if getattr(args, "cwd", None):
|
|
27
|
+
settings.default_cwd = args.cwd
|
|
28
|
+
if getattr(args, "permission_timeout", None) is not None:
|
|
29
|
+
settings.permission_timeout = args.permission_timeout
|
|
30
|
+
return settings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _print_pairing(settings: Settings, pair_code: str) -> None:
|
|
34
|
+
claim_base = settings.claim_url_template.split("?", 1)[0]
|
|
35
|
+
claim_url = settings.claim_url_template.format(code=pair_code)
|
|
36
|
+
print()
|
|
37
|
+
print(" Pair this machine with your Ace account:")
|
|
38
|
+
print(f" 1. Open {claim_base}")
|
|
39
|
+
print(f" 2. Enter pair code: {pair_code}")
|
|
40
|
+
print(f" or open directly: {claim_url}")
|
|
41
|
+
_print_qr(claim_url)
|
|
42
|
+
print()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_qr(data: str) -> None:
|
|
46
|
+
try:
|
|
47
|
+
import qrcode
|
|
48
|
+
except ImportError:
|
|
49
|
+
return
|
|
50
|
+
qr = qrcode.QRCode(border=1)
|
|
51
|
+
qr.add_data(data)
|
|
52
|
+
qr.make(fit=True)
|
|
53
|
+
qr.print_ascii(invert=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _do_pair(settings: Settings) -> str:
|
|
57
|
+
pair_code, expires_in = await start_pairing(settings)
|
|
58
|
+
_print_pairing(settings, pair_code)
|
|
59
|
+
print(f" Waiting for confirmation (expires in {expires_in}s)...")
|
|
60
|
+
deadline = asyncio.get_running_loop().time() + expires_in
|
|
61
|
+
token = await poll_for_token(settings, pair_code, deadline=deadline)
|
|
62
|
+
store.save(
|
|
63
|
+
settings.credentials_path,
|
|
64
|
+
{"node_token": token, "node_name": settings.node_name, "bridge_url": settings.bridge_url},
|
|
65
|
+
)
|
|
66
|
+
print(f" Paired. Credentials saved to {settings.credentials_path}")
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _run_connection(settings: Settings, token: str) -> None:
|
|
71
|
+
connection = BridgeConnection(settings, token)
|
|
72
|
+
print(f" Coding Bridge agent running. Node: {settings.node_name}. Press Ctrl-C to stop.")
|
|
73
|
+
try:
|
|
74
|
+
await connection.run()
|
|
75
|
+
finally:
|
|
76
|
+
await connection.aclose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_pair(args: argparse.Namespace) -> None:
|
|
80
|
+
settings = _build_settings(args)
|
|
81
|
+
try:
|
|
82
|
+
asyncio.run(_do_pair(settings))
|
|
83
|
+
except PairingError as exc:
|
|
84
|
+
print(f"Pairing failed: {exc}", file=sys.stderr)
|
|
85
|
+
raise SystemExit(1) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
89
|
+
settings = _build_settings(args)
|
|
90
|
+
creds = store.load(settings.credentials_path)
|
|
91
|
+
if not creds or not creds.get("node_token"):
|
|
92
|
+
print("Not paired. Run `coding-bridge-agent pair` first.", file=sys.stderr)
|
|
93
|
+
raise SystemExit(1)
|
|
94
|
+
asyncio.run(_run_connection(settings, creds["node_token"]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_up(args: argparse.Namespace) -> None:
|
|
98
|
+
settings = _build_settings(args)
|
|
99
|
+
creds = store.load(settings.credentials_path)
|
|
100
|
+
token = creds.get("node_token") if creds else None
|
|
101
|
+
|
|
102
|
+
async def _go() -> None:
|
|
103
|
+
nonlocal token
|
|
104
|
+
if not token:
|
|
105
|
+
token = await _do_pair(settings)
|
|
106
|
+
await _run_connection(settings, token)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
asyncio.run(_go())
|
|
110
|
+
except PairingError as exc:
|
|
111
|
+
print(f"Pairing failed: {exc}", file=sys.stderr)
|
|
112
|
+
raise SystemExit(1) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_logout(args: argparse.Namespace) -> None:
|
|
116
|
+
settings = _build_settings(args)
|
|
117
|
+
removed = store.clear(settings.credentials_path)
|
|
118
|
+
print("Credentials removed." if removed else "No credentials found.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
122
|
+
settings = _build_settings(args)
|
|
123
|
+
creds = store.load(settings.credentials_path)
|
|
124
|
+
paired = bool(creds and creds.get("node_token"))
|
|
125
|
+
print(f"Bridge URL : {settings.bridge_url}")
|
|
126
|
+
print(f"Node name : {settings.node_name}")
|
|
127
|
+
print(f"Config dir : {settings.config_dir}")
|
|
128
|
+
print(f"Paired : {'yes' if paired else 'no'}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
132
|
+
parser.add_argument("--model", help="Default Claude model for new sessions")
|
|
133
|
+
parser.add_argument("--cwd", help="Default working directory for new sessions")
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--permission-timeout",
|
|
136
|
+
type=float,
|
|
137
|
+
dest="permission_timeout",
|
|
138
|
+
help="Seconds to wait for a permission decision (0 = forever)",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main(argv: list[str] | None = None) -> None:
|
|
143
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
144
|
+
common.add_argument("--bridge-url", default=argparse.SUPPRESS, help="coding-bridge base URL")
|
|
145
|
+
common.add_argument("--name", default=argparse.SUPPRESS, help="Display name for this node")
|
|
146
|
+
common.add_argument(
|
|
147
|
+
"--config-dir", default=argparse.SUPPRESS, help="Where credentials are stored"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
prog="coding-bridge-agent",
|
|
152
|
+
description="Run Claude Code on this machine, driven from the AceDataCloud web app.",
|
|
153
|
+
parents=[common],
|
|
154
|
+
)
|
|
155
|
+
parser.set_defaults(func=cmd_up)
|
|
156
|
+
|
|
157
|
+
sub = parser.add_subparsers(dest="command")
|
|
158
|
+
|
|
159
|
+
p_up = sub.add_parser("up", help="Pair if needed, then run (default)", parents=[common])
|
|
160
|
+
_add_run_args(p_up)
|
|
161
|
+
p_up.set_defaults(func=cmd_up)
|
|
162
|
+
|
|
163
|
+
sub.add_parser("pair", help="Pair this machine and exit", parents=[common]).set_defaults(
|
|
164
|
+
func=cmd_pair
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
p_run = sub.add_parser("run", help="Run using stored credentials", parents=[common])
|
|
168
|
+
_add_run_args(p_run)
|
|
169
|
+
p_run.set_defaults(func=cmd_run)
|
|
170
|
+
|
|
171
|
+
sub.add_parser(
|
|
172
|
+
"status", help="Show configuration and pairing state", parents=[common]
|
|
173
|
+
).set_defaults(func=cmd_status)
|
|
174
|
+
sub.add_parser("logout", help="Remove stored credentials", parents=[common]).set_defaults(
|
|
175
|
+
func=cmd_logout
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
args = parser.parse_args(argv)
|
|
179
|
+
logging.basicConfig(
|
|
180
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
args.func(args)
|
|
184
|
+
except KeyboardInterrupt:
|
|
185
|
+
print("\nStopped.")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Runtime configuration for the node daemon."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
DEFAULT_BRIDGE_URL = "https://coding-bridge.acedata.cloud"
|
|
10
|
+
DEFAULT_CONFIG_DIR = "~/.ace-bridge"
|
|
11
|
+
DEFAULT_CLAIM_URL = "https://nexior.acedata.cloud/bridge?code={code}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_node_name() -> str:
|
|
15
|
+
try:
|
|
16
|
+
return socket.gethostname() or "node"
|
|
17
|
+
except OSError:
|
|
18
|
+
return "node"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Settings:
|
|
23
|
+
"""All tunables, sourced from env or CLI flags."""
|
|
24
|
+
|
|
25
|
+
bridge_url: str = DEFAULT_BRIDGE_URL
|
|
26
|
+
node_name: str = ""
|
|
27
|
+
config_dir: Path = Path(DEFAULT_CONFIG_DIR)
|
|
28
|
+
heartbeat_interval: float = 15.0
|
|
29
|
+
reconnect_min: float = 1.0
|
|
30
|
+
reconnect_max: float = 30.0
|
|
31
|
+
permission_timeout: float = 300.0 # 0 → wait indefinitely for the user
|
|
32
|
+
default_cwd: str = ""
|
|
33
|
+
default_model: str | None = None
|
|
34
|
+
claim_url_template: str = DEFAULT_CLAIM_URL
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if not self.node_name:
|
|
38
|
+
self.node_name = _default_node_name()
|
|
39
|
+
if not self.default_cwd:
|
|
40
|
+
self.default_cwd = os.getcwd()
|
|
41
|
+
self.config_dir = Path(self.config_dir).expanduser()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def _base(self) -> str:
|
|
45
|
+
return self.bridge_url.rstrip("/")
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def ws_node_url(self) -> str:
|
|
49
|
+
base = self._base
|
|
50
|
+
if base.startswith("https://"):
|
|
51
|
+
return "wss://" + base[len("https://") :] + "/ws/node"
|
|
52
|
+
if base.startswith("http://"):
|
|
53
|
+
return "ws://" + base[len("http://") :] + "/ws/node"
|
|
54
|
+
return base + "/ws/node"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def pair_start_url(self) -> str:
|
|
58
|
+
return f"{self._base}/pair/start"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def pair_poll_url(self) -> str:
|
|
62
|
+
return f"{self._base}/pair/poll"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def credentials_path(self) -> Path:
|
|
66
|
+
return self.config_dir / "credentials.json"
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls) -> Settings:
|
|
70
|
+
def _f(name: str, default: float) -> float:
|
|
71
|
+
raw = os.environ.get(name)
|
|
72
|
+
return float(raw) if raw else default
|
|
73
|
+
|
|
74
|
+
return cls(
|
|
75
|
+
bridge_url=os.environ.get("CODING_BRIDGE_URL", DEFAULT_BRIDGE_URL),
|
|
76
|
+
node_name=os.environ.get("CODING_BRIDGE_NODE_NAME", ""),
|
|
77
|
+
config_dir=Path(os.environ.get("CODING_BRIDGE_CONFIG_DIR", DEFAULT_CONFIG_DIR)),
|
|
78
|
+
heartbeat_interval=_f("CODING_BRIDGE_HEARTBEAT_INTERVAL", 15.0),
|
|
79
|
+
permission_timeout=_f("CODING_BRIDGE_PERMISSION_TIMEOUT", 300.0),
|
|
80
|
+
default_model=os.environ.get("CODING_BRIDGE_MODEL") or None,
|
|
81
|
+
claim_url_template=os.environ.get("CODING_BRIDGE_CLAIM_URL", DEFAULT_CLAIM_URL),
|
|
82
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""WebSocket client toward the coding-bridge relay.
|
|
2
|
+
|
|
3
|
+
Maintains a single outbound connection (``/ws/node?token=...``), heartbeats every
|
|
4
|
+
``heartbeat_interval`` seconds, reconnects with backoff, and dispatches browser
|
|
5
|
+
commands to local sessions. The node never accepts inbound connections.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
import websockets
|
|
14
|
+
|
|
15
|
+
from . import protocol
|
|
16
|
+
from .config import Settings
|
|
17
|
+
from .protocol import Action, Event, event_payload
|
|
18
|
+
from .providers import default_provider_factory
|
|
19
|
+
from .providers.base import ProviderFactory
|
|
20
|
+
from .session import Session
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("coding-bridge-agent.connection")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthFailed(Exception):
|
|
26
|
+
"""The bridge rejected the node token; the user must re-pair."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BridgeConnection:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
settings: Settings,
|
|
33
|
+
node_token: str,
|
|
34
|
+
*,
|
|
35
|
+
provider_factory: ProviderFactory | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.settings = settings
|
|
38
|
+
self.node_token = node_token
|
|
39
|
+
self.provider_factory = provider_factory or default_provider_factory(settings)
|
|
40
|
+
self.sessions: dict[str, Session] = {}
|
|
41
|
+
self._ws: websockets.WebSocketClientProtocol | None = None
|
|
42
|
+
self._stop = asyncio.Event()
|
|
43
|
+
|
|
44
|
+
def capabilities(self) -> list[str]:
|
|
45
|
+
return ["claude"]
|
|
46
|
+
|
|
47
|
+
def stop(self) -> None:
|
|
48
|
+
self._stop.set()
|
|
49
|
+
|
|
50
|
+
async def run(self) -> None:
|
|
51
|
+
delay = self.settings.reconnect_min
|
|
52
|
+
while not self._stop.is_set():
|
|
53
|
+
try:
|
|
54
|
+
await self._connect_once()
|
|
55
|
+
delay = self.settings.reconnect_min
|
|
56
|
+
except AuthFailed:
|
|
57
|
+
logger.error("node token rejected by bridge; re-run `coding-bridge-agent pair`")
|
|
58
|
+
return
|
|
59
|
+
except asyncio.CancelledError:
|
|
60
|
+
raise
|
|
61
|
+
except Exception as exc: # noqa: BLE001 - keep retrying on transient errors
|
|
62
|
+
logger.warning("bridge connection error: %s", exc)
|
|
63
|
+
if self._stop.is_set():
|
|
64
|
+
break
|
|
65
|
+
await asyncio.sleep(delay)
|
|
66
|
+
delay = min(delay * 2, self.settings.reconnect_max)
|
|
67
|
+
|
|
68
|
+
async def _connect_once(self) -> None:
|
|
69
|
+
url = f"{self.settings.ws_node_url}?token={self.node_token}"
|
|
70
|
+
try:
|
|
71
|
+
async with websockets.connect(
|
|
72
|
+
url, max_size=None, ping_interval=20, ping_timeout=20
|
|
73
|
+
) as ws:
|
|
74
|
+
self._ws = ws
|
|
75
|
+
logger.info("connected to bridge as node %s", self.node_token[:12])
|
|
76
|
+
heartbeat = asyncio.create_task(self._heartbeat())
|
|
77
|
+
try:
|
|
78
|
+
async for raw in ws:
|
|
79
|
+
await self._on_raw(raw)
|
|
80
|
+
finally:
|
|
81
|
+
heartbeat.cancel()
|
|
82
|
+
self._ws = None
|
|
83
|
+
except websockets.exceptions.ConnectionClosed as exc:
|
|
84
|
+
if getattr(exc, "code", None) == 4401:
|
|
85
|
+
raise AuthFailed() from exc
|
|
86
|
+
raise
|
|
87
|
+
except Exception as exc: # noqa: BLE001 - classify handshake auth failures
|
|
88
|
+
if _is_auth_error(exc):
|
|
89
|
+
raise AuthFailed() from exc
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
async def _heartbeat(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
await asyncio.sleep(self.settings.heartbeat_interval)
|
|
96
|
+
await self._send_envelope(
|
|
97
|
+
protocol.envelope(
|
|
98
|
+
protocol.NODE_HEARTBEAT, {"capabilities": self.capabilities()}
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
except (asyncio.CancelledError, websockets.exceptions.ConnectionClosed):
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
async def _send_envelope(self, env: dict) -> None:
|
|
105
|
+
ws = self._ws
|
|
106
|
+
if ws is None:
|
|
107
|
+
return
|
|
108
|
+
await ws.send(json.dumps(env))
|
|
109
|
+
|
|
110
|
+
async def send_payload(self, payload: dict) -> None:
|
|
111
|
+
"""Send an inner event payload toward the owner's browsers."""
|
|
112
|
+
await self._send_envelope(
|
|
113
|
+
protocol.envelope(protocol.NODE_TO_BROWSER, payload, from_node=self.node_token)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def _on_raw(self, raw: str | bytes) -> None:
|
|
117
|
+
try:
|
|
118
|
+
message = json.loads(raw)
|
|
119
|
+
except (json.JSONDecodeError, TypeError):
|
|
120
|
+
return
|
|
121
|
+
msg_type = message.get("type")
|
|
122
|
+
if msg_type == protocol.BROWSER_TO_NODE:
|
|
123
|
+
await self._dispatch(message.get("payload") or {})
|
|
124
|
+
elif msg_type == protocol.NODE_REGISTERED:
|
|
125
|
+
logger.info("registered with bridge")
|
|
126
|
+
|
|
127
|
+
async def _dispatch(self, payload: dict) -> None:
|
|
128
|
+
action = payload.get("action")
|
|
129
|
+
session_id = payload.get("session_id")
|
|
130
|
+
try:
|
|
131
|
+
if action == Action.SESSION_START:
|
|
132
|
+
await self._start_session(payload)
|
|
133
|
+
elif action == Action.SESSION_SEND:
|
|
134
|
+
await self._send_to_session(session_id, payload.get("prompt", ""))
|
|
135
|
+
elif action == Action.SESSION_INTERRUPT:
|
|
136
|
+
await self._interrupt_session(session_id)
|
|
137
|
+
elif action == Action.SESSION_CLOSE:
|
|
138
|
+
await self._close_session(session_id)
|
|
139
|
+
elif action == Action.PERMISSION_RESOLVE:
|
|
140
|
+
self._resolve_permission(payload)
|
|
141
|
+
elif action == Action.SESSIONS_LIST:
|
|
142
|
+
await self._send_snapshot()
|
|
143
|
+
elif action == Action.PING:
|
|
144
|
+
await self.send_payload(event_payload(Event.PONG))
|
|
145
|
+
else:
|
|
146
|
+
await self.send_payload(
|
|
147
|
+
event_payload(
|
|
148
|
+
Event.SESSION_ERROR, session_id, message=f"unknown action: {action}"
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
except Exception as exc: # noqa: BLE001 - report, never crash the node
|
|
152
|
+
logger.warning("dispatch error (%s): %s", action, exc)
|
|
153
|
+
await self.send_payload(
|
|
154
|
+
event_payload(Event.SESSION_ERROR, session_id, message=str(exc))
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _start_session(self, payload: dict) -> None:
|
|
158
|
+
session_id = payload.get("session_id")
|
|
159
|
+
if not session_id:
|
|
160
|
+
await self.send_payload(
|
|
161
|
+
event_payload(Event.SESSION_ERROR, None, message="session_id required")
|
|
162
|
+
)
|
|
163
|
+
return
|
|
164
|
+
existing = self.sessions.get(session_id)
|
|
165
|
+
if existing is not None:
|
|
166
|
+
await existing.send(payload.get("prompt", ""))
|
|
167
|
+
return
|
|
168
|
+
session = Session(
|
|
169
|
+
session_id,
|
|
170
|
+
self.provider_factory,
|
|
171
|
+
self.send_payload,
|
|
172
|
+
self.settings,
|
|
173
|
+
cwd=payload.get("cwd") or self.settings.default_cwd,
|
|
174
|
+
model=payload.get("model") or self.settings.default_model,
|
|
175
|
+
permission_mode=payload.get("permission_mode") or "default",
|
|
176
|
+
)
|
|
177
|
+
self.sessions[session_id] = session
|
|
178
|
+
await session.start(payload.get("prompt", ""))
|
|
179
|
+
|
|
180
|
+
async def _send_to_session(self, session_id: str | None, prompt: str) -> None:
|
|
181
|
+
session = self.sessions.get(session_id) if session_id else None
|
|
182
|
+
if session is None:
|
|
183
|
+
await self.send_payload(
|
|
184
|
+
event_payload(Event.SESSION_ERROR, session_id, message="unknown session")
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
await session.send(prompt)
|
|
188
|
+
|
|
189
|
+
async def _interrupt_session(self, session_id: str | None) -> None:
|
|
190
|
+
session = self.sessions.get(session_id) if session_id else None
|
|
191
|
+
if session is not None:
|
|
192
|
+
await session.interrupt()
|
|
193
|
+
|
|
194
|
+
async def _close_session(self, session_id: str | None) -> None:
|
|
195
|
+
session = self.sessions.pop(session_id, None) if session_id else None
|
|
196
|
+
if session is not None:
|
|
197
|
+
await session.close()
|
|
198
|
+
|
|
199
|
+
def _resolve_permission(self, payload: dict) -> None:
|
|
200
|
+
request_id = payload.get("request_id")
|
|
201
|
+
decision = "allow" if payload.get("decision") == "allow" else "deny"
|
|
202
|
+
if not request_id:
|
|
203
|
+
return
|
|
204
|
+
for session in self.sessions.values():
|
|
205
|
+
if session.resolve_permission(request_id, decision):
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
async def _send_snapshot(self) -> None:
|
|
209
|
+
await self.send_payload(
|
|
210
|
+
event_payload(
|
|
211
|
+
Event.SESSIONS_SNAPSHOT,
|
|
212
|
+
sessions=[s.info() for s in self.sessions.values()],
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def aclose(self) -> None:
|
|
217
|
+
for session in list(self.sessions.values()):
|
|
218
|
+
await session.close()
|
|
219
|
+
self.sessions.clear()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_auth_error(exc: Exception) -> bool:
|
|
223
|
+
code = getattr(exc, "status_code", None) or getattr(exc, "status", None)
|
|
224
|
+
if code in (401, 403):
|
|
225
|
+
return True
|
|
226
|
+
response = getattr(exc, "response", None)
|
|
227
|
+
return response is not None and getattr(response, "status_code", None) in (401, 403)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Device-flow pairing client.
|
|
2
|
+
|
|
3
|
+
The node calls ``POST /pair/start`` to obtain a short pair code, shows it to the
|
|
4
|
+
user, then polls ``POST /pair/poll`` until the user claims it from Nexior (which
|
|
5
|
+
calls ``/pair/claim`` with their Ace JWT). The poll then returns the node_token.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import Settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PairingError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def start_pairing(settings: Settings) -> tuple[str, int]:
|
|
21
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
22
|
+
response = await client.post(
|
|
23
|
+
settings.pair_start_url, json={"node_name": settings.node_name}
|
|
24
|
+
)
|
|
25
|
+
if response.status_code != 200:
|
|
26
|
+
raise PairingError(f"pair/start failed: HTTP {response.status_code}")
|
|
27
|
+
data = response.json()
|
|
28
|
+
return data["pair_code"], int(data.get("expires_in", 600))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def poll_for_token(
|
|
32
|
+
settings: Settings,
|
|
33
|
+
pair_code: str,
|
|
34
|
+
*,
|
|
35
|
+
interval: float = 2.0,
|
|
36
|
+
deadline: float | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
39
|
+
while True:
|
|
40
|
+
response = await client.post(settings.pair_poll_url, json={"pair_code": pair_code})
|
|
41
|
+
if response.status_code == 200:
|
|
42
|
+
data = response.json()
|
|
43
|
+
status = data.get("status")
|
|
44
|
+
if status == "ready" and data.get("node_token"):
|
|
45
|
+
return data["node_token"]
|
|
46
|
+
if status in {"expired", "consumed"}:
|
|
47
|
+
raise PairingError(f"pairing {status}")
|
|
48
|
+
elif response.status_code == 404:
|
|
49
|
+
raise PairingError("pairing expired")
|
|
50
|
+
if deadline is not None and asyncio.get_running_loop().time() > deadline:
|
|
51
|
+
raise PairingError("pairing timed out")
|
|
52
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Pending tool-approval registry.
|
|
2
|
+
|
|
3
|
+
Each in-flight ``can_use_tool`` call parks on a future keyed by request id. The
|
|
4
|
+
browser's ``permission.resolve`` (or a timeout) settles it. This is the seam
|
|
5
|
+
that turns a local agent prompt into a remote approval.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
Decision = Literal["allow", "deny"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PermissionBroker:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._pending: dict[str, asyncio.Future[Decision]] = {}
|
|
18
|
+
|
|
19
|
+
def pending_ids(self) -> list[str]:
|
|
20
|
+
return list(self._pending)
|
|
21
|
+
|
|
22
|
+
async def request(self, request_id: str, timeout: float | None = None) -> Decision:
|
|
23
|
+
"""Block until the request is resolved; deny on timeout."""
|
|
24
|
+
loop = asyncio.get_running_loop()
|
|
25
|
+
future: asyncio.Future[Decision] = loop.create_future()
|
|
26
|
+
self._pending[request_id] = future
|
|
27
|
+
try:
|
|
28
|
+
if timeout and timeout > 0:
|
|
29
|
+
return await asyncio.wait_for(future, timeout)
|
|
30
|
+
return await future
|
|
31
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
32
|
+
return "deny"
|
|
33
|
+
finally:
|
|
34
|
+
self._pending.pop(request_id, None)
|
|
35
|
+
|
|
36
|
+
def resolve(self, request_id: str, decision: Decision) -> bool:
|
|
37
|
+
future = self._pending.get(request_id)
|
|
38
|
+
if future is None or future.done():
|
|
39
|
+
return False
|
|
40
|
+
future.set_result("allow" if decision == "allow" else "deny")
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def cancel_all(self, decision: Decision = "deny") -> None:
|
|
44
|
+
for future in list(self._pending.values()):
|
|
45
|
+
if not future.done():
|
|
46
|
+
future.set_result(decision)
|
|
47
|
+
self._pending.clear()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""WebSocket protocol shared with the coding-bridge relay.
|
|
2
|
+
|
|
3
|
+
The outer envelope and its ``type`` constants mirror coding-bridge's
|
|
4
|
+
``worker/app/protocol.py`` exactly — the bridge routes on ``type`` and forwards
|
|
5
|
+
``payload`` verbatim. The inner ``Action`` / ``Event`` sub-protocol is opaque to
|
|
6
|
+
the bridge and is carried inside ``payload`` between browser and node.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
PROTOCOL_VERSION = 1
|
|
15
|
+
|
|
16
|
+
# --- Outer envelope types (must match coding-bridge) -----------------------
|
|
17
|
+
BROWSER_TO_NODE = "browser.to_node"
|
|
18
|
+
BROWSER_LIST_NODES = "browser.list_nodes"
|
|
19
|
+
NODE_TO_BROWSER = "node.to_browser"
|
|
20
|
+
NODES_SNAPSHOT = "nodes.snapshot"
|
|
21
|
+
NODE_STATUS = "node.status"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
NODE_REGISTERED = "node.registered"
|
|
24
|
+
NODE_HEARTBEAT = "node.heartbeat"
|
|
25
|
+
NODE_HEARTBEAT_ACK = "node.heartbeat_ack"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def envelope(
|
|
29
|
+
message_type: str, payload: dict[str, Any] | None = None, **extra: Any
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Build a protocol envelope with an id and millisecond timestamp."""
|
|
32
|
+
message: dict[str, Any] = {
|
|
33
|
+
"v": PROTOCOL_VERSION,
|
|
34
|
+
"id": uuid.uuid4().hex,
|
|
35
|
+
"ts": int(time.time() * 1000),
|
|
36
|
+
"type": message_type,
|
|
37
|
+
"payload": payload or {},
|
|
38
|
+
}
|
|
39
|
+
message.update(extra)
|
|
40
|
+
return message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Action:
|
|
44
|
+
"""Browser → node commands (inside ``payload``)."""
|
|
45
|
+
|
|
46
|
+
SESSION_START = "session.start"
|
|
47
|
+
SESSION_SEND = "session.send"
|
|
48
|
+
SESSION_INTERRUPT = "session.interrupt"
|
|
49
|
+
SESSION_CLOSE = "session.close"
|
|
50
|
+
PERMISSION_RESOLVE = "permission.resolve"
|
|
51
|
+
SESSIONS_LIST = "sessions.list"
|
|
52
|
+
PING = "ping"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Event:
|
|
56
|
+
"""Node → browser events (inside ``payload``)."""
|
|
57
|
+
|
|
58
|
+
SESSION_STARTED = "session.started"
|
|
59
|
+
SESSION_TEXT = "session.text"
|
|
60
|
+
SESSION_THINKING = "session.thinking"
|
|
61
|
+
SESSION_TOOL_USE = "session.tool_use"
|
|
62
|
+
SESSION_TOOL_RESULT = "session.tool_result"
|
|
63
|
+
PERMISSION_REQUEST = "permission.request"
|
|
64
|
+
PERMISSION_RESOLVED = "permission.resolved"
|
|
65
|
+
SESSION_RESULT = "session.result"
|
|
66
|
+
SESSION_ERROR = "session.error"
|
|
67
|
+
SESSION_CLOSED = "session.closed"
|
|
68
|
+
SESSIONS_SNAPSHOT = "sessions.snapshot"
|
|
69
|
+
PONG = "pong"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def event_payload(event: str, session_id: str | None = None, **fields: Any) -> dict[str, Any]:
|
|
73
|
+
"""Build an inner event payload for node → browser traffic."""
|
|
74
|
+
payload: dict[str, Any] = {"event": event}
|
|
75
|
+
if session_id is not None:
|
|
76
|
+
payload["session_id"] = session_id
|
|
77
|
+
payload.update(fields)
|
|
78
|
+
return payload
|