pairling 0.0.1 → 0.1.0
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.
- package/package.json +5 -1
- package/payload/mac/SOURCE_BRANCH +1 -0
- package/payload/mac/SOURCE_DIRTY +1 -0
- package/payload/mac/SOURCE_REVISION +1 -0
- package/payload/mac/VERSION +1 -0
- package/payload/mac/companiond/integrations/__init__.py +1 -0
- package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
- package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
- package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
- package/payload/mac/companiond/live_activity_publisher.py +380 -0
- package/payload/mac/companiond/llm_route.py +108 -0
- package/payload/mac/companiond/local_mcp_bridge.py +156 -0
- package/payload/mac/companiond/model_status_contract.py +101 -0
- package/payload/mac/companiond/pairdrop_store.py +920 -0
- package/payload/mac/companiond/pairling_connectd_status.py +149 -0
- package/payload/mac/companiond/pairling_devices.py +459 -0
- package/payload/mac/companiond/pairling_pairing.py +404 -0
- package/payload/mac/companiond/pairling_relay_claims.py +232 -0
- package/payload/mac/companiond/pairling_tools.py +706 -0
- package/payload/mac/companiond/pairlingd.py +18438 -0
- package/payload/mac/companiond/providers/__init__.py +1 -0
- package/payload/mac/companiond/providers/base.py +255 -0
- package/payload/mac/companiond/providers/claude.py +127 -0
- package/payload/mac/companiond/providers/codex.py +124 -0
- package/payload/mac/companiond/providers/external.py +46 -0
- package/payload/mac/companiond/providers/registry.py +70 -0
- package/payload/mac/companiond/pty_broker.py +887 -0
- package/payload/mac/companiond/push_dispatcher.py +1990 -0
- package/payload/mac/companiond/push_event_catalog.py +566 -0
- package/payload/mac/companiond/request_proof.py +142 -0
- package/payload/mac/companiond/runtime_contract.py +47 -0
- package/payload/mac/companiond/runtime_manifest.py +197 -0
- package/payload/mac/companiond/runtime_paths.py +87 -0
- package/payload/mac/companiond/safety_monitor.py +542 -0
- package/payload/mac/companiond/sentinel_notifications.py +491 -0
- package/payload/mac/companiond/standard_push_publisher.py +516 -0
- package/payload/mac/companiond/substrate_status_contract.py +139 -0
- package/payload/mac/companiond/terminal_screen_backend.py +332 -0
- package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
- package/payload/mac/companiond/workstate_feed_contract.py +108 -0
- package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
- package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
- package/payload/mac/connectd/go.mod +51 -0
- package/payload/mac/connectd/go.sum +229 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
- package/payload/mac/connectd/internal/runtime/config.go +99 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
- package/payload/mac/connectd/internal/status/status.go +300 -0
- package/payload/mac/connectd/internal/status/status_test.go +263 -0
- package/payload/mac/guardian/companion-power-guardian.py +613 -0
- package/payload/mac/guardian/guardian_contract.py +67 -0
- package/payload/mac/install/bootstrap-first-run.sh +206 -0
- package/payload/mac/install/doctor.sh +660 -0
- package/payload/mac/install/install-runtime.sh +1241 -0
- package/payload/mac/install/render-launchd.py +119 -0
- package/payload/mac/install/uninstall-runtime.sh +136 -0
- package/payload/mac/mcp/phone_tools.py +210 -0
- package/payload/mac/packaging/bin/pairling +63 -0
- package/payload-manifest.json +255 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Daemon-side Live Activity APNs publisher.
|
|
3
|
+
|
|
4
|
+
The iPhone can start a Live Activity and register its update token, but once
|
|
5
|
+
iOS suspends the app, Mac-side turn-state must drive APNs updates. This module
|
|
6
|
+
bridges Pairling's turn-state JSON files into PairlingPushDispatcher events.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from push_event_catalog import build_push_event, live_activity_content_state, live_activity_payload
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LiveActivityTurnStatePublisher:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
turn_state_dir: Path,
|
|
27
|
+
push_dispatcher,
|
|
28
|
+
claude_uuid_resolver: Callable[[str], str] | None = None,
|
|
29
|
+
now_fn=time.time,
|
|
30
|
+
sleep_fn=time.sleep,
|
|
31
|
+
poll_interval: float = 1.0,
|
|
32
|
+
failed_retry_interval: float = 30.0,
|
|
33
|
+
token_update_interval: float = 15.0,
|
|
34
|
+
logger: Callable[[str], None] | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.turn_state_dir = turn_state_dir
|
|
37
|
+
self.push_dispatcher = push_dispatcher
|
|
38
|
+
self.claude_uuid_resolver = claude_uuid_resolver or (lambda _session_id: "")
|
|
39
|
+
self.now_fn = now_fn
|
|
40
|
+
self.sleep_fn = sleep_fn
|
|
41
|
+
self.poll_interval = poll_interval
|
|
42
|
+
self.failed_retry_interval = failed_retry_interval
|
|
43
|
+
self.token_update_interval = token_update_interval
|
|
44
|
+
self.logger = logger
|
|
45
|
+
self._stop = threading.Event()
|
|
46
|
+
self._thread: threading.Thread | None = None
|
|
47
|
+
self._last_attempts: dict[tuple[str, str], dict[str, Any]] = {}
|
|
48
|
+
|
|
49
|
+
def start(self) -> None:
|
|
50
|
+
if self._thread and self._thread.is_alive():
|
|
51
|
+
return
|
|
52
|
+
self._stop.clear()
|
|
53
|
+
self._thread = threading.Thread(
|
|
54
|
+
target=self.run_forever,
|
|
55
|
+
name="pairling-live-activity-publisher",
|
|
56
|
+
daemon=True,
|
|
57
|
+
)
|
|
58
|
+
self._thread.start()
|
|
59
|
+
|
|
60
|
+
def stop(self) -> None:
|
|
61
|
+
self._stop.set()
|
|
62
|
+
|
|
63
|
+
def run_forever(self) -> None:
|
|
64
|
+
while not self._stop.is_set():
|
|
65
|
+
try:
|
|
66
|
+
self.scan_once()
|
|
67
|
+
except Exception as exc: # pragma: no cover - defensive daemon boundary.
|
|
68
|
+
self._log(f"live activity publisher scan failed: {type(exc).__name__}: {exc}")
|
|
69
|
+
self.sleep_fn(self.poll_interval)
|
|
70
|
+
|
|
71
|
+
def scan_once(self) -> list[dict[str, Any]]:
|
|
72
|
+
results: list[dict[str, Any]] = []
|
|
73
|
+
active = self._active_live_activities()
|
|
74
|
+
seen_keys = {(item["device_id"], item["session_id"]) for item in active}
|
|
75
|
+
for key in list(self._last_attempts):
|
|
76
|
+
if key not in seen_keys:
|
|
77
|
+
self._last_attempts.pop(key, None)
|
|
78
|
+
|
|
79
|
+
for item in active:
|
|
80
|
+
result = self._publish_for_activity(item)
|
|
81
|
+
if result is not None:
|
|
82
|
+
results.append(result)
|
|
83
|
+
return results
|
|
84
|
+
|
|
85
|
+
def publish_turn_state_payload(self, *, session_id: str, state_payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
86
|
+
"""Fast path for hook-observed meaningful turn-state events.
|
|
87
|
+
|
|
88
|
+
The scan loop remains as a safety net. This path lets callers publish
|
|
89
|
+
immediately after writing a semantically meaningful transition instead
|
|
90
|
+
of waiting for the next directory scan.
|
|
91
|
+
"""
|
|
92
|
+
if not isinstance(state_payload, dict):
|
|
93
|
+
return []
|
|
94
|
+
results: list[dict[str, Any]] = []
|
|
95
|
+
for item in self._active_live_activities():
|
|
96
|
+
if item["session_id"] != session_id:
|
|
97
|
+
continue
|
|
98
|
+
result = self._publish_state_to_activity(item, state_payload)
|
|
99
|
+
if result is not None:
|
|
100
|
+
results.append(result)
|
|
101
|
+
return results
|
|
102
|
+
|
|
103
|
+
def _active_live_activities(self) -> list[dict[str, str]]:
|
|
104
|
+
try:
|
|
105
|
+
status = self.push_dispatcher.status()
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
self._log(f"push status unavailable for live activity publisher: {type(exc).__name__}: {exc}")
|
|
108
|
+
return []
|
|
109
|
+
if not _provider_can_deliver(status):
|
|
110
|
+
return []
|
|
111
|
+
devices = status.get("devices") if isinstance(status, dict) else []
|
|
112
|
+
out: list[dict[str, str]] = []
|
|
113
|
+
seen: set[tuple[str, str]] = set()
|
|
114
|
+
for device in devices if isinstance(devices, list) else []:
|
|
115
|
+
if not isinstance(device, dict) or not device.get("live_activity_enabled"):
|
|
116
|
+
continue
|
|
117
|
+
device_id = str(device.get("device_id") or "").strip()
|
|
118
|
+
if not device_id:
|
|
119
|
+
continue
|
|
120
|
+
for activity in device.get("live_activities") or []:
|
|
121
|
+
if not isinstance(activity, dict) or activity.get("invalidated_at"):
|
|
122
|
+
continue
|
|
123
|
+
session_id = str(activity.get("session_id") or "").strip()
|
|
124
|
+
if not session_id:
|
|
125
|
+
continue
|
|
126
|
+
key = (device_id, session_id)
|
|
127
|
+
if key in seen:
|
|
128
|
+
continue
|
|
129
|
+
seen.add(key)
|
|
130
|
+
out.append({"device_id": device_id, "session_id": session_id})
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
def _publish_for_activity(self, item: dict[str, str]) -> dict[str, Any] | None:
|
|
134
|
+
session_id = item["session_id"]
|
|
135
|
+
state_payload = self._read_turn_state(session_id)
|
|
136
|
+
if not state_payload:
|
|
137
|
+
return None
|
|
138
|
+
return self._publish_state_to_activity(item, state_payload)
|
|
139
|
+
|
|
140
|
+
def _publish_state_to_activity(self, item: dict[str, str], state_payload: dict[str, Any]) -> dict[str, Any] | None:
|
|
141
|
+
session_id = item["session_id"]
|
|
142
|
+
payload, signature_visible = self._event_payload_from_turn_state(session_id, state_payload)
|
|
143
|
+
key = (item["device_id"], session_id)
|
|
144
|
+
signature = _stable_hash({
|
|
145
|
+
"session_id": session_id,
|
|
146
|
+
"event": payload["event"],
|
|
147
|
+
"visible": signature_visible,
|
|
148
|
+
})
|
|
149
|
+
now = float(self.now_fn())
|
|
150
|
+
previous = self._last_attempts.get(key)
|
|
151
|
+
if previous and previous.get("signature") == signature:
|
|
152
|
+
if previous.get("ok"):
|
|
153
|
+
return None
|
|
154
|
+
if now - float(previous.get("attempted_at") or 0) < self.failed_retry_interval:
|
|
155
|
+
return None
|
|
156
|
+
if previous and self._is_token_only_change(previous.get("visible"), signature_visible):
|
|
157
|
+
if now - float(previous.get("attempted_at") or 0) < self.token_update_interval:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
event_id = "la_auto_" + signature[:32]
|
|
161
|
+
payload["event_id"] = event_id
|
|
162
|
+
payload["content_state"]["eventId"] = event_id
|
|
163
|
+
delivery = self.push_dispatcher.record_live_activity_event(
|
|
164
|
+
device_id=item["device_id"],
|
|
165
|
+
payload=payload,
|
|
166
|
+
)
|
|
167
|
+
ok = bool(delivery.get("ok"))
|
|
168
|
+
self._last_attempts[key] = {
|
|
169
|
+
"signature": signature,
|
|
170
|
+
"visible": signature_visible,
|
|
171
|
+
"attempted_at": now,
|
|
172
|
+
"ok": ok,
|
|
173
|
+
"event_id": event_id,
|
|
174
|
+
}
|
|
175
|
+
return delivery
|
|
176
|
+
|
|
177
|
+
def _read_turn_state(self, session_id: str) -> dict[str, Any] | None:
|
|
178
|
+
provider, native_id = _parse_session_ref(session_id)
|
|
179
|
+
candidates: list[Path] = []
|
|
180
|
+
if provider == "claude":
|
|
181
|
+
uuid = self.claude_uuid_resolver(native_id)
|
|
182
|
+
if _safe_stem(uuid):
|
|
183
|
+
candidates.append(self.turn_state_dir / f"{uuid}.json")
|
|
184
|
+
if _safe_stem(native_id):
|
|
185
|
+
candidates.append(self.turn_state_dir / f"{native_id}.json")
|
|
186
|
+
elif _safe_stem(native_id):
|
|
187
|
+
candidates.append(self.turn_state_dir / f"{native_id}.json")
|
|
188
|
+
for path in candidates:
|
|
189
|
+
try:
|
|
190
|
+
if not path.is_file():
|
|
191
|
+
continue
|
|
192
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
193
|
+
except (OSError, json.JSONDecodeError):
|
|
194
|
+
continue
|
|
195
|
+
if isinstance(payload, dict):
|
|
196
|
+
return payload
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def _event_payload_from_turn_state(self, session_id: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
200
|
+
raw_state = str(payload.get("state") or "starting").strip().lower()
|
|
201
|
+
raw_event = str(payload.get("event") or "").strip().lower()
|
|
202
|
+
done = raw_state in {"idle", "done", "completed"} or raw_event in {"stop", "session_end"}
|
|
203
|
+
state = "done" if done else _activity_state(raw_state)
|
|
204
|
+
tool = _bounded_optional(payload.get("tool"), 80)
|
|
205
|
+
effort = _bounded_optional(payload.get("effort"), 40)
|
|
206
|
+
tokens = _optional_int(payload.get("tokens", payload.get("total_tokens")))
|
|
207
|
+
last_update = _optional_float(payload.get("last_update")) or float(self.now_fn())
|
|
208
|
+
started_at = _optional_float(payload.get("started_at"))
|
|
209
|
+
freshness = max(0, int(float(self.now_fn()) - last_update))
|
|
210
|
+
kind = _catalog_kind_for_state(state)
|
|
211
|
+
current_step = _current_step_for_state(state, tool, payload)
|
|
212
|
+
event = build_push_event(
|
|
213
|
+
kind=kind,
|
|
214
|
+
source="turn-state",
|
|
215
|
+
provider=_parse_session_ref(session_id)[0],
|
|
216
|
+
session_id=session_id,
|
|
217
|
+
session_title=_bounded_optional(payload.get("session_title"), 60),
|
|
218
|
+
project=_bounded_optional(payload.get("project"), 120),
|
|
219
|
+
observed_at=last_update,
|
|
220
|
+
phase=state,
|
|
221
|
+
current_step=current_step,
|
|
222
|
+
latest_event=_bounded_optional(payload.get("latest_event") or payload.get("event_label"), 120),
|
|
223
|
+
result_summary=_bounded_optional(
|
|
224
|
+
payload.get("result_summary") or ("Turn finished." if done else None),
|
|
225
|
+
120,
|
|
226
|
+
),
|
|
227
|
+
required_action=_bounded_optional(
|
|
228
|
+
payload.get("required_action") or ("Review the requested action." if state == "attention" else None),
|
|
229
|
+
120,
|
|
230
|
+
),
|
|
231
|
+
risk_level=_attention_level(state),
|
|
232
|
+
risk_summary=_bounded_optional(payload.get("risk_summary"), 120),
|
|
233
|
+
route_health=_bounded_optional(payload.get("route_health"), 80),
|
|
234
|
+
worker_summary=_bounded_optional(payload.get("worker_summary"), 80),
|
|
235
|
+
build_label=_bounded_optional(payload.get("build_label"), 60),
|
|
236
|
+
action_route="pairling://session/" + session_id,
|
|
237
|
+
freshness_seconds=freshness,
|
|
238
|
+
)
|
|
239
|
+
outgoing = live_activity_payload(event)
|
|
240
|
+
outgoing["kind"] = event.kind
|
|
241
|
+
outgoing["content_state"].update({
|
|
242
|
+
"state": state,
|
|
243
|
+
"phase": state,
|
|
244
|
+
"tool": tool,
|
|
245
|
+
"effort": effort,
|
|
246
|
+
"tokens": tokens,
|
|
247
|
+
"updatedAtEpoch": last_update,
|
|
248
|
+
})
|
|
249
|
+
if state == "tool" and tool:
|
|
250
|
+
outgoing["content_state"]["currentStep"] = current_step or f"Running {tool}"
|
|
251
|
+
outgoing["stale_seconds"] = 75
|
|
252
|
+
outgoing["dismissal_seconds"] = 300 if state in {"attention", "failed"} else 120
|
|
253
|
+
visible = {
|
|
254
|
+
"state": state,
|
|
255
|
+
"phase": state,
|
|
256
|
+
"tool": tool,
|
|
257
|
+
"effort": effort,
|
|
258
|
+
"tokens": tokens,
|
|
259
|
+
"attentionLevel": _attention_level(state),
|
|
260
|
+
"started_at": started_at,
|
|
261
|
+
"currentStep": outgoing["content_state"].get("currentStep"),
|
|
262
|
+
"resultSummary": outgoing["content_state"].get("resultSummary"),
|
|
263
|
+
"requiredAction": outgoing["content_state"].get("requiredAction"),
|
|
264
|
+
"riskSummary": outgoing["content_state"].get("riskSummary"),
|
|
265
|
+
}
|
|
266
|
+
outgoing["content_state"]["verb"] = _verb_for_state(state, tool)
|
|
267
|
+
return outgoing, visible
|
|
268
|
+
|
|
269
|
+
def _is_token_only_change(self, previous: Any, current: dict[str, Any]) -> bool:
|
|
270
|
+
if not isinstance(previous, dict):
|
|
271
|
+
return False
|
|
272
|
+
prior = dict(previous)
|
|
273
|
+
new = dict(current)
|
|
274
|
+
prior_tokens = prior.pop("tokens", None)
|
|
275
|
+
new_tokens = new.pop("tokens", None)
|
|
276
|
+
return prior == new and prior_tokens != new_tokens
|
|
277
|
+
|
|
278
|
+
def _log(self, message: str) -> None:
|
|
279
|
+
if self.logger:
|
|
280
|
+
self.logger(message)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _parse_session_ref(session_id: str) -> tuple[str, str]:
|
|
284
|
+
text = str(session_id or "").strip()
|
|
285
|
+
if ":" in text:
|
|
286
|
+
provider, native_id = text.split(":", 1)
|
|
287
|
+
if provider in {"claude", "codex"}:
|
|
288
|
+
return provider, native_id
|
|
289
|
+
return "claude", text
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _provider_can_deliver(status: dict[str, Any]) -> bool:
|
|
293
|
+
provider = status.get("provider") if isinstance(status, dict) else None
|
|
294
|
+
return not isinstance(provider, dict) or bool(provider.get("configured"))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _activity_state(state: str) -> str:
|
|
298
|
+
if state in {"starting", "thinking", "tool", "responding", "attention", "stale", "done", "failed", "idle"}:
|
|
299
|
+
return state
|
|
300
|
+
return "starting"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _attention_level(state: str) -> str | None:
|
|
304
|
+
if state == "attention":
|
|
305
|
+
return "warning"
|
|
306
|
+
if state == "failed":
|
|
307
|
+
return "critical"
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _catalog_kind_for_state(state: str) -> str:
|
|
312
|
+
if state == "done":
|
|
313
|
+
return "turn_result"
|
|
314
|
+
if state == "failed":
|
|
315
|
+
return "turn_failed"
|
|
316
|
+
if state == "attention":
|
|
317
|
+
return "action_required"
|
|
318
|
+
if state == "stale":
|
|
319
|
+
return "mac_route_risk"
|
|
320
|
+
return "push_diagnostic"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _current_step_for_state(state: str, tool: str | None, payload: dict[str, Any]) -> str | None:
|
|
324
|
+
explicit = _bounded_optional(payload.get("current_step"), 60)
|
|
325
|
+
if explicit:
|
|
326
|
+
return explicit
|
|
327
|
+
if state == "tool" and tool:
|
|
328
|
+
return f"Running {tool}"[:60]
|
|
329
|
+
if state == "responding":
|
|
330
|
+
return "Writing response"
|
|
331
|
+
if state == "thinking":
|
|
332
|
+
return "Reasoning"
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _verb_for_state(state: str, tool: str | None) -> str:
|
|
337
|
+
if state == "done":
|
|
338
|
+
return "Done"
|
|
339
|
+
if state == "attention":
|
|
340
|
+
return "Review"
|
|
341
|
+
if state == "failed":
|
|
342
|
+
return "Failed"
|
|
343
|
+
if tool:
|
|
344
|
+
return "Using"
|
|
345
|
+
if state == "responding":
|
|
346
|
+
return "Responding"
|
|
347
|
+
return "Thinking"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _stable_hash(payload: dict[str, Any]) -> str:
|
|
351
|
+
return hashlib.sha256(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")).hexdigest()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _safe_stem(value: str | None) -> bool:
|
|
355
|
+
text = str(value or "")
|
|
356
|
+
return bool(text) and len(text) <= 180 and re.match(r"^[A-Za-z0-9_-]+$", text) is not None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _bounded_optional(value: Any, limit: int) -> str | None:
|
|
360
|
+
if value in (None, ""):
|
|
361
|
+
return None
|
|
362
|
+
return str(value)[:limit]
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _optional_int(value: Any) -> int | None:
|
|
366
|
+
if value in (None, ""):
|
|
367
|
+
return None
|
|
368
|
+
try:
|
|
369
|
+
return int(value)
|
|
370
|
+
except (TypeError, ValueError):
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _optional_float(value: Any) -> float | None:
|
|
375
|
+
if value in (None, ""):
|
|
376
|
+
return None
|
|
377
|
+
try:
|
|
378
|
+
return float(value)
|
|
379
|
+
except (TypeError, ValueError):
|
|
380
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local provider CLI runner shared by /llm-route and Pairling tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
HOME = Path.home()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class LLMRouteError(Exception):
|
|
17
|
+
code: str
|
|
18
|
+
message: str
|
|
19
|
+
status: int = 502
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return self.message
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def llm_route_model_family(model: str) -> str | None:
|
|
26
|
+
if model in ("sonnet", "haiku", "opus"):
|
|
27
|
+
return "claude"
|
|
28
|
+
if model in ("gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"):
|
|
29
|
+
return "codex"
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_executable(candidates: list[Path | str]) -> Path | None:
|
|
34
|
+
for candidate in candidates:
|
|
35
|
+
path = Path(candidate)
|
|
36
|
+
if path.exists() and os.access(path, os.X_OK):
|
|
37
|
+
return path
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_local_llm(
|
|
42
|
+
*,
|
|
43
|
+
model: str,
|
|
44
|
+
prompt: str,
|
|
45
|
+
system: str | None = None,
|
|
46
|
+
timeout_seconds: int = 120,
|
|
47
|
+
) -> str:
|
|
48
|
+
family = llm_route_model_family(model)
|
|
49
|
+
if family is None:
|
|
50
|
+
raise LLMRouteError(
|
|
51
|
+
"invalid_model",
|
|
52
|
+
"model must be sonnet|haiku|opus|gpt-5.5|gpt-5.4|gpt-5.4-mini|gpt-5.3-codex",
|
|
53
|
+
400,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if family == "claude":
|
|
57
|
+
cli = find_executable([
|
|
58
|
+
HOME / ".local" / "bin" / "claude",
|
|
59
|
+
"/opt/homebrew/bin/claude",
|
|
60
|
+
"/usr/local/bin/claude",
|
|
61
|
+
])
|
|
62
|
+
if cli is None:
|
|
63
|
+
raise LLMRouteError("claude_cli_not_found", "claude CLI not found", 502)
|
|
64
|
+
cmd = [
|
|
65
|
+
str(cli), "-p",
|
|
66
|
+
"--output-format", "text",
|
|
67
|
+
"--model", model,
|
|
68
|
+
"--dangerously-skip-permissions",
|
|
69
|
+
]
|
|
70
|
+
if system:
|
|
71
|
+
cmd.extend(["--append-system-prompt", system])
|
|
72
|
+
full_prompt = prompt
|
|
73
|
+
else:
|
|
74
|
+
cli = find_executable([
|
|
75
|
+
HOME / ".local" / "bin" / "codex",
|
|
76
|
+
"/opt/homebrew/bin/codex",
|
|
77
|
+
"/usr/local/bin/codex",
|
|
78
|
+
])
|
|
79
|
+
if cli is None:
|
|
80
|
+
raise LLMRouteError("codex_cli_not_found", "codex CLI not found", 502)
|
|
81
|
+
cmd = [
|
|
82
|
+
str(cli), "exec",
|
|
83
|
+
"--ephemeral",
|
|
84
|
+
"--skip-git-repo-check",
|
|
85
|
+
"--model", model,
|
|
86
|
+
"--sandbox", "read-only",
|
|
87
|
+
"--ask-for-approval", "never",
|
|
88
|
+
"-C", "/tmp",
|
|
89
|
+
"-",
|
|
90
|
+
]
|
|
91
|
+
full_prompt = f"System instructions:\n{system}\n\nUser prompt:\n{prompt}" if system else prompt
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
proc = subprocess.run(
|
|
95
|
+
cmd,
|
|
96
|
+
input=full_prompt,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=timeout_seconds,
|
|
100
|
+
cwd="/tmp",
|
|
101
|
+
)
|
|
102
|
+
except subprocess.TimeoutExpired as exc:
|
|
103
|
+
raise LLMRouteError(f"{family}_cli_timeout", f"{family} CLI timeout", 504) from exc
|
|
104
|
+
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
err = (proc.stderr or "").strip()[:500]
|
|
107
|
+
raise LLMRouteError(f"{family}_cli_failed", f"{family} CLI failed: {err}", 502)
|
|
108
|
+
return (proc.stdout or "").strip()
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local credential provisioning for the Pairling MCP bridge."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pairling_devices import DeviceRegistry, load_or_create_install_id, utc_epoch
|
|
13
|
+
from runtime_paths import app_support_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
LOCAL_MCP_DEVICE_NAME = "Pairling MCP Bridge"
|
|
17
|
+
LOCAL_MCP_SCOPE = "pairling-tools:run"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mcp_bridge_credential_path() -> Path:
|
|
21
|
+
return Path(
|
|
22
|
+
os.environ.get(
|
|
23
|
+
"PAIRLING_MCP_CREDENTIAL",
|
|
24
|
+
str(app_support_root() / "mcp-bridge.json"),
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pairling_install_id() -> str:
|
|
30
|
+
config = app_support_root() / "config.json"
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(config.read_text())
|
|
33
|
+
value = payload.get("install_id")
|
|
34
|
+
if isinstance(value, str) and value.strip():
|
|
35
|
+
return value.strip()
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return load_or_create_install_id()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_local_mcp_bridge_device(
|
|
42
|
+
*,
|
|
43
|
+
registry: DeviceRegistry | None = None,
|
|
44
|
+
credential_path: Path | None = None,
|
|
45
|
+
install_id: str | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
target = credential_path or mcp_bridge_credential_path()
|
|
48
|
+
device_registry = registry or DeviceRegistry()
|
|
49
|
+
install_id_value = install_id or _pairling_install_id()
|
|
50
|
+
|
|
51
|
+
existing = _read_credential(target)
|
|
52
|
+
if existing:
|
|
53
|
+
token = str(existing.get("token") or "")
|
|
54
|
+
auth = device_registry.authenticate(
|
|
55
|
+
token,
|
|
56
|
+
required_scopes=[LOCAL_MCP_SCOPE],
|
|
57
|
+
path="/pairling-tools/run",
|
|
58
|
+
)
|
|
59
|
+
if auth.ok and str(auth.install_id or "") == install_id_value:
|
|
60
|
+
normalized = {
|
|
61
|
+
"device_id": auth.device_id or str(existing.get("device_id") or ""),
|
|
62
|
+
"install_id": install_id_value,
|
|
63
|
+
"token": token,
|
|
64
|
+
"proof_secret": auth.proof_secret or str(existing.get("proof_secret") or ""),
|
|
65
|
+
"scopes": sorted(auth.scopes or {LOCAL_MCP_SCOPE}),
|
|
66
|
+
"created_at": float(existing.get("created_at") or utc_epoch()),
|
|
67
|
+
}
|
|
68
|
+
_write_private_json(target, normalized)
|
|
69
|
+
return normalized
|
|
70
|
+
stale_device_id = str(existing.get("device_id") or "")
|
|
71
|
+
if stale_device_id:
|
|
72
|
+
device_registry.revoke_device(stale_device_id, reason="local_mcp_bridge_invalid")
|
|
73
|
+
|
|
74
|
+
if hasattr(device_registry, "revoke_devices_named"):
|
|
75
|
+
device_registry.revoke_devices_named(LOCAL_MCP_DEVICE_NAME, reason="local_mcp_bridge_rotated")
|
|
76
|
+
|
|
77
|
+
created = device_registry.create_device(
|
|
78
|
+
device_name=LOCAL_MCP_DEVICE_NAME,
|
|
79
|
+
scopes=[LOCAL_MCP_SCOPE],
|
|
80
|
+
install_id=install_id_value,
|
|
81
|
+
device_id="dev_local_mcp_" + secrets.token_hex(12),
|
|
82
|
+
)
|
|
83
|
+
credential = {
|
|
84
|
+
"device_id": created.device_id,
|
|
85
|
+
"install_id": created.install_id,
|
|
86
|
+
"token": created.token,
|
|
87
|
+
"proof_secret": created.proof_secret,
|
|
88
|
+
"scopes": list(created.scopes),
|
|
89
|
+
"created_at": utc_epoch(),
|
|
90
|
+
}
|
|
91
|
+
_write_private_json(target, credential)
|
|
92
|
+
return credential
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_local_mcp_bridge_credential(
|
|
96
|
+
*,
|
|
97
|
+
registry: DeviceRegistry | None = None,
|
|
98
|
+
credential_path: Path | None = None,
|
|
99
|
+
install_id: str | None = None,
|
|
100
|
+
) -> tuple[bool, str]:
|
|
101
|
+
target = credential_path or mcp_bridge_credential_path()
|
|
102
|
+
credential = _read_credential(target)
|
|
103
|
+
if credential is None:
|
|
104
|
+
return False, f"credential missing or unreadable: {target}"
|
|
105
|
+
expected_install_id = install_id or _pairling_install_id()
|
|
106
|
+
if str(credential.get("install_id") or "") != expected_install_id:
|
|
107
|
+
return False, "credential install_id does not match this Mac"
|
|
108
|
+
directory_mode = target.parent.stat().st_mode & 0o777
|
|
109
|
+
file_mode = target.stat().st_mode & 0o777
|
|
110
|
+
if directory_mode & 0o077:
|
|
111
|
+
return False, f"credential directory is not private: {oct(directory_mode)}"
|
|
112
|
+
if file_mode & 0o077:
|
|
113
|
+
return False, f"credential file is not private: {oct(file_mode)}"
|
|
114
|
+
token = str(credential.get("token") or "")
|
|
115
|
+
auth = (registry or DeviceRegistry()).authenticate(
|
|
116
|
+
token,
|
|
117
|
+
required_scopes=[LOCAL_MCP_SCOPE],
|
|
118
|
+
path="/pairling-tools/run",
|
|
119
|
+
)
|
|
120
|
+
if not auth.ok:
|
|
121
|
+
return False, f"credential rejected: {auth.reason}"
|
|
122
|
+
if str(auth.install_id or "") != expected_install_id:
|
|
123
|
+
return False, "credential registry install_id does not match this Mac"
|
|
124
|
+
return True, str(target)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _read_credential(path: Path) -> dict[str, Any] | None:
|
|
128
|
+
try:
|
|
129
|
+
payload = json.loads(path.read_text())
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
132
|
+
if not isinstance(payload, dict):
|
|
133
|
+
return None
|
|
134
|
+
required = ("device_id", "install_id", "token", "proof_secret")
|
|
135
|
+
if not all(str(payload.get(key) or "").strip() for key in required):
|
|
136
|
+
return None
|
|
137
|
+
return payload
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
try:
|
|
143
|
+
os.chmod(path.parent, 0o700) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions - credential directory must be user-only.
|
|
144
|
+
except OSError:
|
|
145
|
+
pass
|
|
146
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
147
|
+
with tmp.open("w") as handle:
|
|
148
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
149
|
+
handle.write("\n")
|
|
150
|
+
handle.flush()
|
|
151
|
+
os.fsync(handle.fileno())
|
|
152
|
+
os.replace(tmp, path)
|
|
153
|
+
try:
|
|
154
|
+
os.chmod(path, 0o600)
|
|
155
|
+
except OSError:
|
|
156
|
+
pass
|