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.
Files changed (61) hide show
  1. package/package.json +5 -1
  2. package/payload/mac/SOURCE_BRANCH +1 -0
  3. package/payload/mac/SOURCE_DIRTY +1 -0
  4. package/payload/mac/SOURCE_REVISION +1 -0
  5. package/payload/mac/VERSION +1 -0
  6. package/payload/mac/companiond/integrations/__init__.py +1 -0
  7. package/payload/mac/companiond/integrations/aperture_cli/__init__.py +23 -0
  8. package/payload/mac/companiond/integrations/aperture_cli/launch.py +456 -0
  9. package/payload/mac/companiond/integrations/aperture_cli/status.py +393 -0
  10. package/payload/mac/companiond/live_activity_publisher.py +380 -0
  11. package/payload/mac/companiond/llm_route.py +108 -0
  12. package/payload/mac/companiond/local_mcp_bridge.py +156 -0
  13. package/payload/mac/companiond/model_status_contract.py +101 -0
  14. package/payload/mac/companiond/pairdrop_store.py +920 -0
  15. package/payload/mac/companiond/pairling_connectd_status.py +149 -0
  16. package/payload/mac/companiond/pairling_devices.py +459 -0
  17. package/payload/mac/companiond/pairling_pairing.py +404 -0
  18. package/payload/mac/companiond/pairling_relay_claims.py +232 -0
  19. package/payload/mac/companiond/pairling_tools.py +706 -0
  20. package/payload/mac/companiond/pairlingd.py +18438 -0
  21. package/payload/mac/companiond/providers/__init__.py +1 -0
  22. package/payload/mac/companiond/providers/base.py +255 -0
  23. package/payload/mac/companiond/providers/claude.py +127 -0
  24. package/payload/mac/companiond/providers/codex.py +124 -0
  25. package/payload/mac/companiond/providers/external.py +46 -0
  26. package/payload/mac/companiond/providers/registry.py +70 -0
  27. package/payload/mac/companiond/pty_broker.py +887 -0
  28. package/payload/mac/companiond/push_dispatcher.py +1990 -0
  29. package/payload/mac/companiond/push_event_catalog.py +566 -0
  30. package/payload/mac/companiond/request_proof.py +142 -0
  31. package/payload/mac/companiond/runtime_contract.py +47 -0
  32. package/payload/mac/companiond/runtime_manifest.py +197 -0
  33. package/payload/mac/companiond/runtime_paths.py +87 -0
  34. package/payload/mac/companiond/safety_monitor.py +542 -0
  35. package/payload/mac/companiond/sentinel_notifications.py +491 -0
  36. package/payload/mac/companiond/standard_push_publisher.py +516 -0
  37. package/payload/mac/companiond/substrate_status_contract.py +139 -0
  38. package/payload/mac/companiond/terminal_screen_backend.py +332 -0
  39. package/payload/mac/companiond/terminal_text_sanitizer.py +54 -0
  40. package/payload/mac/companiond/workstate_feed_contract.py +108 -0
  41. package/payload/mac/connectd/cmd/pairling-connectd/auth_open_test.go +116 -0
  42. package/payload/mac/connectd/cmd/pairling-connectd/main.go +345 -0
  43. package/payload/mac/connectd/cmd/pairling-connectd/upstream_health_test.go +33 -0
  44. package/payload/mac/connectd/go.mod +51 -0
  45. package/payload/mac/connectd/go.sum +229 -0
  46. package/payload/mac/connectd/internal/gateway/proxy.go +597 -0
  47. package/payload/mac/connectd/internal/gateway/proxy_test.go +531 -0
  48. package/payload/mac/connectd/internal/runtime/config.go +99 -0
  49. package/payload/mac/connectd/internal/runtime/config_test.go +29 -0
  50. package/payload/mac/connectd/internal/status/status.go +300 -0
  51. package/payload/mac/connectd/internal/status/status_test.go +263 -0
  52. package/payload/mac/guardian/companion-power-guardian.py +613 -0
  53. package/payload/mac/guardian/guardian_contract.py +67 -0
  54. package/payload/mac/install/bootstrap-first-run.sh +206 -0
  55. package/payload/mac/install/doctor.sh +660 -0
  56. package/payload/mac/install/install-runtime.sh +1241 -0
  57. package/payload/mac/install/render-launchd.py +119 -0
  58. package/payload/mac/install/uninstall-runtime.sh +136 -0
  59. package/payload/mac/mcp/phone_tools.py +210 -0
  60. package/payload/mac/packaging/bin/pairling +63 -0
  61. 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