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,531 @@
1
+ package gateway
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "os"
11
+ "path/filepath"
12
+ "strings"
13
+ "sync"
14
+ "testing"
15
+ "time"
16
+ )
17
+
18
+ type recordingLogger struct {
19
+ mu sync.Mutex
20
+ events []Event
21
+ }
22
+
23
+ func (l *recordingLogger) Log(event Event) {
24
+ l.mu.Lock()
25
+ defer l.mu.Unlock()
26
+ l.events = append(l.events, event)
27
+ }
28
+
29
+ func (l *recordingLogger) joined() string {
30
+ l.mu.Lock()
31
+ defer l.mu.Unlock()
32
+ return fmt.Sprintf("%+v", l.events)
33
+ }
34
+
35
+ func TestHandlerForwardsAllowedRequestAndPreservesAuthProofHeaders(t *testing.T) {
36
+ var sawRequest bool
37
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38
+ sawRequest = true
39
+ if r.Method != http.MethodPost {
40
+ t.Fatalf("method = %s, want POST", r.Method)
41
+ }
42
+ if got := r.URL.RequestURI(); got != "/send-text?session=s-1" {
43
+ t.Fatalf("request URI = %s", got)
44
+ }
45
+ body, err := io.ReadAll(r.Body)
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ if string(body) != `{"text":"hello"}` {
50
+ t.Fatalf("body = %q", string(body))
51
+ }
52
+ for _, header := range []string{
53
+ "Authorization",
54
+ "Pairling-Install-ID",
55
+ "Pairling-Request-ID",
56
+ "Pairling-Timestamp",
57
+ "Pairling-Body-SHA256",
58
+ "Pairling-Proof",
59
+ "X-Pairling-Action-Id",
60
+ } {
61
+ if r.Header.Get(header) == "" {
62
+ t.Fatalf("missing forwarded header %s", header)
63
+ }
64
+ }
65
+ w.Header().Set("Content-Type", "application/json")
66
+ w.WriteHeader(http.StatusAccepted)
67
+ _, _ = w.Write([]byte(`{"ok":true}`))
68
+ }))
69
+ defer upstream.Close()
70
+
71
+ handler := newTestHandler(t, upstream.URL, 1024, nil)
72
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text?session=s-1", strings.NewReader(`{"text":"hello"}`))
73
+ req.Header.Set("Authorization", "Bearer device-token")
74
+ req.Header.Set("Pairling-Install-ID", "inst_test")
75
+ req.Header.Set("Pairling-Request-ID", "req_test")
76
+ req.Header.Set("Pairling-Timestamp", "1779490000000")
77
+ req.Header.Set("Pairling-Body-SHA256", "bodyhash")
78
+ req.Header.Set("Pairling-Proof", "proof")
79
+ req.Header.Set("X-Pairling-Action-Id", "action-1")
80
+
81
+ rec := httptest.NewRecorder()
82
+ handler.ServeHTTP(rec, req)
83
+
84
+ if rec.Code != http.StatusAccepted {
85
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
86
+ }
87
+ if !sawRequest {
88
+ t.Fatal("upstream did not receive allowed request")
89
+ }
90
+ }
91
+
92
+ func TestHandlerRejectsDisallowedTrafficBeforeUpstream(t *testing.T) {
93
+ var upstreamCalls int
94
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95
+ upstreamCalls++
96
+ http.Error(w, "should not be called", http.StatusInternalServerError)
97
+ }))
98
+ defer upstream.Close()
99
+ handler := newTestHandler(t, upstream.URL, 1024, nil)
100
+
101
+ cases := []struct {
102
+ name string
103
+ method string
104
+ target string
105
+ want int
106
+ }{
107
+ {"unknown path", http.MethodGet, "http://pairling-connect.local/debug/pprof", http.StatusNotFound},
108
+ {"unsupported method on allowed path", http.MethodDelete, "http://pairling-connect.local/healthz", http.StatusMethodNotAllowed},
109
+ {"connect proxy method", http.MethodConnect, "http://pairling-connect.local/healthz", http.StatusMethodNotAllowed},
110
+ {"arbitrary proxy-looking path", http.MethodGet, "http://pairling-connect.local/http://127.0.0.1:7773/healthz", http.StatusNotFound},
111
+ }
112
+
113
+ for _, tc := range cases {
114
+ t.Run(tc.name, func(t *testing.T) {
115
+ rec := httptest.NewRecorder()
116
+ handler.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.target, nil))
117
+ if rec.Code != tc.want {
118
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, tc.want, rec.Body.String())
119
+ }
120
+ })
121
+ }
122
+ if upstreamCalls != 0 {
123
+ t.Fatalf("upstream calls = %d, want 0", upstreamCalls)
124
+ }
125
+ }
126
+
127
+ func TestAllowedApertureCLIRoutesStayPairlingScoped(t *testing.T) {
128
+ allowedGET := []string{
129
+ "/aperture-cli/launch-contexts",
130
+ "/aperture-cli/status",
131
+ "/aperture-cli/providers",
132
+ }
133
+ for _, path := range allowedGET {
134
+ if !Allowed(http.MethodGet, path) {
135
+ t.Fatalf("GET %s should be allowed", path)
136
+ }
137
+ if Allowed(http.MethodPost, path) {
138
+ t.Fatalf("POST %s should not be allowed", path)
139
+ }
140
+ }
141
+ if !Allowed(http.MethodPost, "/aperture-cli/open") {
142
+ t.Fatal("POST /aperture-cli/open should be allowed for proof-bound manual TUI launch")
143
+ }
144
+ if Allowed(http.MethodGet, "/aperture-cli/open") {
145
+ t.Fatal("GET /aperture-cli/open should not be allowed")
146
+ }
147
+ disallowed := []string{
148
+ "/aperture-proxy/api/providers",
149
+ "/aperture-cli/config",
150
+ "/aperture-cli/providers/openai",
151
+ "/v1/responses",
152
+ "/v1/messages",
153
+ "/v1/chat/completions",
154
+ }
155
+ for _, path := range disallowed {
156
+ if Allowed(http.MethodGet, path) || Allowed(http.MethodPost, path) {
157
+ t.Fatalf("%s should not be allowed", path)
158
+ }
159
+ }
160
+ }
161
+
162
+ func TestAllowedOrchestrationRoutesAreMethodScoped(t *testing.T) {
163
+ allowed := []struct {
164
+ method string
165
+ path string
166
+ }{
167
+ {http.MethodGet, "/orchestrations"},
168
+ {http.MethodPost, "/orchestrations"},
169
+ {http.MethodGet, "/orchestrations/orchestration-abc123"},
170
+ {http.MethodGet, "/orchestrations/orchestration-abc123/stream"},
171
+ {http.MethodPost, "/orchestrations/orchestration-abc123/stop"},
172
+ }
173
+ for _, tc := range allowed {
174
+ if !Allowed(tc.method, tc.path) {
175
+ t.Fatalf("%s %s should be allowed", tc.method, tc.path)
176
+ }
177
+ }
178
+
179
+ disallowed := []struct {
180
+ method string
181
+ path string
182
+ }{
183
+ {http.MethodGet, "/orchestrations/orchestration-abc123/stop"},
184
+ {http.MethodPost, "/orchestrations/orchestration-abc123/stream"},
185
+ {http.MethodPost, "/orchestrations/orchestration-abc123"},
186
+ {http.MethodGet, "/orchestrations/orchestration-abc123/nested/stream"},
187
+ {http.MethodPost, "/orchestrations/orchestration-abc123/nested/stop"},
188
+ }
189
+ for _, tc := range disallowed {
190
+ if Allowed(tc.method, tc.path) {
191
+ t.Fatalf("%s %s should not be allowed", tc.method, tc.path)
192
+ }
193
+ }
194
+ }
195
+
196
+ func TestAllowedSessionSourceDiagnosticsIsGetOnly(t *testing.T) {
197
+ if !Allowed(http.MethodGet, "/session-source-diagnostics") {
198
+ t.Fatal("GET /session-source-diagnostics should be allowed")
199
+ }
200
+ if Allowed(http.MethodPost, "/session-source-diagnostics") {
201
+ t.Fatal("POST /session-source-diagnostics should not be allowed")
202
+ }
203
+ }
204
+
205
+ func TestAllowedPairDropContentRouteIsGetOnlyAndPathStrict(t *testing.T) {
206
+ if !Allowed(http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/content") {
207
+ t.Fatal("GET PairDrop content route should be allowed")
208
+ }
209
+ rejected := []struct {
210
+ method string
211
+ path string
212
+ }{
213
+ {http.MethodPost, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/content"},
214
+ {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/content/extra"},
215
+ {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/extra/content"},
216
+ }
217
+ for _, tc := range rejected {
218
+ if Allowed(tc.method, tc.path) {
219
+ t.Fatalf("%s %s should not be allowed", tc.method, tc.path)
220
+ }
221
+ }
222
+ }
223
+
224
+ func TestHandlerEnforcesRequestBodyLimitBeforeUpstream(t *testing.T) {
225
+ var upstreamCalls int
226
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
227
+ upstreamCalls++
228
+ }))
229
+ defer upstream.Close()
230
+ handler := newTestHandler(t, upstream.URL, 8, nil)
231
+
232
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader("0123456789"))
233
+ rec := httptest.NewRecorder()
234
+ handler.ServeHTTP(rec, req)
235
+
236
+ if rec.Code != http.StatusRequestEntityTooLarge {
237
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
238
+ }
239
+ if upstreamCalls != 0 {
240
+ t.Fatalf("upstream calls = %d, want 0", upstreamCalls)
241
+ }
242
+ }
243
+
244
+ func TestHandlerLogsMetadataWithoutSensitiveBodies(t *testing.T) {
245
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
246
+ _, _ = io.Copy(io.Discard, r.Body)
247
+ w.WriteHeader(http.StatusOK)
248
+ }))
249
+ defer upstream.Close()
250
+ logger := &recordingLogger{}
251
+ handler := newTestHandler(t, upstream.URL, 1024, logger)
252
+
253
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{"text":"sk-secret prompt transcript"}`))
254
+ rec := httptest.NewRecorder()
255
+ handler.ServeHTTP(rec, req)
256
+
257
+ if rec.Code != http.StatusOK {
258
+ t.Fatalf("status = %d, want 200", rec.Code)
259
+ }
260
+ logged := logger.joined()
261
+ for _, sensitive := range []string{"sk-secret", "prompt", "transcript"} {
262
+ if strings.Contains(logged, sensitive) {
263
+ t.Fatalf("log leaked %q: %s", sensitive, logged)
264
+ }
265
+ }
266
+ if !strings.Contains(logged, "/send-text") {
267
+ t.Fatalf("log missing endpoint metadata: %s", logged)
268
+ }
269
+ }
270
+
271
+ func TestNewHandlerRejectsNonLocalUpstream(t *testing.T) {
272
+ cases := []string{
273
+ "http://example.com:7773",
274
+ "http://10.0.0.20:7773",
275
+ "http://192.168.1.20:7773",
276
+ }
277
+
278
+ for _, raw := range cases {
279
+ t.Run(raw, func(t *testing.T) {
280
+ upstreamURL, err := url.Parse(raw)
281
+ if err != nil {
282
+ t.Fatal(err)
283
+ }
284
+ if _, err := NewHandler(Options{Upstream: upstreamURL}); err == nil {
285
+ t.Fatal("NewHandler accepted non-local upstream")
286
+ }
287
+ })
288
+ }
289
+ }
290
+
291
+ func TestNewHandlerAcceptsLoopbackUpstream(t *testing.T) {
292
+ cases := []string{
293
+ "http://127.0.0.1:7773",
294
+ "http://localhost:7773",
295
+ "http://[::1]:7773",
296
+ }
297
+
298
+ for _, raw := range cases {
299
+ t.Run(raw, func(t *testing.T) {
300
+ upstreamURL, err := url.Parse(raw)
301
+ if err != nil {
302
+ t.Fatal(err)
303
+ }
304
+ if _, err := NewHandler(Options{Upstream: upstreamURL}); err != nil {
305
+ t.Fatalf("NewHandler rejected loopback upstream: %v", err)
306
+ }
307
+ })
308
+ }
309
+ }
310
+
311
+ func TestPrePairModeOnlyAllowsHealthManifestAndClaim(t *testing.T) {
312
+ var forwarded []string
313
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
314
+ forwarded = append(forwarded, r.Method+" "+r.URL.Path)
315
+ w.Header().Set("Content-Type", "application/json")
316
+ _, _ = w.Write([]byte(`{"ok":true}`))
317
+ }))
318
+ defer upstream.Close()
319
+ handler := newTestHandlerWithMode(t, upstream.URL, 1024, nil, ExposureModePrePair, nil)
320
+
321
+ allowed := []struct {
322
+ method string
323
+ path string
324
+ }{
325
+ {http.MethodGet, "/health"},
326
+ {http.MethodGet, "/healthz"},
327
+ {http.MethodGet, "/manifest"},
328
+ {http.MethodPost, "/pair/claim"},
329
+ }
330
+ for _, tc := range allowed {
331
+ t.Run("allows "+tc.method+" "+tc.path, func(t *testing.T) {
332
+ rec := httptest.NewRecorder()
333
+ handler.ServeHTTP(rec, httptest.NewRequest(tc.method, "http://pairling-connect.local"+tc.path, strings.NewReader(`{}`)))
334
+ if rec.Code != http.StatusOK {
335
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
336
+ }
337
+ })
338
+ }
339
+
340
+ rejected := []struct {
341
+ method string
342
+ path string
343
+ want int
344
+ }{
345
+ {http.MethodPost, "/pair/start", http.StatusNotFound},
346
+ {http.MethodGet, "/sessions", http.StatusNotFound},
347
+ {http.MethodGet, "/sessions-stream", http.StatusNotFound},
348
+ {http.MethodPost, "/send-text", http.StatusNotFound},
349
+ {http.MethodPost, "/terminal-control", http.StatusNotFound},
350
+ {http.MethodPost, "/worker-kill", http.StatusNotFound},
351
+ {http.MethodPost, "/push/preferences", http.StatusNotFound},
352
+ {http.MethodPost, "/safety/ack", http.StatusNotFound},
353
+ {http.MethodPost, "/aperture-cli/open", http.StatusNotFound},
354
+ {http.MethodGet, "/health", http.StatusOK},
355
+ {http.MethodPost, "/health", http.StatusMethodNotAllowed},
356
+ }
357
+ for _, tc := range rejected {
358
+ t.Run("policy "+tc.method+" "+tc.path, func(t *testing.T) {
359
+ rec := httptest.NewRecorder()
360
+ handler.ServeHTTP(rec, httptest.NewRequest(tc.method, "http://pairling-connect.local"+tc.path, strings.NewReader(`{}`)))
361
+ if rec.Code != tc.want {
362
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, tc.want, rec.Body.String())
363
+ }
364
+ })
365
+ }
366
+
367
+ if len(forwarded) != len(allowed)+1 {
368
+ t.Fatalf("forwarded = %+v", forwarded)
369
+ }
370
+ }
371
+
372
+ func TestPairlingConnectModeRequiresBearerForPostPairEndpointsAndRejectsRemotePairStart(t *testing.T) {
373
+ var forwarded []string
374
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
375
+ forwarded = append(forwarded, r.Method+" "+r.URL.Path)
376
+ w.WriteHeader(http.StatusOK)
377
+ }))
378
+ defer upstream.Close()
379
+ handler := newTestHandlerWithMode(t, upstream.URL, 1024, nil, ExposureModePairlingConnect, nil)
380
+
381
+ rec := httptest.NewRecorder()
382
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`)))
383
+ if rec.Code != http.StatusNotFound {
384
+ t.Fatalf("unauthorized send-text status = %d body = %s", rec.Code, rec.Body.String())
385
+ }
386
+
387
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
388
+ req.Header.Set("Authorization", "Bearer device-token")
389
+ rec = httptest.NewRecorder()
390
+ handler.ServeHTTP(rec, req)
391
+ if rec.Code != http.StatusOK {
392
+ t.Fatalf("authorized send-text status = %d body = %s", rec.Code, rec.Body.String())
393
+ }
394
+
395
+ req = httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/pair/start", strings.NewReader(`{}`))
396
+ req.Header.Set("Authorization", "Bearer device-token")
397
+ rec = httptest.NewRecorder()
398
+ handler.ServeHTTP(rec, req)
399
+ if rec.Code != http.StatusMethodNotAllowed {
400
+ t.Fatalf("remote pair/start status = %d body = %s", rec.Code, rec.Body.String())
401
+ }
402
+
403
+ rec = httptest.NewRecorder()
404
+ handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/pair/claim", strings.NewReader(`{}`)))
405
+ if rec.Code != http.StatusOK {
406
+ t.Fatalf("pre-pair claim status = %d body = %s", rec.Code, rec.Body.String())
407
+ }
408
+
409
+ if fmt.Sprintf("%+v", forwarded) != "[POST /send-text POST /pair/claim]" {
410
+ t.Fatalf("forwarded = %+v", forwarded)
411
+ }
412
+ }
413
+
414
+ func TestPairlingConnectModeMatchesEndpointContract(t *testing.T) {
415
+ contract := loadEndpointContractForTesting(t)
416
+ var forwarded []string
417
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
418
+ forwarded = append(forwarded, r.Method+" "+r.URL.Path)
419
+ w.WriteHeader(http.StatusOK)
420
+ }))
421
+ defer upstream.Close()
422
+ handler := newTestHandlerWithMode(t, upstream.URL, 1024, nil, ExposureModePairlingConnect, nil)
423
+
424
+ for _, row := range contract.Rows {
425
+ if !row.AssertConnectdParity {
426
+ continue
427
+ }
428
+ req := httptest.NewRequest(row.Method, "http://pairling-connect.local"+row.SamplePath, strings.NewReader(`{}`))
429
+ if row.BearerRequired {
430
+ req.Header.Set("Authorization", "Bearer device-token")
431
+ }
432
+ rec := httptest.NewRecorder()
433
+ handler.ServeHTTP(rec, req)
434
+ got := rec.Code == http.StatusOK
435
+ if got != row.ConnectdPairlingConnect {
436
+ t.Fatalf("%s %s connectd allowed = %t, want %t (status=%d body=%s)", row.Method, row.SamplePath, got, row.ConnectdPairlingConnect, rec.Code, rec.Body.String())
437
+ }
438
+ }
439
+ if len(forwarded) == 0 {
440
+ t.Fatal("contract did not exercise any forwarded connectd rows")
441
+ }
442
+ }
443
+
444
+ func TestPrePairClaimIsRateLimitedAndBodyLimited(t *testing.T) {
445
+ var upstreamCalls int
446
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447
+ upstreamCalls++
448
+ w.WriteHeader(http.StatusOK)
449
+ }))
450
+ defer upstream.Close()
451
+ limiter := NewMemoryRateLimiter(1, time.Minute)
452
+ handler := newTestHandlerWithMode(t, upstream.URL, defaultMaxBodyBytes, nil, ExposureModePrePair, limiter)
453
+
454
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/pair/claim", strings.NewReader(`{}`))
455
+ req.RemoteAddr = "100.64.0.8:12345"
456
+ rec := httptest.NewRecorder()
457
+ handler.ServeHTTP(rec, req)
458
+ if rec.Code != http.StatusOK {
459
+ t.Fatalf("first claim status = %d body = %s", rec.Code, rec.Body.String())
460
+ }
461
+
462
+ req = httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/pair/claim", strings.NewReader(`{}`))
463
+ req.RemoteAddr = "100.64.0.8:12345"
464
+ rec = httptest.NewRecorder()
465
+ handler.ServeHTTP(rec, req)
466
+ if rec.Code != http.StatusTooManyRequests {
467
+ t.Fatalf("second claim status = %d body = %s", rec.Code, rec.Body.String())
468
+ }
469
+
470
+ largeBody := strings.NewReader(strings.Repeat("x", int(prePairMaxBodyBytes)+1))
471
+ req = httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/pair/claim", largeBody)
472
+ req.RemoteAddr = "100.64.0.9:12345"
473
+ req.ContentLength = prePairMaxBodyBytes + 1
474
+ rec = httptest.NewRecorder()
475
+ handler.ServeHTTP(rec, req)
476
+ if rec.Code != http.StatusRequestEntityTooLarge {
477
+ t.Fatalf("large claim status = %d body = %s", rec.Code, rec.Body.String())
478
+ }
479
+
480
+ if upstreamCalls != 1 {
481
+ t.Fatalf("upstream calls = %d, want 1", upstreamCalls)
482
+ }
483
+ }
484
+
485
+ type endpointContractForTesting struct {
486
+ Rows []endpointContractRowForTesting `json:"rows"`
487
+ }
488
+
489
+ type endpointContractRowForTesting struct {
490
+ Method string `json:"method"`
491
+ SamplePath string `json:"sample_path"`
492
+ ConnectdPairlingConnect bool `json:"connectd_pairling_connect"`
493
+ BearerRequired bool `json:"bearer_required"`
494
+ AssertConnectdParity bool `json:"assert_connectd_parity"`
495
+ }
496
+
497
+ func loadEndpointContractForTesting(t *testing.T) endpointContractForTesting {
498
+ t.Helper()
499
+ raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "thoughts", "shared", "contracts", "pairling-connect-endpoints.json"))
500
+ if err != nil {
501
+ t.Fatal(err)
502
+ }
503
+ var contract endpointContractForTesting
504
+ if err := json.Unmarshal(raw, &contract); err != nil {
505
+ t.Fatal(err)
506
+ }
507
+ return contract
508
+ }
509
+
510
+ func newTestHandler(t *testing.T, upstream string, maxBody int64, logger Logger) http.Handler {
511
+ return newTestHandlerWithMode(t, upstream, maxBody, logger, ExposureModePostPair, nil)
512
+ }
513
+
514
+ func newTestHandlerWithMode(t *testing.T, upstream string, maxBody int64, logger Logger, mode ExposureMode, limiter RateLimiter) http.Handler {
515
+ t.Helper()
516
+ upstreamURL, err := url.Parse(upstream)
517
+ if err != nil {
518
+ t.Fatal(err)
519
+ }
520
+ handler, err := NewHandler(Options{
521
+ Upstream: upstreamURL,
522
+ MaxBodyBytes: maxBody,
523
+ Mode: mode,
524
+ Logger: logger,
525
+ RateLimiter: limiter,
526
+ })
527
+ if err != nil {
528
+ t.Fatal(err)
529
+ }
530
+ return handler
531
+ }
@@ -0,0 +1,99 @@
1
+ package runtime
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "encoding/hex"
6
+ "encoding/json"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "unicode"
11
+ )
12
+
13
+ func DefaultAppSupportRoot(home string) string {
14
+ if v := os.Getenv("PAIRLING_APP_SUPPORT_ROOT"); v != "" {
15
+ return v
16
+ }
17
+ if v := os.Getenv("COMPANION_APP_SUPPORT_ROOT"); v != "" {
18
+ return v
19
+ }
20
+ if home == "" {
21
+ if detected, err := os.UserHomeDir(); err == nil {
22
+ home = detected
23
+ }
24
+ }
25
+ return filepath.Join(home, "Library", "Application Support", "Pairling")
26
+ }
27
+
28
+ func DefaultStateDir(home string) string {
29
+ return filepath.Join(DefaultAppSupportRoot(home), "connectd", "tsnet-state")
30
+ }
31
+
32
+ func LoadInstallID(appSupportRoot string) string {
33
+ for _, candidate := range []string{
34
+ filepath.Join(appSupportRoot, "config.json"),
35
+ filepath.Join(appSupportRoot, "state", "install-id"),
36
+ } {
37
+ value := loadInstallIDCandidate(candidate)
38
+ if value != "" {
39
+ return value
40
+ }
41
+ }
42
+ return ""
43
+ }
44
+
45
+ func HostnameFromInstallID(installID string) string {
46
+ slug := sanitizeHostnamePart(installID)
47
+ if slug == "" {
48
+ slug = randomSlug()
49
+ }
50
+ if len(slug) > 11 {
51
+ slug = strings.Trim(slug[:11], "-")
52
+ }
53
+ if slug == "" {
54
+ slug = "mac"
55
+ }
56
+ return "pairling-" + slug
57
+ }
58
+
59
+ func loadInstallIDCandidate(path string) string {
60
+ data, err := os.ReadFile(path)
61
+ if err != nil {
62
+ return ""
63
+ }
64
+ if strings.HasSuffix(path, ".json") {
65
+ var payload struct {
66
+ InstallID string `json:"install_id"`
67
+ }
68
+ if json.Unmarshal(data, &payload) == nil {
69
+ return strings.TrimSpace(payload.InstallID)
70
+ }
71
+ return ""
72
+ }
73
+ return strings.TrimSpace(string(data))
74
+ }
75
+
76
+ func sanitizeHostnamePart(value string) string {
77
+ var b strings.Builder
78
+ lastHyphen := false
79
+ for _, r := range strings.ToLower(value) {
80
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
81
+ b.WriteRune(r)
82
+ lastHyphen = false
83
+ continue
84
+ }
85
+ if !lastHyphen {
86
+ b.WriteByte('-')
87
+ lastHyphen = true
88
+ }
89
+ }
90
+ return strings.Trim(b.String(), "-")
91
+ }
92
+
93
+ func randomSlug() string {
94
+ var buf [4]byte
95
+ if _, err := rand.Read(buf[:]); err != nil {
96
+ return "mac"
97
+ }
98
+ return hex.EncodeToString(buf[:])
99
+ }
@@ -0,0 +1,29 @@
1
+ package runtime
2
+
3
+ import (
4
+ "path/filepath"
5
+ "testing"
6
+ )
7
+
8
+ func TestDefaultStateDirUsesPairlingApplicationSupport(t *testing.T) {
9
+ home := filepath.Join(string(filepath.Separator), "Users", "mergim")
10
+ got := DefaultStateDir(home)
11
+ want := filepath.Join(home, "Library", "Application Support", "Pairling", "connectd", "tsnet-state")
12
+ if got != want {
13
+ t.Fatalf("state dir = %q, want %q", got, want)
14
+ }
15
+ }
16
+
17
+ func TestHostnameFromInstallIDIsPairlingScopedAndStable(t *testing.T) {
18
+ got := HostnameFromInstallID("inst_abcDEF-1234567890")
19
+ if got != "pairling-inst-abcdef" {
20
+ t.Fatalf("hostname = %q", got)
21
+ }
22
+ }
23
+
24
+ func TestHostnameFromInstallIDFallsBackWhenInstallIDMissing(t *testing.T) {
25
+ got := HostnameFromInstallID("")
26
+ if got == "" || got == "pairling-" {
27
+ t.Fatalf("hostname fallback is empty: %q", got)
28
+ }
29
+ }