pairling 0.2.0 → 0.2.2
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 +160 -18
- package/payload/mac/install/install-runtime.sh +329 -12
- 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 {
|
|
@@ -62,7 +62,8 @@ PAIRLING_PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
|
|
|
62
62
|
PAIRLING_LABEL = "dev.pairling.companiond"
|
|
63
63
|
PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
|
|
64
64
|
PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
|
|
65
|
-
|
|
65
|
+
PAIRLING_PTYBROKER_LABEL = "dev.pairling.ptybroker"
|
|
66
|
+
TEAM_ID = os.environ.get("PAIRLING_TEAM_ID", os.environ.get("PAIRLING_CONNECTD_TEAM_ID", "965AVD34A3"))
|
|
66
67
|
APP_SUPPORT = Path(os.environ.get("PAIRLING_APP_SUPPORT_ROOT", os.environ.get("COMPANION_APP_SUPPORT_ROOT", str(home / "Library" / "Application Support" / "Pairling"))))
|
|
67
68
|
LOGS_ROOT = Path(os.environ.get("PAIRLING_LOGS_ROOT", os.environ.get("COMPANION_LOGS_ROOT", str(home / "Library" / "Logs" / "Pairling"))))
|
|
68
69
|
CURRENT = APP_SUPPORT / "runtime" / "current"
|
|
@@ -75,9 +76,8 @@ USER_PAIRLING = home / ".local" / "bin" / "pairling"
|
|
|
75
76
|
PAIR_ROOT = APP_SUPPORT / "pair"
|
|
76
77
|
USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_LABEL}.plist"
|
|
77
78
|
CONNECTD_USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_CONNECTD_LABEL}.plist"
|
|
78
|
-
|
|
79
|
+
PTYBROKER_USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_PTYBROKER_LABEL}.plist"
|
|
79
80
|
SYSTEM_PLIST = Path("/Library/LaunchDaemons") / f"{PAIRLING_GUARDIAN_LABEL}.plist"
|
|
80
|
-
LEGACY_SYSTEM_PLIST = Path("/Library/LaunchDaemons/com.mghome.companion-power-guardian.plist")
|
|
81
81
|
|
|
82
82
|
sys.path.insert(0, str(repo_root / "mac" / "companiond"))
|
|
83
83
|
from pairling_connectd_status import fetch_connectd_status, redacted_connectd_summary
|
|
@@ -95,6 +95,23 @@ def add(identifier, ok, severity, summary, evidence=None):
|
|
|
95
95
|
})
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def codesigning_identity_summary(output: str) -> dict:
|
|
99
|
+
lines = [line.strip() for line in output.splitlines() if line.strip()]
|
|
100
|
+
developer_id_lines = [line for line in lines if "Developer ID Application:" in line]
|
|
101
|
+
expected_team_present = any(f"({TEAM_ID})" in line for line in developer_id_lines)
|
|
102
|
+
valid_count = None
|
|
103
|
+
for line in lines:
|
|
104
|
+
match = re.search(r"(\d+)\s+valid identities found", line)
|
|
105
|
+
if match:
|
|
106
|
+
valid_count = int(match.group(1))
|
|
107
|
+
break
|
|
108
|
+
return {
|
|
109
|
+
"valid_identity_count": valid_count,
|
|
110
|
+
"developer_id_application_count": len(developer_id_lines),
|
|
111
|
+
"expected_team_present": expected_team_present,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
98
115
|
def run(args, timeout=5):
|
|
99
116
|
try:
|
|
100
117
|
proc = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
|
@@ -202,6 +219,103 @@ def active_pair_records(pair_root: Path) -> list[dict]:
|
|
|
202
219
|
return records
|
|
203
220
|
|
|
204
221
|
|
|
222
|
+
def desired_ptybroker_identity() -> dict:
|
|
223
|
+
revision = None
|
|
224
|
+
if manifest and manifest.get("source_revision"):
|
|
225
|
+
revision = str(manifest.get("source_revision"))
|
|
226
|
+
elif (CURRENT / "mac" / "SOURCE_REVISION").is_file():
|
|
227
|
+
try:
|
|
228
|
+
revision = (CURRENT / "mac" / "SOURCE_REVISION").read_text().strip() or None
|
|
229
|
+
except Exception:
|
|
230
|
+
revision = None
|
|
231
|
+
desired_root = CURRENT.resolve() if CURRENT.exists() else CURRENT
|
|
232
|
+
return {
|
|
233
|
+
"runtime_root": str(desired_root),
|
|
234
|
+
"script_path": str(desired_root / "companiond" / "pty_broker_service.py"),
|
|
235
|
+
"source_revision": revision,
|
|
236
|
+
"protocol_version": 1,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def ptybroker_status_rpc() -> tuple[dict | None, str | None]:
|
|
241
|
+
try:
|
|
242
|
+
sys.path.insert(0, str(CURRENT / "companiond"))
|
|
243
|
+
from pty_broker_client import PTYBrokerClient, ensure_pty_broker_token
|
|
244
|
+
|
|
245
|
+
companion = home / ".claude" / "companion"
|
|
246
|
+
client = PTYBrokerClient(companion / "pty-broker.sock", ensure_pty_broker_token(companion), timeout=1.0)
|
|
247
|
+
status = client.status()
|
|
248
|
+
return status if isinstance(status, dict) else {}, None
|
|
249
|
+
except Exception as exc:
|
|
250
|
+
return None, f"{type(exc).__name__}: {exc}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def ptybroker_deployment_status(*, launchd_loaded: bool) -> dict:
|
|
254
|
+
desired = desired_ptybroker_identity()
|
|
255
|
+
base = {
|
|
256
|
+
"label": PAIRLING_PTYBROKER_LABEL,
|
|
257
|
+
"state": "unknown",
|
|
258
|
+
"restart_deferred": False,
|
|
259
|
+
"pid": None,
|
|
260
|
+
"live_session_count": None,
|
|
261
|
+
"live_source_revision": None,
|
|
262
|
+
"desired_source_revision": desired.get("source_revision"),
|
|
263
|
+
"desired_runtime_root": desired.get("runtime_root"),
|
|
264
|
+
"desired_script_path": desired.get("script_path"),
|
|
265
|
+
"evidence": None,
|
|
266
|
+
}
|
|
267
|
+
if not MANIFEST_PATH.is_file() and not CURRENT.exists():
|
|
268
|
+
return {**base, "state": "not_installed", "evidence": "runtime/current is missing"}
|
|
269
|
+
if not launchd_loaded:
|
|
270
|
+
return {**base, "state": "not_running", "evidence": "launchd label is not running"}
|
|
271
|
+
live, error = ptybroker_status_rpc()
|
|
272
|
+
if live is None:
|
|
273
|
+
return {**base, "state": "unreachable_socket", "evidence": error}
|
|
274
|
+
reasons = []
|
|
275
|
+
live_root = live.get("runtime_root")
|
|
276
|
+
if live_root:
|
|
277
|
+
if os.path.realpath(str(live_root)) != str(desired.get("runtime_root")):
|
|
278
|
+
reasons.append("runtime_root_mismatch")
|
|
279
|
+
else:
|
|
280
|
+
reasons.append("runtime_root_missing")
|
|
281
|
+
live_script = live.get("script_path")
|
|
282
|
+
if live_script:
|
|
283
|
+
if os.path.realpath(str(live_script)) != str(desired.get("script_path")):
|
|
284
|
+
reasons.append("script_path_mismatch")
|
|
285
|
+
else:
|
|
286
|
+
reasons.append("script_path_missing")
|
|
287
|
+
live_revision = live.get("source_revision")
|
|
288
|
+
if desired.get("source_revision") and not live_revision:
|
|
289
|
+
reasons.append("source_revision_missing")
|
|
290
|
+
elif live_revision and desired.get("source_revision") and str(live_revision) != str(desired.get("source_revision")):
|
|
291
|
+
reasons.append("source_revision_mismatch")
|
|
292
|
+
try:
|
|
293
|
+
live_protocol = int(live.get("protocol_version") or 0)
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
live_protocol = 0
|
|
296
|
+
if live_protocol != int(desired.get("protocol_version") or 0):
|
|
297
|
+
if not live.get("protocol_version"):
|
|
298
|
+
reasons.append("protocol_version_missing")
|
|
299
|
+
else:
|
|
300
|
+
reasons.append("protocol_version_mismatch")
|
|
301
|
+
state = "current" if not reasons else "stale_deferred"
|
|
302
|
+
return {
|
|
303
|
+
**base,
|
|
304
|
+
"state": state,
|
|
305
|
+
"restart_deferred": state == "stale_deferred",
|
|
306
|
+
"pid": live.get("pid"),
|
|
307
|
+
"live_session_count": live.get("live_session_count"),
|
|
308
|
+
"live_source_revision": live_revision,
|
|
309
|
+
"live_runtime_root": live_root,
|
|
310
|
+
"live_script_path": live_script,
|
|
311
|
+
"protocol_version": live.get("protocol_version"),
|
|
312
|
+
"code_version": live.get("code_version"),
|
|
313
|
+
"started_at": live.get("started_at"),
|
|
314
|
+
"reasons": reasons,
|
|
315
|
+
"evidence": live,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
205
319
|
def first_run_stage(*, installed: bool, running: bool, pair_window_open: bool, remote_ready: bool) -> str:
|
|
206
320
|
if not installed:
|
|
207
321
|
return "helper_missing"
|
|
@@ -269,7 +383,13 @@ if manifest:
|
|
|
269
383
|
runtime = manifest.get("runtime") if isinstance(manifest.get("runtime"), dict) else {}
|
|
270
384
|
add("runtime_port", runtime.get("port") == PAIRLING_PORT, "error", "Runtime port is locked to 7773.", runtime.get("port"))
|
|
271
385
|
launchd = manifest.get("launchd") if isinstance(manifest.get("launchd"), dict) else {}
|
|
272
|
-
|
|
386
|
+
launchd_evidence = {
|
|
387
|
+
"daemon_label": launchd.get("daemon_label"),
|
|
388
|
+
"ptybroker_label": launchd.get("ptybroker_label"),
|
|
389
|
+
"connectd_label": launchd.get("connectd_label"),
|
|
390
|
+
"guardian_label": launchd.get("guardian_label"),
|
|
391
|
+
}
|
|
392
|
+
add("launchd_labels", launchd.get("daemon_label") == PAIRLING_LABEL and launchd.get("ptybroker_label") == PAIRLING_PTYBROKER_LABEL and launchd.get("connectd_label") == PAIRLING_CONNECTD_LABEL and launchd.get("guardian_label") == PAIRLING_GUARDIAN_LABEL, "error", "Manifest launchd labels are Pairling labels.", launchd_evidence)
|
|
273
393
|
mismatches = []
|
|
274
394
|
for item in manifest.get("files") or []:
|
|
275
395
|
rel = item.get("path")
|
|
@@ -365,7 +485,7 @@ try:
|
|
|
365
485
|
add(
|
|
366
486
|
"shell_pairling_wrapper",
|
|
367
487
|
"runtime/current/bin/pairling" in pairling_text
|
|
368
|
-
and "/Users/
|
|
488
|
+
and re.search(r"/Users/[^\\s'\"]+/projects/Pairling", pairling_text) is None,
|
|
369
489
|
"error",
|
|
370
490
|
"User pairling command resolves through runtime/current unless PAIRLING_REPO_ROOT is explicitly set.",
|
|
371
491
|
str(USER_PAIRLING),
|
|
@@ -411,6 +531,19 @@ except Exception as exc:
|
|
|
411
531
|
add("connectd_launchagent_plist", False, "error", f"Pairling Connect LaunchAgent plist unreadable: {type(exc).__name__}: {exc}", str(CONNECTD_USER_PLIST))
|
|
412
532
|
add("connectd_launchagent_env", False, "error", "Cannot validate Pairling Connect LaunchAgent environment.", str(CONNECTD_USER_PLIST))
|
|
413
533
|
|
|
534
|
+
try:
|
|
535
|
+
payload = load_plist(PTYBROKER_USER_PLIST)
|
|
536
|
+
args = payload.get("ProgramArguments") or []
|
|
537
|
+
add(
|
|
538
|
+
"ptybroker_launchagent_plist",
|
|
539
|
+
payload.get("Label") == PAIRLING_PTYBROKER_LABEL and any(str(CURRENT / "companiond" / "pty_broker_service.py") == value for value in args),
|
|
540
|
+
"error",
|
|
541
|
+
"Pairling PTY broker LaunchAgent points at runtime/current.",
|
|
542
|
+
{"label": payload.get("Label"), "args": args},
|
|
543
|
+
)
|
|
544
|
+
except Exception as exc:
|
|
545
|
+
add("ptybroker_launchagent_plist", False, "error", f"Pairling PTY broker LaunchAgent plist unreadable: {type(exc).__name__}: {exc}", str(PTYBROKER_USER_PLIST))
|
|
546
|
+
|
|
414
547
|
try:
|
|
415
548
|
payload = load_plist(SYSTEM_PLIST)
|
|
416
549
|
add("guardian_plist", payload.get("Label") == PAIRLING_GUARDIAN_LABEL, "warning", "Pairling guardian LaunchDaemon is rendered/installed.", {"label": payload.get("Label")})
|
|
@@ -425,16 +558,23 @@ code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{PAIRLING_CONNEC
|
|
|
425
558
|
add("connectd_launchagent_loaded", code == 0 and "state = running" in out, "error", "Pairling Connect LaunchAgent is running." if code == 0 else "Pairling Connect LaunchAgent is not loaded.", (out or err)[:2000])
|
|
426
559
|
add("connectd_loaded_from_current", str(CURRENT / "connectd" / "pairling-connectd") in out, "error", "Loaded Pairling Connect LaunchAgent uses runtime/current.", out[:2000])
|
|
427
560
|
|
|
428
|
-
code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{
|
|
429
|
-
|
|
430
|
-
add("
|
|
431
|
-
add("
|
|
432
|
-
|
|
561
|
+
code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{PAIRLING_PTYBROKER_LABEL}"])
|
|
562
|
+
ptybroker_launchd_loaded = code == 0 and "state = running" in out
|
|
563
|
+
add("ptybroker_launchagent_loaded", ptybroker_launchd_loaded, "error", "Pairling PTY broker LaunchAgent is running." if code == 0 else "Pairling PTY broker LaunchAgent is not loaded.", (out or err)[:2000])
|
|
564
|
+
add("ptybroker_loaded_from_current", str(CURRENT / "companiond" / "pty_broker_service.py") in out, "error", "Loaded Pairling PTY broker uses runtime/current.", out[:2000])
|
|
565
|
+
ptybroker_deployment = ptybroker_deployment_status(launchd_loaded=ptybroker_launchd_loaded)
|
|
566
|
+
add(
|
|
567
|
+
"ptybroker_deployment_state",
|
|
568
|
+
ptybroker_deployment["state"] == "current",
|
|
569
|
+
"warning",
|
|
570
|
+
f"Pairling PTY broker deployment state is {ptybroker_deployment['state']}.",
|
|
571
|
+
ptybroker_deployment,
|
|
572
|
+
)
|
|
433
573
|
|
|
434
574
|
listeners_7773 = port_listeners(PAIRLING_PORT)
|
|
435
575
|
listeners_7723 = port_listeners(7723)
|
|
436
576
|
add("port_7773_listener", bool(listeners_7773) or tcp_accepts("127.0.0.1", PAIRLING_PORT), "error", "Runtime is listening on 7773.", listeners_7773)
|
|
437
|
-
legacy_conflict = any("
|
|
577
|
+
legacy_conflict = any("Python" in line or "python" in line for line in listeners_7723)
|
|
438
578
|
add("legacy_port_7723_clear", not legacy_conflict, "error", "Legacy 7723 daemon is not conflicting.", listeners_7723)
|
|
439
579
|
|
|
440
580
|
health = None
|
|
@@ -475,17 +615,20 @@ for name in ["claude", "codex"]:
|
|
|
475
615
|
add("provider_clis_detected", True, "warning", "Provider CLI detection completed.", provider_evidence)
|
|
476
616
|
|
|
477
617
|
release_blockers = []
|
|
478
|
-
developer_id_identity = os.environ.get("PAIRLING_DEVELOPER_ID_IDENTITY"
|
|
618
|
+
developer_id_identity = os.environ.get("PAIRLING_DEVELOPER_ID_IDENTITY")
|
|
479
619
|
code, out, err = run(["/usr/bin/security", "find-identity", "-v", "-p", "codesigning"], timeout=5)
|
|
480
|
-
|
|
620
|
+
identity_evidence = codesigning_identity_summary(out)
|
|
621
|
+
has_developer_id = code == 0 and (
|
|
622
|
+
(developer_id_identity in out) if developer_id_identity else identity_evidence["expected_team_present"]
|
|
623
|
+
)
|
|
481
624
|
if not has_developer_id:
|
|
482
|
-
release_blockers.append(
|
|
625
|
+
release_blockers.append("Developer ID Application identity is missing from the login keychain for the expected team.")
|
|
483
626
|
add(
|
|
484
627
|
"developer_id_identity",
|
|
485
628
|
has_developer_id,
|
|
486
629
|
"warning",
|
|
487
630
|
"Developer ID Application identity is available for public helper signing.",
|
|
488
|
-
(
|
|
631
|
+
identity_evidence if code == 0 else {"error": (err or "security find-identity failed")[:200]},
|
|
489
632
|
)
|
|
490
633
|
|
|
491
634
|
notary_profile = os.environ.get("PAIRLING_NOTARY_PROFILE", "pairling-notary")
|
|
@@ -498,7 +641,7 @@ add(
|
|
|
498
641
|
has_notary_profile,
|
|
499
642
|
"warning",
|
|
500
643
|
"Notary credentials are stored and can authenticate.",
|
|
501
|
-
(
|
|
644
|
+
{"profile": notary_profile, "authenticated": has_notary_profile, "error": None if has_notary_profile else (err or out)[:200]},
|
|
502
645
|
)
|
|
503
646
|
|
|
504
647
|
# npm distribution: the staged pairling-connectd binary must be a valid
|
|
@@ -654,6 +797,7 @@ result = {
|
|
|
654
797
|
"launchd_label": PAIRLING_LABEL,
|
|
655
798
|
"guardian_label": PAIRLING_GUARDIAN_LABEL,
|
|
656
799
|
},
|
|
800
|
+
"ptybroker": ptybroker_deployment,
|
|
657
801
|
"paths": {
|
|
658
802
|
"app_support": str(APP_SUPPORT),
|
|
659
803
|
"logs": str(LOGS_ROOT),
|
|
@@ -662,9 +806,7 @@ result = {
|
|
|
662
806
|
"pair_records": str(PAIR_ROOT),
|
|
663
807
|
},
|
|
664
808
|
"legacy": {
|
|
665
|
-
"daemon_label": LEGACY_LABEL,
|
|
666
809
|
"port": 7723,
|
|
667
|
-
"loaded": legacy_loaded,
|
|
668
810
|
"listeners": listeners_7723,
|
|
669
811
|
},
|
|
670
812
|
"release_blockers": release_blockers,
|