agentlink-cli 0.1.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.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
connector/codex/rpc.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from connector.launch import launch_target
|
|
14
|
+
from connector.logging import logger
|
|
15
|
+
from connector.version import connector_version
|
|
16
|
+
|
|
17
|
+
NotificationHandler = Callable[[dict[str, Any]], Awaitable[None]]
|
|
18
|
+
APP_SERVER_STREAM_LIMIT = 64 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JsonRpcStdioClient:
|
|
22
|
+
"""Line-delimited JSON-RPC client for `codex app-server --listen stdio://`."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, command: list[str] | None = None) -> None:
|
|
25
|
+
self.command = command or _resolve_codex_command()
|
|
26
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
27
|
+
self._start_lock = asyncio.Lock()
|
|
28
|
+
self._next_id = 1
|
|
29
|
+
self._pending: dict[int | str, asyncio.Future[dict[str, Any]]] = {}
|
|
30
|
+
self._server_request_ids: set[int | str] = set()
|
|
31
|
+
self._notification_handler: NotificationHandler | None = None
|
|
32
|
+
self._initialized = False
|
|
33
|
+
|
|
34
|
+
async def start(self, handler: NotificationHandler) -> None:
|
|
35
|
+
async with self._start_lock:
|
|
36
|
+
if self.process and self._initialized:
|
|
37
|
+
self._notification_handler = handler
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
self._notification_handler = handler
|
|
41
|
+
if self.process is None:
|
|
42
|
+
logger.info("starting codex app-server command={}", self.command)
|
|
43
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
44
|
+
*self.command,
|
|
45
|
+
stdin=asyncio.subprocess.PIPE,
|
|
46
|
+
stdout=asyncio.subprocess.PIPE,
|
|
47
|
+
stderr=asyncio.subprocess.PIPE,
|
|
48
|
+
limit=APP_SERVER_STREAM_LIMIT,
|
|
49
|
+
)
|
|
50
|
+
self._track_reader(asyncio.create_task(self._read_stdout(self.process)), "stdout")
|
|
51
|
+
self._track_reader(asyncio.create_task(self._read_stderr(self.process)), "stderr")
|
|
52
|
+
|
|
53
|
+
await self.request(
|
|
54
|
+
"initialize",
|
|
55
|
+
{
|
|
56
|
+
"clientInfo": {
|
|
57
|
+
"name": "agent-server-connector",
|
|
58
|
+
"title": "Agent Server Connector",
|
|
59
|
+
"version": connector_version(),
|
|
60
|
+
},
|
|
61
|
+
"capabilities": {
|
|
62
|
+
"experimentalApi": True,
|
|
63
|
+
"requestAttestation": False,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
await self.notify("initialized")
|
|
68
|
+
self._initialized = True
|
|
69
|
+
|
|
70
|
+
async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
71
|
+
if not self.process or not self.process.stdin:
|
|
72
|
+
raise RuntimeError("Codex app-server is not started")
|
|
73
|
+
|
|
74
|
+
request_id = self._next_id
|
|
75
|
+
self._next_id += 1
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
78
|
+
self._pending[request_id] = future
|
|
79
|
+
payload: dict[str, Any] = {
|
|
80
|
+
"jsonrpc": "2.0",
|
|
81
|
+
"id": request_id,
|
|
82
|
+
"method": method,
|
|
83
|
+
"params": params or {},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
started = time.perf_counter()
|
|
87
|
+
self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
88
|
+
await self.process.stdin.drain()
|
|
89
|
+
try:
|
|
90
|
+
return await future
|
|
91
|
+
finally:
|
|
92
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
93
|
+
logger.trace("codex rpc method={} id={} elapsed_ms={:.1f}", method, request_id, elapsed_ms)
|
|
94
|
+
|
|
95
|
+
async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
96
|
+
if not self.process or not self.process.stdin:
|
|
97
|
+
raise RuntimeError("Codex app-server is not started")
|
|
98
|
+
|
|
99
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
|
|
100
|
+
self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
101
|
+
await self.process.stdin.drain()
|
|
102
|
+
|
|
103
|
+
async def respond(self, request_id: str | int, result: dict[str, Any] | None = None) -> None:
|
|
104
|
+
if not self.process or not self.process.stdin:
|
|
105
|
+
raise RuntimeError("Codex app-server is not started")
|
|
106
|
+
|
|
107
|
+
response_id = self._response_id_for(request_id)
|
|
108
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": response_id, "result": result or {}}
|
|
109
|
+
self.process.stdin.write((json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
110
|
+
await self.process.stdin.drain()
|
|
111
|
+
|
|
112
|
+
async def close(self) -> None:
|
|
113
|
+
if self.process is None:
|
|
114
|
+
return
|
|
115
|
+
self.process.terminate()
|
|
116
|
+
try:
|
|
117
|
+
await asyncio.wait_for(self.process.wait(), timeout=5)
|
|
118
|
+
except TimeoutError:
|
|
119
|
+
self.process.kill()
|
|
120
|
+
await self.process.wait()
|
|
121
|
+
finally:
|
|
122
|
+
self.process = None
|
|
123
|
+
self._initialized = False
|
|
124
|
+
|
|
125
|
+
async def _read_stdout(self, process: asyncio.subprocess.Process) -> None:
|
|
126
|
+
assert process.stdout
|
|
127
|
+
while line := await process.stdout.readline():
|
|
128
|
+
try:
|
|
129
|
+
payload = json.loads(line)
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
logger.warning("codex app-server emitted non-json stdout: {}", line.decode(errors="replace").strip())
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
request_id = payload.get("id")
|
|
135
|
+
if request_id in self._pending and ("result" in payload or "error" in payload):
|
|
136
|
+
future = self._pending.pop(request_id)
|
|
137
|
+
self._settle_pending_future(future, payload)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if request_id is not None and isinstance(payload.get("method"), str):
|
|
141
|
+
self._server_request_ids.add(request_id)
|
|
142
|
+
|
|
143
|
+
if self._notification_handler is not None:
|
|
144
|
+
await self._notification_handler(payload)
|
|
145
|
+
|
|
146
|
+
async def _read_stderr(self, process: asyncio.subprocess.Process) -> None:
|
|
147
|
+
assert process.stderr
|
|
148
|
+
while line := await process.stderr.readline():
|
|
149
|
+
logger.trace("codex app-server stderr: {}", line.decode(errors="replace").rstrip())
|
|
150
|
+
|
|
151
|
+
def _track_reader(self, task: asyncio.Task[None], name: str) -> None:
|
|
152
|
+
def done(completed: asyncio.Task[None]) -> None:
|
|
153
|
+
try:
|
|
154
|
+
completed.result()
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
return
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.exception("codex app-server {} reader stopped unexpectedly", name)
|
|
159
|
+
|
|
160
|
+
task.add_done_callback(done)
|
|
161
|
+
|
|
162
|
+
def _settle_pending_future(self, future: asyncio.Future[dict[str, Any]], payload: dict[str, Any]) -> None:
|
|
163
|
+
if future.done():
|
|
164
|
+
logger.trace("codex rpc received response for completed request id={}", payload.get("id"))
|
|
165
|
+
return
|
|
166
|
+
if "error" in payload:
|
|
167
|
+
future.set_exception(RuntimeError(json.dumps(payload["error"], ensure_ascii=False)))
|
|
168
|
+
else:
|
|
169
|
+
future.set_result(payload.get("result") or {})
|
|
170
|
+
|
|
171
|
+
def _response_id_for(self, request_id: str | int) -> str | int:
|
|
172
|
+
if request_id in self._server_request_ids:
|
|
173
|
+
self._server_request_ids.remove(request_id)
|
|
174
|
+
return request_id
|
|
175
|
+
if isinstance(request_id, str):
|
|
176
|
+
try:
|
|
177
|
+
numeric_request_id = int(request_id)
|
|
178
|
+
except ValueError:
|
|
179
|
+
numeric_request_id = None
|
|
180
|
+
if numeric_request_id is not None and numeric_request_id in self._server_request_ids:
|
|
181
|
+
self._server_request_ids.remove(numeric_request_id)
|
|
182
|
+
logger.trace(
|
|
183
|
+
"codex rpc coerced approval response id from string to number request_id={}",
|
|
184
|
+
request_id,
|
|
185
|
+
)
|
|
186
|
+
return numeric_request_id
|
|
187
|
+
logger.warning("codex rpc responding to unknown server request id={}", request_id)
|
|
188
|
+
return request_id
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _resolve_codex_bin() -> str:
|
|
192
|
+
for candidate in codex_candidate_paths():
|
|
193
|
+
if candidate["source"] == "custom":
|
|
194
|
+
return candidate["path"]
|
|
195
|
+
path = Path(candidate["path"])
|
|
196
|
+
if path.is_file():
|
|
197
|
+
return str(path)
|
|
198
|
+
return "codex"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolve_codex_command() -> list[str]:
|
|
202
|
+
path = _resolve_codex_bin()
|
|
203
|
+
return launch_target("cli", path).command(["app-server", "--listen", "stdio://"])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def codex_candidate_paths() -> list[dict[str, str]]:
|
|
207
|
+
if sys.platform == "win32":
|
|
208
|
+
home = Path.home()
|
|
209
|
+
appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
|
|
210
|
+
candidates = [
|
|
211
|
+
{"source": "custom", "path": os.getenv("CODEX_BIN", "")},
|
|
212
|
+
*[
|
|
213
|
+
{"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
|
|
214
|
+
for name in ("codex.cmd", "codex.ps1", "codex.exe")
|
|
215
|
+
],
|
|
216
|
+
{"source": "cli", "path": shutil.which("codex") or ""},
|
|
217
|
+
*[
|
|
218
|
+
{"source": "npm", "path": str(Path(appdata) / "npm" / name)}
|
|
219
|
+
for name in ("codex.cmd", "codex.ps1", "codex.exe")
|
|
220
|
+
],
|
|
221
|
+
*[
|
|
222
|
+
{"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
|
|
223
|
+
for name in ("codex.cmd", "codex.ps1", "codex.exe")
|
|
224
|
+
],
|
|
225
|
+
*[
|
|
226
|
+
{"source": "cli", "path": str(home / ".local" / "bin" / name)}
|
|
227
|
+
for name in ("codex.exe", "codex.cmd", "codex.ps1")
|
|
228
|
+
],
|
|
229
|
+
*[
|
|
230
|
+
{"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
|
|
231
|
+
for name in ("codex.exe", "codex.cmd", "codex.ps1")
|
|
232
|
+
],
|
|
233
|
+
]
|
|
234
|
+
seen: set[str] = set()
|
|
235
|
+
out: list[dict[str, str]] = []
|
|
236
|
+
for candidate in candidates:
|
|
237
|
+
path = candidate.get("path") or ""
|
|
238
|
+
if not path or path in seen:
|
|
239
|
+
continue
|
|
240
|
+
seen.add(path)
|
|
241
|
+
out.append(candidate)
|
|
242
|
+
return out
|
|
243
|
+
|
|
244
|
+
candidates = [
|
|
245
|
+
{"source": "custom", "path": os.getenv("CODEX_BIN", "")},
|
|
246
|
+
{"source": "app", "path": "/Applications/Codex.app/Contents/Resources/codex"},
|
|
247
|
+
{"source": "app", "path": str(Path.home() / "Applications" / "Codex.app" / "Contents" / "Resources" / "codex")},
|
|
248
|
+
{"source": "cli", "path": shutil.which("codex") or ""},
|
|
249
|
+
{"source": "cli", "path": "/opt/homebrew/bin/codex"},
|
|
250
|
+
{"source": "cli", "path": "/usr/local/bin/codex"},
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
seen: set[str] = set()
|
|
254
|
+
out: list[dict[str, str]] = []
|
|
255
|
+
for candidate in candidates:
|
|
256
|
+
path = candidate.get("path") or ""
|
|
257
|
+
if not path or path in seen:
|
|
258
|
+
continue
|
|
259
|
+
seen.add(path)
|
|
260
|
+
out.append(candidate)
|
|
261
|
+
return out
|
connector/control.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from connector.local_runtime import (
|
|
11
|
+
ConnectorAlreadyRunningError,
|
|
12
|
+
assert_can_start,
|
|
13
|
+
clear_runtime,
|
|
14
|
+
runtime_path,
|
|
15
|
+
write_runtime,
|
|
16
|
+
)
|
|
17
|
+
from connector.logging import logger
|
|
18
|
+
from connector.runtime import BackendRpcClient, ConnectorAuthenticationError, ConnectorConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ControlNotifier = Callable[[str, Any], Awaitable[None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConnectorController:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
config_path: str | Path | None = None,
|
|
29
|
+
notifier: ControlNotifier | None = None,
|
|
30
|
+
client_factory: Callable[[ConnectorConfig], BackendRpcClient] = BackendRpcClient,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.config_path = Path(config_path) if config_path is not None else ConnectorConfig.default_path()
|
|
33
|
+
self.notifier = notifier
|
|
34
|
+
self.client_factory = client_factory
|
|
35
|
+
self.runtime_path = runtime_path(self.config_path)
|
|
36
|
+
self._runtime_task: asyncio.Task[None] | None = None
|
|
37
|
+
self._pairing_task: asyncio.Task[None] | None = None
|
|
38
|
+
self._last_error: str | None = None
|
|
39
|
+
self._auth_failed = False
|
|
40
|
+
|
|
41
|
+
def get_state(self, _params: Any = None) -> dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"status": self._status(),
|
|
44
|
+
"running": self._runtime_task is not None and not self._runtime_task.done(),
|
|
45
|
+
"pairing": self._pairing_task is not None and not self._pairing_task.done(),
|
|
46
|
+
"authFailed": self._auth_failed,
|
|
47
|
+
"lastError": self._last_error,
|
|
48
|
+
"configPath": str(self.config_path),
|
|
49
|
+
"runtimePath": str(self.runtime_path),
|
|
50
|
+
"hasConfig": self.config_path.exists(),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def get_paths(self, _params: Any = None) -> dict[str, str]:
|
|
54
|
+
return {
|
|
55
|
+
"configPath": str(self.config_path),
|
|
56
|
+
"configDir": str(self.config_path.parent),
|
|
57
|
+
"runtimePath": str(self.runtime_path),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def get_config(self, _params: Any = None) -> dict[str, Any]:
|
|
61
|
+
if not self.config_path.exists():
|
|
62
|
+
return default_config_payload()
|
|
63
|
+
return config_to_payload(ConnectorConfig.load(self.config_path))
|
|
64
|
+
|
|
65
|
+
async def save_config(self, params: Any) -> dict[str, Any]:
|
|
66
|
+
config = config_from_params(params)
|
|
67
|
+
saved_path = config.save(self.config_path)
|
|
68
|
+
self._auth_failed = False
|
|
69
|
+
self._last_error = None
|
|
70
|
+
logger.info("saved connector config path={}", saved_path)
|
|
71
|
+
await self._emit_state()
|
|
72
|
+
return config_to_payload(config)
|
|
73
|
+
|
|
74
|
+
async def start(self, params: Any = None) -> dict[str, Any]:
|
|
75
|
+
if self._runtime_task is not None and not self._runtime_task.done():
|
|
76
|
+
return self.get_state()
|
|
77
|
+
|
|
78
|
+
config = config_from_params(params) if isinstance(params, dict) and params else ConnectorConfig.load(self.config_path)
|
|
79
|
+
self._last_error = None
|
|
80
|
+
self._auth_failed = False
|
|
81
|
+
try:
|
|
82
|
+
assert_can_start(self.runtime_path, config)
|
|
83
|
+
except ConnectorAlreadyRunningError as exc:
|
|
84
|
+
self._last_error = str(exc)
|
|
85
|
+
await self._emit_state()
|
|
86
|
+
raise
|
|
87
|
+
write_runtime(self.runtime_path, config, kind="desktop")
|
|
88
|
+
self._runtime_task = asyncio.create_task(self._run_runtime(config))
|
|
89
|
+
logger.info("starting connector runtime")
|
|
90
|
+
await self._emit_state()
|
|
91
|
+
return self.get_state()
|
|
92
|
+
|
|
93
|
+
async def stop(self, _params: Any = None) -> dict[str, Any]:
|
|
94
|
+
if self._runtime_task is not None and not self._runtime_task.done():
|
|
95
|
+
self._runtime_task.cancel()
|
|
96
|
+
try:
|
|
97
|
+
await self._runtime_task
|
|
98
|
+
except asyncio.CancelledError:
|
|
99
|
+
pass
|
|
100
|
+
self._runtime_task = None
|
|
101
|
+
clear_runtime(self.runtime_path)
|
|
102
|
+
logger.info("stopped connector runtime")
|
|
103
|
+
await self._emit_state()
|
|
104
|
+
return self.get_state()
|
|
105
|
+
|
|
106
|
+
async def restart(self, params: Any = None) -> dict[str, Any]:
|
|
107
|
+
await self.stop()
|
|
108
|
+
return await self.start(params)
|
|
109
|
+
|
|
110
|
+
async def start_pairing(self, params: Any) -> dict[str, Any]:
|
|
111
|
+
if self._pairing_task is not None and not self._pairing_task.done():
|
|
112
|
+
self._pairing_task.cancel()
|
|
113
|
+
server = str_param(params, "server") or str_param(params, "serverUrl")
|
|
114
|
+
server_url = await resolve_pair_server_url(server, timeout=float_param(params, "resolveTimeout", 10))
|
|
115
|
+
timeout = float_param(params, "timeout", 600)
|
|
116
|
+
poll_interval = float_param(params, "pollInterval", 2)
|
|
117
|
+
self._pairing_task = asyncio.create_task(self._run_pairing(server_url, timeout=timeout, poll_interval=poll_interval))
|
|
118
|
+
payload = {"status": "starting", "serverUrl": server_url}
|
|
119
|
+
await self._emit_pairing(payload)
|
|
120
|
+
await self._emit_state()
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
async def cancel_pairing(self, _params: Any = None) -> dict[str, Any]:
|
|
124
|
+
if self._pairing_task is not None and not self._pairing_task.done():
|
|
125
|
+
self._pairing_task.cancel()
|
|
126
|
+
self._pairing_task = None
|
|
127
|
+
payload = {"status": "cancelled"}
|
|
128
|
+
await self._emit_pairing(payload)
|
|
129
|
+
await self._emit_state()
|
|
130
|
+
return payload
|
|
131
|
+
|
|
132
|
+
async def shutdown(self) -> None:
|
|
133
|
+
if self._pairing_task is not None and not self._pairing_task.done():
|
|
134
|
+
self._pairing_task.cancel()
|
|
135
|
+
await self.stop()
|
|
136
|
+
|
|
137
|
+
async def _run_runtime(self, config: ConnectorConfig) -> None:
|
|
138
|
+
try:
|
|
139
|
+
await self.client_factory(config).run_forever()
|
|
140
|
+
except asyncio.CancelledError:
|
|
141
|
+
raise
|
|
142
|
+
except ConnectorAuthenticationError as exc:
|
|
143
|
+
self._auth_failed = True
|
|
144
|
+
self._last_error = str(exc)
|
|
145
|
+
logger.error("connector authentication failed: {}", exc)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
self._last_error = str(exc) or exc.__class__.__name__
|
|
148
|
+
logger.exception("connector runtime failed")
|
|
149
|
+
finally:
|
|
150
|
+
clear_runtime(self.runtime_path)
|
|
151
|
+
if self._runtime_task is asyncio.current_task():
|
|
152
|
+
self._runtime_task = None
|
|
153
|
+
await self._emit_state()
|
|
154
|
+
|
|
155
|
+
async def _run_pairing(self, server_url: str, *, timeout: float, poll_interval: float) -> None:
|
|
156
|
+
try:
|
|
157
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
158
|
+
start_response = await client.post(
|
|
159
|
+
f"{server_url}/pairing/start",
|
|
160
|
+
json={"serverUrl": server_url, "ttlSeconds": int(timeout)},
|
|
161
|
+
)
|
|
162
|
+
start_response.raise_for_status()
|
|
163
|
+
pairing = start_response.json()
|
|
164
|
+
pairing_id = pairing["pairingId"]
|
|
165
|
+
code = pairing["code"]
|
|
166
|
+
await self._emit_pairing(
|
|
167
|
+
{
|
|
168
|
+
"status": "waiting",
|
|
169
|
+
"serverUrl": server_url,
|
|
170
|
+
"pairingId": pairing_id,
|
|
171
|
+
"code": code,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
176
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
177
|
+
poll_response = await client.post(f"{server_url}/pairing/poll", json={"pairingId": pairing_id})
|
|
178
|
+
poll_response.raise_for_status()
|
|
179
|
+
payload = poll_response.json()
|
|
180
|
+
if payload["status"] == "claimed" and payload.get("config"):
|
|
181
|
+
config = ConnectorConfig.from_mapping(payload["config"])
|
|
182
|
+
config.save(self.config_path)
|
|
183
|
+
await self._emit_pairing({"status": "claimed", "config": config_to_payload(config)})
|
|
184
|
+
await self.start()
|
|
185
|
+
return
|
|
186
|
+
if payload["status"] in {"expired", "consumed"}:
|
|
187
|
+
await self._emit_pairing({"status": payload["status"]})
|
|
188
|
+
return
|
|
189
|
+
await asyncio.sleep(poll_interval)
|
|
190
|
+
await self._emit_pairing({"status": "expired"})
|
|
191
|
+
except asyncio.CancelledError:
|
|
192
|
+
await self._emit_pairing({"status": "cancelled"})
|
|
193
|
+
raise
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
self._last_error = str(exc) or exc.__class__.__name__
|
|
196
|
+
await self._emit_pairing({"status": "error", "error": self._last_error})
|
|
197
|
+
await self._emit_state()
|
|
198
|
+
|
|
199
|
+
def _status(self) -> str:
|
|
200
|
+
if self._runtime_task is not None and not self._runtime_task.done():
|
|
201
|
+
return "running"
|
|
202
|
+
if self._auth_failed:
|
|
203
|
+
return "expired credential"
|
|
204
|
+
if self._last_error:
|
|
205
|
+
return "error"
|
|
206
|
+
return "stopped"
|
|
207
|
+
|
|
208
|
+
async def _emit_state(self) -> None:
|
|
209
|
+
await self._notify("connector/state", self.get_state())
|
|
210
|
+
|
|
211
|
+
async def _emit_pairing(self, payload: dict[str, Any]) -> None:
|
|
212
|
+
await self._notify("connector/pairing", payload)
|
|
213
|
+
|
|
214
|
+
async def _notify(self, method: str, params: Any) -> None:
|
|
215
|
+
if self.notifier is not None:
|
|
216
|
+
await self.notifier(method, params)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def default_config_payload() -> dict[str, Any]:
|
|
220
|
+
return {
|
|
221
|
+
"serverUrl": "",
|
|
222
|
+
"connectorId": "",
|
|
223
|
+
"connectorToken": "",
|
|
224
|
+
"heartbeatSeconds": 20,
|
|
225
|
+
"reconnectSeconds": 3,
|
|
226
|
+
"syncExistingOnConnect": True,
|
|
227
|
+
"syncIntervalSeconds": 30,
|
|
228
|
+
"stateDbPath": None,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def config_to_payload(config: ConnectorConfig) -> dict[str, Any]:
|
|
233
|
+
return {
|
|
234
|
+
"serverUrl": config.server_url,
|
|
235
|
+
"connectorId": config.connector_id,
|
|
236
|
+
"connectorToken": config.connector_token,
|
|
237
|
+
"heartbeatSeconds": config.heartbeat_seconds,
|
|
238
|
+
"reconnectSeconds": config.reconnect_seconds,
|
|
239
|
+
"syncExistingOnConnect": config.sync_existing_on_connect,
|
|
240
|
+
"syncIntervalSeconds": config.sync_interval_seconds,
|
|
241
|
+
"stateDbPath": config.state_db_path,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def config_from_params(params: Any) -> ConnectorConfig:
|
|
246
|
+
if not isinstance(params, dict):
|
|
247
|
+
raise ValueError("config params must be an object")
|
|
248
|
+
server_url = str(params.get("serverUrl") or "").strip().rstrip("/")
|
|
249
|
+
connector_id = str(params.get("connectorId") or "").strip()
|
|
250
|
+
connector_token = str(params.get("connectorToken") or "").strip()
|
|
251
|
+
if not server_url or not connector_id or not connector_token:
|
|
252
|
+
raise ValueError("serverUrl, connectorId, and connectorToken are required")
|
|
253
|
+
return ConnectorConfig(
|
|
254
|
+
server_url=server_url,
|
|
255
|
+
connector_id=connector_id,
|
|
256
|
+
connector_token=connector_token,
|
|
257
|
+
heartbeat_seconds=float(params.get("heartbeatSeconds", 20)),
|
|
258
|
+
reconnect_seconds=float(params.get("reconnectSeconds", 3)),
|
|
259
|
+
sync_existing_on_connect=bool(params.get("syncExistingOnConnect", True)),
|
|
260
|
+
sync_interval_seconds=float(params.get("syncIntervalSeconds", 30)),
|
|
261
|
+
state_db_path=params.get("stateDbPath") if isinstance(params.get("stateDbPath"), str) else None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def resolve_pair_server_url(value: str | None, *, timeout: float = 10) -> str:
|
|
266
|
+
normalized = str(value or "").strip().rstrip("/")
|
|
267
|
+
if not normalized:
|
|
268
|
+
raise ValueError("server is required")
|
|
269
|
+
if normalized.startswith(("http://", "https://")):
|
|
270
|
+
return normalized
|
|
271
|
+
candidates = [f"https://{normalized}", f"http://{normalized}"]
|
|
272
|
+
errors: list[str] = []
|
|
273
|
+
for candidate in candidates:
|
|
274
|
+
try:
|
|
275
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
276
|
+
response = await client.get(f"{candidate}/health")
|
|
277
|
+
if response.status_code < 500:
|
|
278
|
+
return candidate
|
|
279
|
+
errors.append(f"{candidate}: HTTP {response.status_code}")
|
|
280
|
+
except httpx.RequestError as exc:
|
|
281
|
+
errors.append(f"{candidate}: {exc}")
|
|
282
|
+
raise ValueError(f"could not reach server over https or http ({'; '.join(errors)})")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def str_param(params: Any, key: str) -> str | None:
|
|
286
|
+
if not isinstance(params, dict):
|
|
287
|
+
return None
|
|
288
|
+
value = params.get(key)
|
|
289
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def float_param(params: Any, key: str, default: float) -> float:
|
|
293
|
+
if not isinstance(params, dict):
|
|
294
|
+
return default
|
|
295
|
+
try:
|
|
296
|
+
return float(params.get(key, default))
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
return default
|