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