pairling 0.1.0 → 0.2.1

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 (29) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/payload/mac/SOURCE_BRANCH +1 -1
  4. package/payload/mac/SOURCE_REVISION +1 -1
  5. package/payload/mac/VERSION +1 -1
  6. package/payload/mac/companiond/app_attest_lan.py +87 -0
  7. package/payload/mac/companiond/codex_approval.py +69 -0
  8. package/payload/mac/companiond/pairling_devices.py +35 -0
  9. package/payload/mac/companiond/pairling_pairing.py +374 -70
  10. package/payload/mac/companiond/pairling_psk.py +100 -0
  11. package/payload/mac/companiond/pairling_tools.py +2 -2
  12. package/payload/mac/companiond/pairlingd.py +977 -104
  13. package/payload/mac/companiond/pty_broker.py +441 -3
  14. package/payload/mac/companiond/pty_broker_client.py +167 -0
  15. package/payload/mac/companiond/pty_broker_service.py +84 -0
  16. package/payload/mac/companiond/runtime_contract.py +0 -2
  17. package/payload/mac/companiond/standard_push_publisher.py +7 -0
  18. package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
  19. package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
  20. package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
  21. package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
  22. package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
  23. package/payload/mac/connectd/internal/status/status.go +9 -0
  24. package/payload/mac/install/doctor.sh +227 -51
  25. package/payload/mac/install/install-runtime.sh +398 -15
  26. package/payload/mac/install/psk_dependency_check.py +40 -0
  27. package/payload/mac/install/render-launchd.py +23 -0
  28. package/payload/mac/install/uninstall-runtime.sh +4 -12
  29. package/payload-manifest.json +51 -23
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from pty_broker import PTYBrokerManager, ensure_pty_broker_token
11
+
12
+
13
+ def _app_support_root() -> Path:
14
+ raw = os.environ.get("PAIRLING_APP_SUPPORT_ROOT")
15
+ if raw:
16
+ return Path(raw).expanduser()
17
+ return Path.home() / "Library" / "Application Support" / "Pairling"
18
+
19
+
20
+ def _runtime_root() -> Path:
21
+ return Path(__file__).absolute().parent.parent
22
+
23
+
24
+ def _source_revision(runtime_root: Path) -> str | None:
25
+ for path in [runtime_root / "manifest.json", runtime_root / "mac" / "SOURCE_REVISION", runtime_root / "SOURCE_REVISION"]:
26
+ try:
27
+ if path.name == "manifest.json":
28
+ import json
29
+
30
+ payload = json.loads(path.read_text(encoding="utf-8"))
31
+ revision = payload.get("source_revision")
32
+ return str(revision) if revision else None
33
+ revision = path.read_text(encoding="utf-8").strip()
34
+ return revision or None
35
+ except FileNotFoundError:
36
+ continue
37
+ except Exception:
38
+ continue
39
+ return None
40
+
41
+
42
+ def main() -> int:
43
+ home = Path.home()
44
+ companion_dir = home / ".claude" / "companion"
45
+ terminal_capture_dir = companion_dir / "terminal-capture"
46
+ token = ensure_pty_broker_token(companion_dir)
47
+ socket_path = companion_dir / "pty-broker.sock"
48
+ runtime_root = _runtime_root()
49
+ manager = PTYBrokerManager(
50
+ socket_path=socket_path,
51
+ log_dir=terminal_capture_dir,
52
+ token=token,
53
+ runtime_root=runtime_root,
54
+ script_path=Path(__file__).absolute(),
55
+ source_revision=_source_revision(runtime_root),
56
+ )
57
+
58
+ stopping = False
59
+
60
+ def _handle_signal(signum, _frame) -> None:
61
+ nonlocal stopping
62
+ stopping = True
63
+ print(
64
+ f"pairling pty broker received {signal.Signals(signum).name}; live PTYs will hang up if the service exits",
65
+ file=sys.stderr,
66
+ flush=True,
67
+ )
68
+
69
+ signal.signal(signal.SIGTERM, _handle_signal)
70
+ signal.signal(signal.SIGINT, _handle_signal)
71
+
72
+ print(
73
+ f"pairling pty broker starting socket={socket_path} app_support={_app_support_root()}",
74
+ file=sys.stderr,
75
+ flush=True,
76
+ )
77
+ manager.start_attach_server()
78
+ while not stopping:
79
+ time.sleep(1.0)
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
@@ -15,8 +15,6 @@ PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
15
15
  LEGACY_PORT = 7723
16
16
  DAEMON_LABEL = "dev.pairling.companiond"
17
17
  GUARDIAN_LABEL = "dev.pairling.power-guardian"
18
- LEGACY_DAEMON_LABEL = "com.mghome.notify-webhook"
19
- LEGACY_GUARDIAN_LABEL = "com.mghome.companion-power-guardian"
20
18
  LEGACY_TOKEN_RELATIVE_PATH = ".claude/scripts/.notify-token"
21
19
  POWER_STATE_PATH = "/var/run/pairling-power-state.json"
22
20
  TAILSCALE_VARIANT = "standalone"
@@ -143,6 +143,8 @@ class TurnStateAlertPublisher:
143
143
  "last_update": last_update,
144
144
  "provider": provider,
145
145
  "tool": _bounded_optional(payload.get("tool"), 80),
146
+ "request_nonce": _bounded_optional(payload.get("request_nonce"), 160),
147
+ "mac_install_id": _bounded_optional(payload.get("mac_install_id"), 160),
146
148
  }
147
149
  return {
148
150
  "state_key": path.stem,
@@ -211,6 +213,11 @@ class TurnStateAlertPublisher:
211
213
  "session_id": native_id,
212
214
  "provider": provider,
213
215
  })
216
+ if kind == "action_required":
217
+ if visible.get("request_nonce"):
218
+ payload["request_nonce"] = visible.get("request_nonce")
219
+ if visible.get("mac_install_id"):
220
+ payload["mac_install_id"] = visible.get("mac_install_id")
214
221
  return payload
215
222
 
216
223
  def _route_session_id_for_event(self, session_id: str) -> str:
@@ -0,0 +1,47 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func TestDefaultAuthKeyTag(t *testing.T) {
10
+ t.Setenv("PAIRLING_TS_AUTHKEY_TAG", "")
11
+ if got := defaultAuthKeyTag(); got != "tag:pairling-connect" {
12
+ t.Fatalf("default tag = %q, want tag:pairling-connect", got)
13
+ }
14
+ t.Setenv("PAIRLING_TS_AUTHKEY_TAG", "tag:custom")
15
+ if got := defaultAuthKeyTag(); got != "tag:custom" {
16
+ t.Fatalf("override tag = %q, want tag:custom", got)
17
+ }
18
+ }
19
+
20
+ func TestLoadTailscaleAuthKeyPrefersEnv(t *testing.T) {
21
+ dir := t.TempDir()
22
+ connectdDir := filepath.Join(dir, "connectd")
23
+ if err := os.MkdirAll(connectdDir, 0o700); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if err := os.WriteFile(filepath.Join(connectdDir, "connectd-ts-authkey"), []byte("tskey-auth-from-file\n"), 0o600); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+
30
+ t.Setenv("PAIRLING_TS_AUTHKEY", " tskey-auth-from-env ")
31
+ if got := loadTailscaleAuthKey(dir); got != "tskey-auth-from-env" {
32
+ t.Fatalf("env precedence failed: got %q", got)
33
+ }
34
+
35
+ t.Setenv("PAIRLING_TS_AUTHKEY", "")
36
+ if got := loadTailscaleAuthKey(dir); got != "tskey-auth-from-file" {
37
+ t.Fatalf("file fallback failed: got %q", got)
38
+ }
39
+ }
40
+
41
+ func TestLoadTailscaleAuthKeyAbsentIsInteractive(t *testing.T) {
42
+ dir := t.TempDir()
43
+ t.Setenv("PAIRLING_TS_AUTHKEY", "")
44
+ if got := loadTailscaleAuthKey(dir); got != "" {
45
+ t.Fatalf("expected empty (interactive) when no key present, got %q", got)
46
+ }
47
+ }
@@ -47,6 +47,11 @@ func run(args []string) int {
47
47
  controlURL := fs.String("control-url", "", "advanced: custom Tailscale-compatible control server URL")
48
48
  maxBodyBytes := fs.Int64("max-body-bytes", 1_000_000, "maximum proxied request body size")
49
49
  verbose := fs.Bool("verbose", false, "enable verbose tsnet backend logs")
50
+ // WS1: a tagged auth key (minted from an OAuth client scoped to
51
+ // tag:pairling-connect) registers this node pre-authorized AND with key
52
+ // expiry disabled — no 180-day re-auth cliff, no per-node REST call.
53
+ // Empty keeps the legacy interactive browser-auth path (back-compat).
54
+ authKeyTag := fs.String("auth-key-tag", defaultAuthKeyTag(), "tag applied to this node when registering with an auth key")
50
55
 
51
56
  if err := fs.Parse(args); err != nil {
52
57
  if errors.Is(err, flag.ErrHelp) {
@@ -86,6 +91,18 @@ func run(args []string) int {
86
91
  ControlURL: strings.TrimSpace(*controlURL),
87
92
  UserLogf: userLogf(statusStore),
88
93
  }
94
+ // WS1: tagged auth-key registration. A tagged node never expires its key,
95
+ // eliminating the 180-day re-auth cliff without any Tailscale REST call.
96
+ if authKey := loadTailscaleAuthKey(appSupport); authKey != "" {
97
+ srv.AuthKey = authKey
98
+ if tag := strings.TrimSpace(*authKeyTag); tag != "" {
99
+ srv.AdvertiseTags = []string{tag}
100
+ }
101
+ statusStore.SetAuthKeyMode("tagged")
102
+ log.Printf("pairling-connectd registering with tagged auth key (tag=%s)", strings.TrimSpace(*authKeyTag))
103
+ } else {
104
+ statusStore.SetAuthKeyMode("interactive")
105
+ }
89
106
  if *verbose {
90
107
  srv.Logf = func(format string, args ...any) {
91
108
  log.Printf("tsnet: "+format, args...)
@@ -146,6 +163,30 @@ func controlURLMode(raw string) string {
146
163
  return status.CustomControlURLMode
147
164
  }
148
165
 
166
+ // defaultAuthKeyTag is the ACL tag applied to Pairling Connect nodes that
167
+ // register with a tagged auth key. Tagged nodes do not expire their keys.
168
+ func defaultAuthKeyTag() string {
169
+ if tag := strings.TrimSpace(os.Getenv("PAIRLING_TS_AUTHKEY_TAG")); tag != "" {
170
+ return tag
171
+ }
172
+ return "tag:pairling-connect"
173
+ }
174
+
175
+ // loadTailscaleAuthKey resolves a tagged auth key, preferring the environment
176
+ // (PAIRLING_TS_AUTHKEY) over a mode-600 credential file under Application
177
+ // Support. Returns "" when neither is present (legacy interactive auth).
178
+ func loadTailscaleAuthKey(appSupport string) string {
179
+ if key := strings.TrimSpace(os.Getenv("PAIRLING_TS_AUTHKEY")); key != "" {
180
+ return key
181
+ }
182
+ path := filepath.Join(appSupport, "connectd", "connectd-ts-authkey")
183
+ data, err := os.ReadFile(path)
184
+ if err != nil {
185
+ return ""
186
+ }
187
+ return strings.TrimSpace(string(data))
188
+ }
189
+
149
190
  func listenPort(addr string) int {
150
191
  addr = strings.TrimSpace(addr)
151
192
  if addr == "" {
@@ -503,6 +503,7 @@ var postPaths = map[string]bool{
503
503
  "/phone-tools/result": true,
504
504
  "/push/live-activity-test": true,
505
505
  "/push/live-activity-token": true,
506
+ "/push/permission/allow": true,
506
507
  "/push/preferences": true,
507
508
  "/push/test": true,
508
509
  "/resume-session": true,
@@ -349,6 +349,7 @@ func TestPrePairModeOnlyAllowsHealthManifestAndClaim(t *testing.T) {
349
349
  {http.MethodPost, "/terminal-control", http.StatusNotFound},
350
350
  {http.MethodPost, "/worker-kill", http.StatusNotFound},
351
351
  {http.MethodPost, "/push/preferences", http.StatusNotFound},
352
+ {http.MethodPost, "/push/permission/allow", http.StatusNotFound},
352
353
  {http.MethodPost, "/safety/ack", http.StatusNotFound},
353
354
  {http.MethodPost, "/aperture-cli/open", http.StatusNotFound},
354
355
  {http.MethodGet, "/health", http.StatusOK},
@@ -6,7 +6,7 @@ import (
6
6
  )
7
7
 
8
8
  func TestDefaultStateDirUsesPairlingApplicationSupport(t *testing.T) {
9
- home := filepath.Join(string(filepath.Separator), "Users", "mergim")
9
+ home := filepath.Join(string(filepath.Separator), "Users", "example")
10
10
  got := DefaultStateDir(home)
11
11
  want := filepath.Join(home, "Library", "Application Support", "Pairling", "connectd", "tsnet-state")
12
12
  if got != want {
@@ -43,6 +43,7 @@ type Snapshot struct {
43
43
  TailnetIPCount int `json:"tailnet_ip_count"`
44
44
  AuthURLPresent bool `json:"auth_url_present"`
45
45
  ControlURLMode string `json:"control_url_mode"`
46
+ AuthKeyMode string `json:"auth_key_mode,omitempty"`
46
47
  UpstreamReachable bool `json:"upstream_reachable"`
47
48
  ListenerRunning bool `json:"listener_running"`
48
49
  GatewayHealthy bool `json:"gateway_healthy"`
@@ -88,6 +89,14 @@ func (s *Store) SetControlURLMode(mode string) {
88
89
  })
89
90
  }
90
91
 
92
+ // SetAuthKeyMode records how this node authenticated: "tagged" (a tagged
93
+ // auth key, key expiry disabled) or "interactive" (the legacy browser flow).
94
+ func (s *Store) SetAuthKeyMode(mode string) {
95
+ s.update(func(snapshot *Snapshot) {
96
+ snapshot.AuthKeyMode = mode
97
+ })
98
+ }
99
+
91
100
  func (s *Store) SetListenPort(port int) {
92
101
  s.update(func(snapshot *Snapshot) {
93
102
  if port <= 0 {