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,332 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any, Protocol
10
+
11
+
12
+ PENDING_INPUT_PARSER_VERSION = "terminal_pending_input_v2_2026_06_08"
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TerminalCell:
17
+ text: str
18
+ width: int = 1
19
+ fg: str = "default"
20
+ bg: str = "default"
21
+ bold: bool = False
22
+ italic: bool = False
23
+ underline: bool = False
24
+ inverse: bool = False
25
+ link_id: str | None = None
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class TerminalRow:
30
+ index: int
31
+ cells: tuple[TerminalCell, ...]
32
+ wrapped: bool = False
33
+ dirty_generation: int = 0
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TerminalCursor:
38
+ row: int | None
39
+ column: int | None
40
+ visible: bool = True
41
+ style: str = "block"
42
+
43
+
44
+ def detect_terminal_pending_input(rows: list[str]) -> dict[str, Any] | None:
45
+ choice_re = re.compile(r"^\s*(?P<selected>[>›]?)\s*(?P<id>\d+)[.)]\s+(?P<body>.+?)\s*$")
46
+ choices: list[dict[str, Any]] = []
47
+ prompt = ""
48
+ for idx, row in enumerate(rows):
49
+ match = choice_re.match(row)
50
+ if not match:
51
+ continue
52
+ body = match.group("body").strip()
53
+ label = body
54
+ description = ""
55
+ split = re.split(r"\s{2,}", body, maxsplit=1)
56
+ if len(split) == 2:
57
+ label, description = split[0].strip(), split[1].strip()
58
+ choices.append({
59
+ "id": match.group("id"),
60
+ "label": label,
61
+ "description": description,
62
+ "selected": bool(match.group("selected")),
63
+ })
64
+ if not prompt:
65
+ for prev in reversed(rows[:idx]):
66
+ prev = prev.strip()
67
+ if prev:
68
+ prompt = prev
69
+ break
70
+
71
+ lowered = "\n".join(rows).lower()
72
+ update_prompt = next((row.strip() for row in rows if "update available" in row.lower()), "")
73
+ if update_prompt:
74
+ return {
75
+ "state": "maintenance_update",
76
+ "confidence": "high" if choices else "medium",
77
+ "prompt": update_prompt,
78
+ "kind": "codex_update",
79
+ "choices": choices,
80
+ }
81
+
82
+ if len(choices) >= 2:
83
+ return {
84
+ "state": "awaiting_selection",
85
+ "confidence": "high",
86
+ "prompt": prompt,
87
+ "choices": choices,
88
+ }
89
+
90
+ if "press enter" in lowered or "confirm" in lowered:
91
+ return {
92
+ "state": "awaiting_confirmation",
93
+ "confidence": "medium",
94
+ "prompt": next((r.strip() for r in rows if r.strip()), ""),
95
+ "choices": [],
96
+ }
97
+
98
+ text_prompt_markers = (
99
+ "enter new goal",
100
+ "new goal",
101
+ "what should the goal be",
102
+ "type your response",
103
+ "resume from",
104
+ )
105
+ for row in rows:
106
+ stripped = row.strip()
107
+ lowered_row = stripped.lower()
108
+ if not stripped:
109
+ continue
110
+ if any(marker in lowered_row for marker in text_prompt_markers) or (
111
+ stripped.endswith(":") and any(marker in lowered for marker in ("goal", "resume", "prompt"))
112
+ ):
113
+ return {
114
+ "state": "awaiting_text",
115
+ "confidence": "medium",
116
+ "prompt": stripped,
117
+ "choices": [],
118
+ }
119
+ return None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class TerminalScreenState:
124
+ rows: int
125
+ columns: int
126
+ generation: int
127
+ raw_offset: int
128
+ source: str
129
+ backend: str
130
+ title: str | None
131
+ alternate_screen: bool
132
+ cursor: TerminalCursor
133
+ visible_rows: tuple[TerminalRow, ...]
134
+ dirty_row_indexes: tuple[int, ...]
135
+ capabilities: tuple[str, ...]
136
+ pending_input: dict[str, Any] | None = None
137
+ pending_input_detection: dict[str, Any] | None = None
138
+ degraded_reason: str | None = None
139
+ links: dict[str, str] | None = None
140
+
141
+
142
+ class TerminalScreenBackend(Protocol):
143
+ def feed(self, data: bytes, *, raw_offset: int) -> TerminalScreenState:
144
+ ...
145
+
146
+ def resize(self, rows: int, columns: int) -> TerminalScreenState:
147
+ ...
148
+
149
+ def snapshot(self) -> TerminalScreenState:
150
+ ...
151
+
152
+ def dirty_delta(self, *, since_generation: int) -> TerminalScreenState | None:
153
+ ...
154
+
155
+
156
+ class VTScreenBackend:
157
+ def __init__(self, screen: Any, *, source: str = "broker_vt", backend: str = "pty_broker") -> None:
158
+ self.screen = screen
159
+ self.source = source
160
+ self.backend = backend
161
+ self.generation = 0
162
+ self.raw_offset = 0
163
+ self._dirty_row_indexes: tuple[int, ...] = tuple(range(int(getattr(screen, "rows", 0) or 0)))
164
+
165
+ def feed(self, data: bytes, *, raw_offset: int) -> TerminalScreenState:
166
+ self.screen.feed(data)
167
+ self.generation += 1
168
+ self.raw_offset = max(self.raw_offset, int(raw_offset or 0))
169
+ self._dirty_row_indexes = tuple(range(int(getattr(self.screen, "rows", 0) or 0)))
170
+ return self.snapshot()
171
+
172
+ def resize(self, rows: int, columns: int) -> TerminalScreenState:
173
+ if hasattr(self.screen, "resize"):
174
+ self.screen.resize(rows, columns)
175
+ self.generation += 1
176
+ self._dirty_row_indexes = tuple(range(int(getattr(self.screen, "rows", rows) or rows)))
177
+ return self.snapshot()
178
+
179
+ def snapshot(self) -> TerminalScreenState:
180
+ if hasattr(self.screen, "cell_rows"):
181
+ cell_rows = self.screen.cell_rows()
182
+ text_rows = ["".join(str(cell.get("text", "")) for cell in row).rstrip() for row in cell_rows]
183
+ row_count = int(getattr(self.screen, "rows", len(cell_rows)) or len(cell_rows))
184
+ visible_rows = tuple(
185
+ TerminalRow(
186
+ index=index,
187
+ cells=tuple(TerminalCell(**cell) for cell in row),
188
+ wrapped=bool(getattr(self.screen, "wrapped", [False] * len(cell_rows))[index]),
189
+ dirty_generation=self.generation,
190
+ )
191
+ for index, row in enumerate(cell_rows)
192
+ )
193
+ else:
194
+ rows = list(self.screen.text_rows())
195
+ text_rows = rows
196
+ row_count = int(getattr(self.screen, "rows", len(rows)) or len(rows))
197
+ visible_rows = tuple(
198
+ TerminalRow(
199
+ index=index,
200
+ cells=tuple(TerminalCell(text=ch) for ch in row),
201
+ wrapped=False,
202
+ dirty_generation=self.generation,
203
+ )
204
+ for index, row in enumerate(rows)
205
+ )
206
+ capabilities = ["cells", "attributes", "cursor", "dirty_rows", "raw_offset", "control_receipts"]
207
+ if getattr(self.screen, "title", None):
208
+ capabilities.append("title")
209
+ if getattr(self.screen, "links", None):
210
+ capabilities.append("links")
211
+ if getattr(self.screen, "alternate_screen", False):
212
+ capabilities.append("alternate_screen")
213
+ cursor = TerminalCursor(
214
+ row=getattr(self.screen, "cursor_row", None),
215
+ column=getattr(self.screen, "cursor_col", None),
216
+ visible=True,
217
+ )
218
+ pending_input = detect_terminal_pending_input(text_rows)
219
+ pending_detection = {
220
+ "status": "ran",
221
+ "parser_version": PENDING_INPUT_PARSER_VERSION,
222
+ "surface": "v2",
223
+ "confidence": pending_input.get("confidence") if pending_input else None,
224
+ "reason": None,
225
+ }
226
+ return TerminalScreenState(
227
+ rows=row_count,
228
+ columns=int(getattr(self.screen, "columns", 0) or 0),
229
+ generation=self.generation,
230
+ raw_offset=self.raw_offset,
231
+ source=self.source,
232
+ backend=self.backend,
233
+ title=getattr(self.screen, "title", None),
234
+ alternate_screen=bool(getattr(self.screen, "alternate_screen", False)),
235
+ cursor=cursor,
236
+ visible_rows=visible_rows,
237
+ dirty_row_indexes=self._dirty_row_indexes,
238
+ capabilities=tuple(dict.fromkeys(capabilities)),
239
+ pending_input=pending_input,
240
+ pending_input_detection=pending_detection,
241
+ links=dict(getattr(self.screen, "links", {}) or {}),
242
+ )
243
+
244
+ def dirty_delta(self, *, since_generation: int) -> TerminalScreenState | None:
245
+ if self.generation <= int(since_generation or 0):
246
+ return None
247
+ return self.snapshot()
248
+
249
+
250
+ class DegradedTerminalScreenBackend:
251
+ def __init__(self, fallback: TerminalScreenBackend, *, reason: str, requested_backend: str) -> None:
252
+ self.fallback = fallback
253
+ self.reason = reason
254
+ self.requested_backend = requested_backend
255
+
256
+ @property
257
+ def generation(self) -> int:
258
+ return int(getattr(self.fallback, "generation", 0) or 0)
259
+
260
+ @property
261
+ def raw_offset(self) -> int:
262
+ return int(getattr(self.fallback, "raw_offset", 0) or 0)
263
+
264
+ def feed(self, data: bytes, *, raw_offset: int) -> TerminalScreenState:
265
+ return self._degrade(self.fallback.feed(data, raw_offset=raw_offset))
266
+
267
+ def resize(self, rows: int, columns: int) -> TerminalScreenState:
268
+ return self._degrade(self.fallback.resize(rows, columns))
269
+
270
+ def snapshot(self) -> TerminalScreenState:
271
+ return self._degrade(self.fallback.snapshot())
272
+
273
+ def dirty_delta(self, *, since_generation: int) -> TerminalScreenState | None:
274
+ state = self.fallback.dirty_delta(since_generation=since_generation)
275
+ return self._degrade(state) if state is not None else None
276
+
277
+ def _degrade(self, state: TerminalScreenState) -> TerminalScreenState:
278
+ return TerminalScreenState(
279
+ rows=state.rows,
280
+ columns=state.columns,
281
+ generation=state.generation,
282
+ raw_offset=state.raw_offset,
283
+ source=state.source,
284
+ backend=state.backend,
285
+ title=state.title,
286
+ alternate_screen=state.alternate_screen,
287
+ cursor=state.cursor,
288
+ visible_rows=state.visible_rows,
289
+ dirty_row_indexes=state.dirty_row_indexes,
290
+ capabilities=state.capabilities,
291
+ pending_input=state.pending_input,
292
+ pending_input_detection=state.pending_input_detection,
293
+ degraded_reason=self.reason,
294
+ links=state.links,
295
+ )
296
+
297
+
298
+ class PyteScreenBackend:
299
+ def __init__(self, *, rows: int, columns: int, source: str = "broker_vt", backend: str = "pyte") -> None:
300
+ if importlib.util.find_spec("pyte") is None or importlib.util.find_spec("wcwidth") is None:
301
+ raise RuntimeError("parser_backend_unavailable")
302
+ raise NotImplementedError("pyte backend is not selected until packaging proof exists")
303
+
304
+
305
+ def create_terminal_screen_backend(screen: Any, *, backend_name: str | None = None) -> TerminalScreenBackend:
306
+ requested = (backend_name or os.environ.get("PAIRLING_TERMINAL_BACKEND") or "vt").strip().lower()
307
+ fallback = VTScreenBackend(screen)
308
+ if requested in {"", "vt", "vtscreen", "pty_broker"}:
309
+ return fallback
310
+ if requested == "pyte":
311
+ try:
312
+ return PyteScreenBackend(
313
+ rows=int(getattr(screen, "rows", 30) or 30),
314
+ columns=int(getattr(screen, "columns", 120) or 120),
315
+ )
316
+ except (RuntimeError, NotImplementedError):
317
+ return DegradedTerminalScreenBackend(
318
+ fallback,
319
+ reason="parser_backend_unavailable",
320
+ requested_backend="pyte",
321
+ )
322
+ return DegradedTerminalScreenBackend(
323
+ fallback,
324
+ reason="parser_backend_unavailable",
325
+ requested_backend=requested,
326
+ )
327
+
328
+
329
+ def semantic_hash(material: dict[str, Any]) -> str:
330
+ return "sha256:" + hashlib.sha256(
331
+ json.dumps(material, sort_keys=True, separators=(",", ":")).encode("utf-8")
332
+ ).hexdigest()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ TERMINAL_TEXT_MAX_CHARS = 8000
5
+ TERMINAL_TEXT_SUBMIT_MAX_CHARS = 2000
6
+ _BRACKETED_PASTE_START = "\x1b[200~"
7
+ _BRACKETED_PASTE_END = "\x1b[201~"
8
+ _BIDI_CONTROL_CODES = {
9
+ 0x061C, # Arabic Letter Mark
10
+ 0x200E, # Left-to-Right Mark
11
+ 0x200F, # Right-to-Left Mark
12
+ *range(0x202A, 0x202F), # bidi embedding/override/pop controls
13
+ *range(0x2066, 0x206A), # bidi isolate/pop controls
14
+ }
15
+
16
+
17
+ def terminal_text_rejection_reason(text: str, *, allow_newline: bool) -> tuple[str, str] | None:
18
+ if _BRACKETED_PASTE_START in text or _BRACKETED_PASTE_END in text:
19
+ return "bracketed_paste_delimiter", "bracketed paste delimiters are not accepted from clients"
20
+ for ch in text:
21
+ code = ord(ch)
22
+ if ch == "\n":
23
+ if allow_newline:
24
+ continue
25
+ return "multi_line_text", "terminal text must be single-line"
26
+ if ch == "\t" and allow_newline:
27
+ continue
28
+ if code == 0x1B:
29
+ return "escape_not_allowed", "ESC is not accepted in terminal text"
30
+ if code in _BIDI_CONTROL_CODES:
31
+ return "bidi_control_not_allowed", "Unicode bidi controls are not accepted in terminal text"
32
+ if code < 0x20:
33
+ return "c0_not_allowed", "C0 control characters are not accepted in terminal text"
34
+ if code == 0x7F or 0x80 <= code <= 0x9F:
35
+ return "c1_or_del_not_allowed", "DEL and C1 control characters are not accepted in terminal text"
36
+ return None
37
+
38
+
39
+ def sanitize_terminal_text_input(
40
+ text: str,
41
+ *,
42
+ allow_newline: bool,
43
+ max_chars: int,
44
+ ) -> tuple[str | None, dict | None]:
45
+ rejection = terminal_text_rejection_reason(text, allow_newline=allow_newline)
46
+ if rejection:
47
+ code, message = rejection
48
+ return None, {"code": code, "message": message, "status": 400}
49
+ cleaned = text.strip()
50
+ if not cleaned:
51
+ return None, {"code": "empty_text", "message": "terminal text cannot be empty", "status": 400}
52
+ if len(cleaned) > max_chars:
53
+ return None, {"code": "text_too_long", "message": f"terminal text exceeds {max_chars} chars", "status": 413}
54
+ return cleaned, None
@@ -0,0 +1,108 @@
1
+ """Read-only workstate feed contract for the Pairling Mac daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Mapping
10
+
11
+
12
+ DEFAULT_SINCE = "0000-01-01T00:00:00.000Z"
13
+ DEFAULT_LIMIT = 50
14
+ DEFAULT_MAX_LIMIT = 200
15
+ DEFAULT_ORGANIZER_BIN = str(Path.home() / "projects" / "metal-perception-memory-substrate" / "organizer")
16
+ DEFAULT_ORGANIZER_CONFIG = str(Path.home() / "projects" / "metal-perception-memory-substrate" / "organizer.yaml")
17
+
18
+
19
+ class WorkstateFeedError(ValueError):
20
+ pass
21
+
22
+
23
+ def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
24
+ return os.environ if env is None else env
25
+
26
+
27
+ def workstate_feed_max_limit(env: Mapping[str, str] | None = None) -> int:
28
+ current_env = _env(env)
29
+ try:
30
+ value = int(current_env.get("COMPANION_WORKSTATE_FEED_MAX_LIMIT", str(DEFAULT_MAX_LIMIT)))
31
+ except ValueError:
32
+ return DEFAULT_MAX_LIMIT
33
+ return max(1, min(value, 1000))
34
+
35
+
36
+ def organizer_bin(env: Mapping[str, str] | None = None) -> str:
37
+ return _env(env).get("WORKSTATE_ORGANIZER_BIN") or DEFAULT_ORGANIZER_BIN
38
+
39
+
40
+ def organizer_config(env: Mapping[str, str] | None = None) -> str:
41
+ return _env(env).get("WORKSTATE_ORGANIZER_CONFIG") or DEFAULT_ORGANIZER_CONFIG
42
+
43
+
44
+ def build_workstate_feed_command(
45
+ run: str | Path,
46
+ since: str = DEFAULT_SINCE,
47
+ limit: int = DEFAULT_LIMIT,
48
+ event_types: list[str] | None = None,
49
+ *,
50
+ organizer: str | None = None,
51
+ config: str | None = None,
52
+ env: Mapping[str, str] | None = None,
53
+ ) -> list[str]:
54
+ if not str(run).strip():
55
+ raise WorkstateFeedError("run is required")
56
+ if limit <= 0:
57
+ raise WorkstateFeedError("limit must be positive")
58
+
59
+ current_limit = min(limit, workstate_feed_max_limit(env))
60
+ command = [
61
+ organizer or organizer_bin(env),
62
+ "--config",
63
+ config or organizer_config(env),
64
+ "workstate",
65
+ "feed",
66
+ "--run",
67
+ str(run),
68
+ "--since",
69
+ since,
70
+ "--limit",
71
+ str(current_limit),
72
+ "--json",
73
+ ]
74
+ for event_type in event_types or []:
75
+ if not event_type or len(event_type) > 120:
76
+ raise WorkstateFeedError("event type must be a non-empty bounded string")
77
+ command.extend(["--type", event_type])
78
+ return command
79
+
80
+
81
+ def fetch_workstate_feed(
82
+ run: str | Path,
83
+ since: str = DEFAULT_SINCE,
84
+ limit: int = DEFAULT_LIMIT,
85
+ event_types: list[str] | None = None,
86
+ *,
87
+ timeout_seconds: float = 5.0,
88
+ env: Mapping[str, str] | None = None,
89
+ ) -> dict:
90
+ command = build_workstate_feed_command(run, since=since, limit=limit, event_types=event_types, env=env)
91
+ try:
92
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=timeout_seconds, check=False)
93
+ except OSError as exc:
94
+ raise WorkstateFeedError(f"could not execute workstate feed adapter: {exc}") from exc
95
+ except subprocess.TimeoutExpired as exc:
96
+ raise WorkstateFeedError("workstate feed adapter timed out") from exc
97
+ if completed.returncode != 0:
98
+ detail = (completed.stderr or completed.stdout or "").strip()[:500]
99
+ raise WorkstateFeedError(f"workstate feed adapter failed: {detail}")
100
+ try:
101
+ payload = json.loads(completed.stdout)
102
+ except json.JSONDecodeError as exc:
103
+ raise WorkstateFeedError("workstate feed adapter returned invalid JSON") from exc
104
+ if payload.get("feed") != "workstate.readonly.v1" or payload.get("read_only") is not True:
105
+ raise WorkstateFeedError("workstate feed adapter returned an unexpected contract")
106
+ payload["consumer"] = "pairling"
107
+ payload["adapter"] = "pairling.workstate_feed"
108
+ return payload
@@ -0,0 +1,116 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "strings"
8
+ "testing"
9
+
10
+ "dev.pairling/connectd/internal/status"
11
+ )
12
+
13
+ func TestAuthOpenEndpointOpensStoredURLWithoutReturningIt(t *testing.T) {
14
+ store := status.NewStore("pairling-inst-test")
15
+ rawAuthURL := "https://login.tailscale.com/a/secret-auth-token?next=pairling"
16
+ store.SetAuthPending("approve at " + rawAuthURL)
17
+
18
+ originalOpenAuthURL := openAuthURL
19
+ defer func() { openAuthURL = originalOpenAuthURL }()
20
+ var openedURL string
21
+ openAuthURL = func(rawURL string) error {
22
+ openedURL = rawURL
23
+ return nil
24
+ }
25
+
26
+ req := httptest.NewRequest(http.MethodPost, "/auth/open", nil)
27
+ req.RemoteAddr = "127.0.0.1:54321"
28
+ rec := httptest.NewRecorder()
29
+ handleAuthOpen(rec, req, store)
30
+
31
+ if rec.Code != http.StatusOK {
32
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
33
+ }
34
+ if openedURL != rawAuthURL {
35
+ t.Fatalf("opened URL = %q, want raw in-memory auth URL", openedURL)
36
+ }
37
+ if strings.Contains(rec.Body.String(), "secret-auth-token") || strings.Contains(rec.Body.String(), "login.tailscale.com/a/") {
38
+ t.Fatalf("auth/open response leaked auth URL: %s", rec.Body.String())
39
+ }
40
+ var body map[string]any
41
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ if body["ok"] != true || body["opened"] != true || body["auth_url_present"] != true {
45
+ t.Fatalf("bad auth/open response: %+v", body)
46
+ }
47
+ }
48
+
49
+ func TestAuthOpenEndpointRequiresLoopbackAndPost(t *testing.T) {
50
+ store := status.NewStore("pairling-inst-test")
51
+ store.SetAuthPending("approve at https://login.tailscale.com/a/example-token")
52
+
53
+ for _, tc := range []struct {
54
+ name string
55
+ method string
56
+ remoteAddr string
57
+ wantStatus int
58
+ }{
59
+ {name: "get denied", method: http.MethodGet, remoteAddr: "127.0.0.1:54321", wantStatus: http.StatusMethodNotAllowed},
60
+ {name: "non loopback denied", method: http.MethodPost, remoteAddr: "192.0.2.10:54321", wantStatus: http.StatusForbidden},
61
+ } {
62
+ t.Run(tc.name, func(t *testing.T) {
63
+ req := httptest.NewRequest(tc.method, "/auth/open", nil)
64
+ req.RemoteAddr = tc.remoteAddr
65
+ rec := httptest.NewRecorder()
66
+ handleAuthOpen(rec, req, store)
67
+ if rec.Code != tc.wantStatus {
68
+ t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
69
+ }
70
+ })
71
+ }
72
+ }
73
+
74
+ func TestAuthOpenEndpointReturnsSafeUnavailableError(t *testing.T) {
75
+ store := status.NewStore("pairling-inst-test")
76
+
77
+ req := httptest.NewRequest(http.MethodPost, "/auth/open", nil)
78
+ req.RemoteAddr = "127.0.0.1:54321"
79
+ rec := httptest.NewRecorder()
80
+ handleAuthOpen(rec, req, store)
81
+
82
+ if rec.Code != http.StatusConflict {
83
+ t.Fatalf("status = %d, want 409", rec.Code)
84
+ }
85
+ if strings.Contains(rec.Body.String(), "login.tailscale.com/a/") {
86
+ t.Fatalf("unavailable response leaked auth URL: %s", rec.Body.String())
87
+ }
88
+ }
89
+
90
+ func TestAuthOpenEndpointDoesNotOpenInvalidAuthURL(t *testing.T) {
91
+ store := status.NewStore("pairling-inst-test")
92
+ store.SetAuthPending("approve at http://login.tailscale.com/a/not-secure")
93
+
94
+ originalOpenAuthURL := openAuthURL
95
+ defer func() { openAuthURL = originalOpenAuthURL }()
96
+ opened := false
97
+ openAuthURL = func(rawURL string) error {
98
+ opened = true
99
+ return nil
100
+ }
101
+
102
+ req := httptest.NewRequest(http.MethodPost, "/auth/open", nil)
103
+ req.RemoteAddr = "127.0.0.1:54321"
104
+ rec := httptest.NewRecorder()
105
+ handleAuthOpen(rec, req, store)
106
+
107
+ if rec.Code != http.StatusConflict {
108
+ t.Fatalf("status = %d, want 409", rec.Code)
109
+ }
110
+ if opened {
111
+ t.Fatal("invalid auth URL was opened")
112
+ }
113
+ if strings.Contains(rec.Body.String(), "not-secure") || strings.Contains(rec.Body.String(), "login.tailscale.com/a/") {
114
+ t.Fatalf("invalid URL response leaked auth URL: %s", rec.Body.String())
115
+ }
116
+ }