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,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
|
+
}
|