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.
- package/README.md +1 -1
- package/package.json +5 -5
- package/payload/mac/SOURCE_BRANCH +1 -1
- package/payload/mac/SOURCE_REVISION +1 -1
- package/payload/mac/VERSION +1 -1
- package/payload/mac/companiond/app_attest_lan.py +87 -0
- package/payload/mac/companiond/codex_approval.py +69 -0
- package/payload/mac/companiond/pairling_devices.py +35 -0
- package/payload/mac/companiond/pairling_pairing.py +374 -70
- package/payload/mac/companiond/pairling_psk.py +100 -0
- package/payload/mac/companiond/pairling_tools.py +2 -2
- package/payload/mac/companiond/pairlingd.py +977 -104
- package/payload/mac/companiond/pty_broker.py +441 -3
- package/payload/mac/companiond/pty_broker_client.py +167 -0
- package/payload/mac/companiond/pty_broker_service.py +84 -0
- package/payload/mac/companiond/runtime_contract.py +0 -2
- package/payload/mac/companiond/standard_push_publisher.py +7 -0
- package/payload/mac/connectd/cmd/pairling-connectd/authkey_test.go +47 -0
- package/payload/mac/connectd/cmd/pairling-connectd/main.go +41 -0
- package/payload/mac/connectd/internal/gateway/proxy.go +1 -0
- package/payload/mac/connectd/internal/gateway/proxy_test.go +1 -0
- package/payload/mac/connectd/internal/runtime/config_test.go +1 -1
- package/payload/mac/connectd/internal/status/status.go +9 -0
- package/payload/mac/install/doctor.sh +227 -51
- package/payload/mac/install/install-runtime.sh +398 -15
- package/payload/mac/install/psk_dependency_check.py +40 -0
- package/payload/mac/install/render-launchd.py +23 -0
- package/payload/mac/install/uninstall-runtime.sh +4 -12
- 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", "
|
|
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 {
|