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,597 @@
1
+ package gateway
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "net"
7
+ "net/http"
8
+ "net/http/httputil"
9
+ "net/url"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+ )
14
+
15
+ const defaultMaxBodyBytes int64 = 1_000_000
16
+ const prePairMaxBodyBytes int64 = 16 * 1024
17
+ const pairDropSmallFileMaxBodyBytes int64 = 10 * 1024 * 1024
18
+ const pairDropUploadChunkMaxBodyBytes int64 = 1024 * 1024
19
+
20
+ // Chat attachment uploads (POST /upload) carry whole photos/short videos in
21
+ // one shot — the 1MB default rejected most camera photos with 413.
22
+ const chatUploadMaxBodyBytes int64 = 25 * 1024 * 1024
23
+
24
+ type ExposureMode string
25
+
26
+ const (
27
+ ExposureModePostPair ExposureMode = "post_pair"
28
+ ExposureModePrePair ExposureMode = "pre_pair"
29
+ ExposureModePairlingConnect ExposureMode = "pairling_connect"
30
+ )
31
+
32
+ // Logger receives metadata-only gateway events. Event intentionally excludes
33
+ // request bodies, query values, authorization values, and proof material.
34
+ type Logger interface {
35
+ Log(Event)
36
+ }
37
+
38
+ type Event struct {
39
+ Method string
40
+ Path string
41
+ Outcome string
42
+ Status int
43
+ }
44
+
45
+ type Options struct {
46
+ Upstream *url.URL
47
+ MaxBodyBytes int64
48
+ Mode ExposureMode
49
+ Logger Logger
50
+ RateLimiter RateLimiter
51
+ }
52
+
53
+ type Handler struct {
54
+ upstream *url.URL
55
+ maxBodyBytes int64
56
+ mode ExposureMode
57
+ logger Logger
58
+ rateLimiter RateLimiter
59
+ proxy *httputil.ReverseProxy
60
+ }
61
+
62
+ type RateLimiter interface {
63
+ Allow(remoteAddr, method, path string) bool
64
+ }
65
+
66
+ func NewHandler(opts Options) (*Handler, error) {
67
+ if opts.Upstream == nil {
68
+ return nil, errors.New("upstream is required")
69
+ }
70
+ if opts.Upstream.Scheme != "http" && opts.Upstream.Scheme != "https" {
71
+ return nil, errors.New("upstream scheme must be http or https")
72
+ }
73
+ if opts.Upstream.Host == "" {
74
+ return nil, errors.New("upstream host is required")
75
+ }
76
+ if !localUpstream(opts.Upstream) {
77
+ return nil, errors.New("upstream host must be loopback")
78
+ }
79
+ maxBody := opts.MaxBodyBytes
80
+ if maxBody <= 0 {
81
+ maxBody = defaultMaxBodyBytes
82
+ }
83
+ mode := opts.Mode
84
+ if mode == "" {
85
+ mode = ExposureModePostPair
86
+ }
87
+ if mode != ExposureModePostPair && mode != ExposureModePrePair && mode != ExposureModePairlingConnect {
88
+ return nil, errors.New("unknown exposure mode")
89
+ }
90
+ upstream := *opts.Upstream
91
+ h := &Handler{
92
+ upstream: &upstream,
93
+ maxBodyBytes: maxBody,
94
+ mode: mode,
95
+ logger: opts.Logger,
96
+ rateLimiter: opts.RateLimiter,
97
+ }
98
+ h.proxy = &httputil.ReverseProxy{
99
+ Rewrite: h.rewrite,
100
+ ErrorHandler: h.proxyError,
101
+ FlushInterval: -1,
102
+ }
103
+ return h, nil
104
+ }
105
+
106
+ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
107
+ path := r.URL.EscapedPath()
108
+ if path == "" {
109
+ path = "/"
110
+ }
111
+ if !supportedMethod(r.Method) {
112
+ h.reject(w, r, http.StatusMethodNotAllowed, "method_not_allowed")
113
+ return
114
+ }
115
+ if !h.allowed(r.Method, path, r.Header) {
116
+ if h.allowedForAnyMethod(path, r.Header) {
117
+ h.reject(w, r, http.StatusMethodNotAllowed, "method_not_allowed")
118
+ return
119
+ }
120
+ h.reject(w, r, http.StatusNotFound, "path_not_allowed")
121
+ return
122
+ }
123
+ if h.rateLimiter != nil && h.rateLimitPath(r.Method, path) && !h.rateLimiter.Allow(r.RemoteAddr, r.Method, path) {
124
+ h.reject(w, r, http.StatusTooManyRequests, "rate_limited")
125
+ return
126
+ }
127
+ bodyLimit := h.requestBodyLimit(r.Method, path)
128
+ if bodyLimit > 0 {
129
+ if r.ContentLength > bodyLimit {
130
+ h.reject(w, r, http.StatusRequestEntityTooLarge, "request_too_large")
131
+ return
132
+ }
133
+ r.Body = http.MaxBytesReader(w, r.Body, bodyLimit)
134
+ }
135
+
136
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
137
+ h.proxy.ServeHTTP(rec, r)
138
+ h.log(r, rec.status, "forwarded")
139
+ }
140
+
141
+ func (h *Handler) rewrite(r *httputil.ProxyRequest) {
142
+ in := r.In
143
+ r.SetURL(h.upstream)
144
+ r.Out.URL.Path = joinPath(h.upstream.Path, in.URL.Path)
145
+ r.Out.URL.RawPath = ""
146
+ if h.upstream.RawQuery == "" || in.URL.RawQuery == "" {
147
+ r.Out.URL.RawQuery = h.upstream.RawQuery + in.URL.RawQuery
148
+ } else {
149
+ r.Out.URL.RawQuery = h.upstream.RawQuery + "&" + in.URL.RawQuery
150
+ }
151
+ r.Out.Host = h.upstream.Host
152
+ r.Out.Header.Del("X-Forwarded-For")
153
+ r.Out.Header.Set("X-Pairling-Connect-Gateway", "pairling-connectd")
154
+ r.SetXForwarded()
155
+ }
156
+
157
+ func (h *Handler) proxyError(w http.ResponseWriter, r *http.Request, err error) {
158
+ h.reject(w, r, http.StatusBadGateway, "upstream_error")
159
+ }
160
+
161
+ func (h *Handler) reject(w http.ResponseWriter, r *http.Request, status int, code string) {
162
+ h.log(r, status, code)
163
+ w.Header().Set("Content-Type", "application/json")
164
+ w.WriteHeader(status)
165
+ _ = json.NewEncoder(w).Encode(map[string]any{
166
+ "ok": false,
167
+ "error": map[string]string{
168
+ "code": code,
169
+ },
170
+ })
171
+ }
172
+
173
+ func (h *Handler) log(r *http.Request, status int, outcome string) {
174
+ if h.logger == nil {
175
+ return
176
+ }
177
+ path := r.URL.EscapedPath()
178
+ if path == "" {
179
+ path = "/"
180
+ }
181
+ h.logger.Log(Event{
182
+ Method: r.Method,
183
+ Path: path,
184
+ Outcome: outcome,
185
+ Status: status,
186
+ })
187
+ }
188
+
189
+ type statusRecorder struct {
190
+ http.ResponseWriter
191
+ status int
192
+ }
193
+
194
+ func (r *statusRecorder) WriteHeader(status int) {
195
+ r.status = status
196
+ r.ResponseWriter.WriteHeader(status)
197
+ }
198
+
199
+ func supportedMethod(method string) bool {
200
+ return method == http.MethodGet || method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete
201
+ }
202
+
203
+ func (h *Handler) allowed(method, path string, header http.Header) bool {
204
+ switch h.mode {
205
+ case ExposureModePrePair:
206
+ return prePairAllowed(method, path)
207
+ case ExposureModePairlingConnect:
208
+ if path == "/pair/start" {
209
+ return false
210
+ }
211
+ if prePairAllowed(method, path) {
212
+ return true
213
+ }
214
+ return hasBearer(header) && Allowed(method, path)
215
+ default:
216
+ return Allowed(method, path)
217
+ }
218
+ }
219
+
220
+ func (h *Handler) allowedForAnyMethod(path string, header http.Header) bool {
221
+ switch h.mode {
222
+ case ExposureModePrePair:
223
+ return prePairAllowed(http.MethodGet, path) || prePairAllowed(http.MethodPost, path)
224
+ case ExposureModePairlingConnect:
225
+ if path == "/pair/start" {
226
+ return true
227
+ }
228
+ if prePairAllowed(http.MethodGet, path) || prePairAllowed(http.MethodPost, path) {
229
+ return true
230
+ }
231
+ return hasBearer(header) && allowedForAnyMethod(path)
232
+ default:
233
+ return allowedForAnyMethod(path)
234
+ }
235
+ }
236
+
237
+ func (h *Handler) requestBodyLimit(method, path string) int64 {
238
+ if method == http.MethodPost && path == "/pair/claim" && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect) {
239
+ if h.maxBodyBytes <= 0 || prePairMaxBodyBytes < h.maxBodyBytes {
240
+ return prePairMaxBodyBytes
241
+ }
242
+ }
243
+ if method == http.MethodPost && path == "/pairdrop/files" {
244
+ if h.maxBodyBytes <= 0 || pairDropSmallFileMaxBodyBytes < h.maxBodyBytes {
245
+ return pairDropSmallFileMaxBodyBytes
246
+ }
247
+ }
248
+ if method == http.MethodPost && path == "/upload" {
249
+ if h.maxBodyBytes <= 0 || h.maxBodyBytes < chatUploadMaxBodyBytes {
250
+ return chatUploadMaxBodyBytes
251
+ }
252
+ }
253
+ if method == http.MethodPut && pairDropUploadBytesPath(path) {
254
+ if h.maxBodyBytes <= 0 || pairDropUploadChunkMaxBodyBytes < h.maxBodyBytes {
255
+ return pairDropUploadChunkMaxBodyBytes
256
+ }
257
+ }
258
+ return h.maxBodyBytes
259
+ }
260
+
261
+ func (h *Handler) rateLimitPath(method, path string) bool {
262
+ return method == http.MethodPost && path == "/pair/claim" && (h.mode == ExposureModePrePair || h.mode == ExposureModePairlingConnect)
263
+ }
264
+
265
+ func prePairAllowed(method, path string) bool {
266
+ switch method {
267
+ case http.MethodGet:
268
+ return prePairGetPaths[path]
269
+ case http.MethodPost:
270
+ return prePairPostPaths[path]
271
+ default:
272
+ return false
273
+ }
274
+ }
275
+
276
+ func hasBearer(header http.Header) bool {
277
+ return strings.HasPrefix(header.Get("Authorization"), "Bearer ")
278
+ }
279
+
280
+ func Allowed(method, path string) bool {
281
+ if !supportedMethod(method) {
282
+ return false
283
+ }
284
+ switch method {
285
+ case http.MethodGet:
286
+ return getPaths[path] || dynamicGETPath(path)
287
+ case http.MethodPost:
288
+ return postPaths[path] || dynamicPOSTPath(path)
289
+ case http.MethodPut:
290
+ return dynamicPUTPath(path)
291
+ case http.MethodDelete:
292
+ return dynamicDELETEPath(path)
293
+ default:
294
+ return false
295
+ }
296
+ }
297
+
298
+ func allowedForAnyMethod(path string) bool {
299
+ return getPaths[path] || postPaths[path] || dynamicGETPath(path) || dynamicPOSTPath(path) || dynamicPUTPath(path) || dynamicDELETEPath(path)
300
+ }
301
+
302
+ func localUpstream(upstream *url.URL) bool {
303
+ host := upstream.Hostname()
304
+ if host == "localhost" {
305
+ return true
306
+ }
307
+ ip := net.ParseIP(host)
308
+ return ip != nil && ip.IsLoopback()
309
+ }
310
+
311
+ func dynamicGETPath(path string) bool {
312
+ return sessionExportPath(path) || orchestrationItemPath(path) || orchestrationStreamPath(path) || pairDropFileContentPath(path) || pairDropFileItemPath(path) || pairDropUploadItemPath(path)
313
+ }
314
+
315
+ func dynamicPOSTPath(path string) bool {
316
+ return pickerMCPRestartPath(path) || orchestrationStopPath(path) || pairDropAttachPath(path) || pairDropUploadCompletePath(path)
317
+ }
318
+
319
+ func dynamicPUTPath(path string) bool {
320
+ return pairDropUploadBytesPath(path)
321
+ }
322
+
323
+ func dynamicDELETEPath(path string) bool {
324
+ return pairDropFileItemPath(path) || pairDropUploadItemPath(path)
325
+ }
326
+
327
+ func sessionExportPath(path string) bool {
328
+ return strings.HasPrefix(path, "/sessions/") && strings.HasSuffix(path, "/export")
329
+ }
330
+
331
+ func pickerMCPRestartPath(path string) bool {
332
+ return strings.HasPrefix(path, "/pickers/mcp/") && strings.HasSuffix(path, "/restart")
333
+ }
334
+
335
+ func pairDropFileItemPath(path string) bool {
336
+ if !strings.HasPrefix(path, "/pairdrop/files/") {
337
+ return false
338
+ }
339
+ suffix := strings.TrimPrefix(path, "/pairdrop/files/")
340
+ return suffix != "" && !strings.Contains(suffix, "/")
341
+ }
342
+
343
+ func pairDropFileContentPath(path string) bool {
344
+ if !strings.HasPrefix(path, "/pairdrop/files/") || !strings.HasSuffix(path, "/content") {
345
+ return false
346
+ }
347
+ inner := strings.TrimSuffix(strings.TrimPrefix(path, "/pairdrop/files/"), "/content")
348
+ inner = strings.Trim(inner, "/")
349
+ return inner != "" && !strings.Contains(inner, "/")
350
+ }
351
+
352
+ func pairDropAttachPath(path string) bool {
353
+ if !strings.HasPrefix(path, "/pairdrop/files/") || !strings.HasSuffix(path, "/attach") {
354
+ return false
355
+ }
356
+ inner := strings.TrimSuffix(strings.TrimPrefix(path, "/pairdrop/files/"), "/attach")
357
+ inner = strings.Trim(inner, "/")
358
+ return inner != "" && !strings.Contains(inner, "/")
359
+ }
360
+
361
+ func pairDropUploadItemPath(path string) bool {
362
+ if !strings.HasPrefix(path, "/pairdrop/uploads/") {
363
+ return false
364
+ }
365
+ suffix := strings.TrimPrefix(path, "/pairdrop/uploads/")
366
+ return suffix != "" && !strings.Contains(suffix, "/")
367
+ }
368
+
369
+ func pairDropUploadBytesPath(path string) bool {
370
+ if !strings.HasPrefix(path, "/pairdrop/uploads/") || !strings.HasSuffix(path, "/bytes") {
371
+ return false
372
+ }
373
+ inner := strings.TrimSuffix(strings.TrimPrefix(path, "/pairdrop/uploads/"), "/bytes")
374
+ inner = strings.Trim(inner, "/")
375
+ return inner != "" && !strings.Contains(inner, "/")
376
+ }
377
+
378
+ func pairDropUploadCompletePath(path string) bool {
379
+ if !strings.HasPrefix(path, "/pairdrop/uploads/") || !strings.HasSuffix(path, "/complete") {
380
+ return false
381
+ }
382
+ inner := strings.TrimSuffix(strings.TrimPrefix(path, "/pairdrop/uploads/"), "/complete")
383
+ inner = strings.Trim(inner, "/")
384
+ return inner != "" && !strings.Contains(inner, "/")
385
+ }
386
+
387
+ func orchestrationItemPath(path string) bool {
388
+ if !strings.HasPrefix(path, "/orchestrations/") {
389
+ return false
390
+ }
391
+ suffix := strings.TrimPrefix(path, "/orchestrations/")
392
+ return suffix != "" && !strings.Contains(suffix, "/")
393
+ }
394
+
395
+ func orchestrationStopPath(path string) bool {
396
+ if !strings.HasPrefix(path, "/orchestrations/") {
397
+ return false
398
+ }
399
+ parts := strings.Split(strings.TrimPrefix(path, "/orchestrations/"), "/")
400
+ return len(parts) == 2 && parts[0] != "" && parts[1] == "stop"
401
+ }
402
+
403
+ func orchestrationStreamPath(path string) bool {
404
+ if !strings.HasPrefix(path, "/orchestrations/") {
405
+ return false
406
+ }
407
+ parts := strings.Split(strings.TrimPrefix(path, "/orchestrations/"), "/")
408
+ return len(parts) == 2 && parts[0] != "" && parts[1] == "stream"
409
+ }
410
+
411
+ var getPaths = map[string]bool{
412
+ "/activity": true,
413
+ "/activity-stream": true,
414
+ "/aperture-cli/launch-contexts": true,
415
+ "/aperture-cli/providers": true,
416
+ "/aperture-cli/status": true,
417
+ "/commands": true,
418
+ "/commands-stream": true,
419
+ "/corpus": true,
420
+ "/filesystem/directories": true,
421
+ "/health": true,
422
+ "/health-stream": true,
423
+ "/healthz": true,
424
+ "/readyz": true,
425
+ "/routez": true,
426
+ "/invocations": true,
427
+ "/invocations-stream": true,
428
+ "/manifest": true,
429
+ "/mirror/conflicts": true,
430
+ "/mirror/projects": true,
431
+ "/mirror/status": true,
432
+ "/model-status": true,
433
+ "/orchestrations": true,
434
+ "/personal-context": true,
435
+ "/pairdrop/events": true,
436
+ "/pairdrop/files": true,
437
+ "/pickers/hooks": true,
438
+ "/pickers/mcp": true,
439
+ "/pickers/memory": true,
440
+ "/pickers/permissions": true,
441
+ "/pickers/resume": true,
442
+ "/pickers/resume/preview": true,
443
+ "/power-state": true,
444
+ "/provider-status": true,
445
+ "/push/status": true,
446
+ "/recent-projects": true,
447
+ "/safety/events": true,
448
+ "/safety/status": true,
449
+ "/search": true,
450
+ "/sentinel/events": true,
451
+ "/sentinel/preferences": true,
452
+ "/sentinel/status": true,
453
+ "/session-meta": true,
454
+ "/session-live-events": true,
455
+ "/session-source-diagnostics": true,
456
+ "/sessions": true,
457
+ "/sessions-stream": true,
458
+ "/sessions-visible": true,
459
+ "/session-runtime-truth": true,
460
+ "/session-runtime-truth-stream": true,
461
+ "/status": true,
462
+ "/substrate-feed": true,
463
+ "/substrate-status": true,
464
+ "/terminal-stream": true,
465
+ "/terminal-stream-diagnostics": true,
466
+ "/terminal-surface": true,
467
+ "/terminal-surface-stream": true,
468
+ "/terminal-surface-v2": true,
469
+ "/terminal-surface-stream-v2": true,
470
+ "/terminal-workspace": true,
471
+ "/terminal-workspace-stream": true,
472
+ "/tokens": true,
473
+ "/transcript": true,
474
+ "/transcript-stream": true,
475
+ "/turn-state-stream": true,
476
+ "/worker-stats": true,
477
+ "/workers": true,
478
+ "/workstate-feed": true,
479
+ }
480
+
481
+ var postPaths = map[string]bool{
482
+ "/aperture-cli/open": true,
483
+ "/cross-provider-action": true,
484
+ "/inject": true,
485
+ "/inject-now": true,
486
+ "/interrupt": true,
487
+ "/llm-route": true,
488
+ "/llm-route-stream": true,
489
+ "/mirror/flush": true,
490
+ "/mirror/resume": true,
491
+ "/open": true,
492
+ "/orchestrations": true,
493
+ "/pair/claim": true,
494
+ "/pair/revoke": true,
495
+ "/pair/rotate-token": true,
496
+ "/pair/start": true,
497
+ "/pairling-tools/run": true,
498
+ "/pairdrop/files": true,
499
+ "/pairdrop/maintenance/cleanup-partials": true,
500
+ "/pairdrop/uploads": true,
501
+ "/phone-tools/availability": true,
502
+ "/phone-tools/next": true,
503
+ "/phone-tools/result": true,
504
+ "/push/live-activity-test": true,
505
+ "/push/live-activity-token": true,
506
+ "/push/preferences": true,
507
+ "/push/test": true,
508
+ "/resume-session": true,
509
+ "/safety/ack": true,
510
+ "/send-text": true,
511
+ "/sentinel/evaluate-now": true,
512
+ "/sentinel/preferences": true,
513
+ "/sentinel/snooze": true,
514
+ "/sigint": true,
515
+ "/sigterm": true,
516
+ "/spawn-session": true,
517
+ "/terminal-control": true,
518
+ "/upload": true,
519
+ "/worker-kill": true,
520
+ }
521
+
522
+ var prePairGetPaths = map[string]bool{
523
+ "/health": true,
524
+ "/healthz": true,
525
+ "/readyz": true,
526
+ "/manifest": true,
527
+ }
528
+
529
+ var prePairPostPaths = map[string]bool{
530
+ "/pair/claim": true,
531
+ }
532
+
533
+ type MemoryRateLimiter struct {
534
+ mu sync.Mutex
535
+ limit int
536
+ window time.Duration
537
+ hits map[string][]time.Time
538
+ now func() time.Time
539
+ }
540
+
541
+ func NewMemoryRateLimiter(limit int, window time.Duration) *MemoryRateLimiter {
542
+ if limit <= 0 {
543
+ limit = 20
544
+ }
545
+ if window <= 0 {
546
+ window = 5 * time.Minute
547
+ }
548
+ return &MemoryRateLimiter{
549
+ limit: limit,
550
+ window: window,
551
+ hits: map[string][]time.Time{},
552
+ now: time.Now,
553
+ }
554
+ }
555
+
556
+ func (l *MemoryRateLimiter) Allow(remoteAddr, method, path string) bool {
557
+ if l == nil {
558
+ return true
559
+ }
560
+ l.mu.Lock()
561
+ defer l.mu.Unlock()
562
+ now := l.now()
563
+ cutoff := now.Add(-l.window)
564
+ key := rateLimitKey(remoteAddr, method, path)
565
+ existing := l.hits[key]
566
+ kept := existing[:0]
567
+ for _, ts := range existing {
568
+ if ts.After(cutoff) {
569
+ kept = append(kept, ts)
570
+ }
571
+ }
572
+ if len(kept) >= l.limit {
573
+ l.hits[key] = kept
574
+ return false
575
+ }
576
+ kept = append(kept, now)
577
+ l.hits[key] = kept
578
+ return true
579
+ }
580
+
581
+ func rateLimitKey(remoteAddr, method, path string) string {
582
+ host, _, err := net.SplitHostPort(remoteAddr)
583
+ if err != nil || host == "" {
584
+ host = remoteAddr
585
+ }
586
+ return host + "|" + method + "|" + path
587
+ }
588
+
589
+ func joinPath(base, path string) string {
590
+ if base == "" || base == "/" {
591
+ if path == "" {
592
+ return "/"
593
+ }
594
+ return path
595
+ }
596
+ return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(path, "/")
597
+ }