pairling 0.2.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 +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
|
@@ -43,10 +43,11 @@ Runtime endpoints use per-device scoped Authorization: Bearer tokens:
|
|
|
43
43
|
POST /pairling-tools/run body:JSON daemon-first MCP tool router
|
|
44
44
|
POST /phone-tools/availability body:JSON foreground iPhone tool-listener availability
|
|
45
45
|
|
|
46
|
-
Listens on PAIRLING_WEBHOOK_HOST
|
|
46
|
+
Listens on PAIRLING_WEBHOOK_HOST, loopback by default unless explicitly configured.
|
|
47
47
|
"""
|
|
48
48
|
from __future__ import annotations
|
|
49
49
|
|
|
50
|
+
import base64
|
|
50
51
|
import hashlib
|
|
51
52
|
import html
|
|
52
53
|
import json
|
|
@@ -82,7 +83,7 @@ try:
|
|
|
82
83
|
fetch_connectd_status,
|
|
83
84
|
redacted_connectd_summary,
|
|
84
85
|
)
|
|
85
|
-
from pairling_pairing import DEFAULT_PAIR_TTL_SECONDS, PairingAdvertiser, PairingError, PairingStore
|
|
86
|
+
from pairling_pairing import DEFAULT_PAIR_TTL_SECONDS, PairingAdvertiser, PairingError, PairingStore, ReauthStore
|
|
86
87
|
from pairdrop_store import PairDropStore, PairDropStoreError
|
|
87
88
|
except Exception:
|
|
88
89
|
RUNTIME_AUTH_MODE = "scoped-device-bearer"
|
|
@@ -101,6 +102,7 @@ except Exception:
|
|
|
101
102
|
PairingAdvertiser = None
|
|
102
103
|
PairingError = None
|
|
103
104
|
PairingStore = None
|
|
105
|
+
ReauthStore = None
|
|
104
106
|
PairDropStore = None
|
|
105
107
|
PairDropStoreError = ValueError
|
|
106
108
|
app_support_root = None
|
|
@@ -186,9 +188,15 @@ except Exception:
|
|
|
186
188
|
SafetyMonitorBridge = None
|
|
187
189
|
|
|
188
190
|
try:
|
|
189
|
-
from
|
|
191
|
+
from pty_broker_client import PTYBrokerClient, ensure_pty_broker_token
|
|
190
192
|
except Exception:
|
|
191
|
-
|
|
193
|
+
PTYBrokerClient = None
|
|
194
|
+
ensure_pty_broker_token = None
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
from codex_approval import classify_codex_approval
|
|
198
|
+
except Exception:
|
|
199
|
+
classify_codex_approval = None
|
|
192
200
|
|
|
193
201
|
try:
|
|
194
202
|
from terminal_text_sanitizer import (
|
|
@@ -297,7 +305,8 @@ AGENT_REGISTRY_DB = COMPANION_DIR / "agent-sessions.sqlite"
|
|
|
297
305
|
TERMINAL_CAPTURE_DIR = COMPANION_DIR / "terminal-capture"
|
|
298
306
|
TERMINAL_CAPTURE_MAP_DIR = TERMINAL_CAPTURE_DIR / "by-tty"
|
|
299
307
|
PTY_BROKER_SOCKET = COMPANION_DIR / "pty-broker.sock"
|
|
300
|
-
|
|
308
|
+
PTY_BROKER_TOKEN = ensure_pty_broker_token(COMPANION_DIR) if ensure_pty_broker_token else ""
|
|
309
|
+
PTY_BROKER = PTYBrokerClient(PTY_BROKER_SOCKET, PTY_BROKER_TOKEN) if PTYBrokerClient and PTY_BROKER_TOKEN else None
|
|
301
310
|
APP_SUPPORT_ROOT = app_support_root() if app_support_root else Path(os.environ.get(
|
|
302
311
|
"PAIRLING_APP_SUPPORT_ROOT",
|
|
303
312
|
str(HOME / "Library" / "Application Support" / "Pairling"),
|
|
@@ -314,6 +323,7 @@ PAIRING_STORE = (
|
|
|
314
323
|
else None
|
|
315
324
|
)
|
|
316
325
|
PAIRING_ADVERTISER = PairingAdvertiser() if PairingAdvertiser else None
|
|
326
|
+
REAUTH_STORE = ReauthStore(DEVICE_REGISTRY) if ReauthStore and DEVICE_REGISTRY else None
|
|
317
327
|
RELAY_CLAIM_VERIFIER = (
|
|
318
328
|
RelayClaimVerifier.from_environment(mac_install_id=PAIRING_STORE.install_id)
|
|
319
329
|
if RelayClaimVerifier and PAIRING_STORE
|
|
@@ -335,6 +345,37 @@ STANDARD_TURN_PUSH_PUBLISHER = None
|
|
|
335
345
|
MAC_HEALTH_PUSH_PUBLISHER = None
|
|
336
346
|
SENTINEL_PUSH_PUBLISHER = None
|
|
337
347
|
|
|
348
|
+
|
|
349
|
+
def _broker_value(session, key: str, default=None):
|
|
350
|
+
if isinstance(session, dict):
|
|
351
|
+
value = session.get(key, default)
|
|
352
|
+
return default if value is None else value
|
|
353
|
+
return getattr(session, key, default)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _broker_session_id(session) -> str:
|
|
357
|
+
return str(_broker_value(session, "session_id", "") or "")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _broker_slave_tty(session) -> str:
|
|
361
|
+
return str(_broker_value(session, "slave_tty", "") or "")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _broker_pid(session) -> int:
|
|
365
|
+
try:
|
|
366
|
+
return int(_broker_value(session, "pid", 0) or 0)
|
|
367
|
+
except Exception:
|
|
368
|
+
return 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _broker_raw_log_path(session) -> Path | None:
|
|
372
|
+
raw = _broker_value(session, "raw_log_path", None)
|
|
373
|
+
if isinstance(raw, Path):
|
|
374
|
+
return raw
|
|
375
|
+
if raw:
|
|
376
|
+
return Path(str(raw))
|
|
377
|
+
return None
|
|
378
|
+
|
|
338
379
|
QUEUE_DIR.mkdir(parents=True, exist_ok=True)
|
|
339
380
|
ORCHESTRATIONS_DIR.mkdir(parents=True, exist_ok=True)
|
|
340
381
|
HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -347,9 +388,7 @@ TERMINAL_CAPTURE_MAP_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
347
388
|
def _bind_host() -> str:
|
|
348
389
|
if os.environ.get("PAIRLING_WEBHOOK_HOST"):
|
|
349
390
|
return os.environ["PAIRLING_WEBHOOK_HOST"]
|
|
350
|
-
|
|
351
|
-
return os.environ["NOTIFY_WEBHOOK_HOST"]
|
|
352
|
-
mode = os.environ.get("PAIRLING_BIND_MODE", os.environ.get("NOTIFY_WEBHOOK_BIND_MODE", "loopback")).strip().lower()
|
|
391
|
+
mode = os.environ.get("PAIRLING_BIND_MODE", "loopback").strip().lower()
|
|
353
392
|
if mode in ("all", "tailnet_lan"):
|
|
354
393
|
return "0.0.0.0"
|
|
355
394
|
if mode == "loopback":
|
|
@@ -544,7 +583,7 @@ class _RuntimeAdmission:
|
|
|
544
583
|
pass
|
|
545
584
|
self._released = True
|
|
546
585
|
|
|
547
|
-
PUBLIC_ENDPOINTS = {"/health", "/healthz", "/readyz", "/manifest", "/pair/start", "/pair/claim"}
|
|
586
|
+
PUBLIC_ENDPOINTS = {"/health", "/healthz", "/readyz", "/manifest", "/pair/start", "/pair/claim", "/pair/psk-claim", "/pair/reauth-challenge", "/pair/reauth-claim"}
|
|
548
587
|
|
|
549
588
|
# Internal hook tier: loopback-only endpoints used by Claude Code hooks to
|
|
550
589
|
# write the session registry without device pairing. Gated by client IP AND
|
|
@@ -555,6 +594,7 @@ INTERNAL_LOOPBACK_PATHS = {
|
|
|
555
594
|
"/internal/session-heartbeat",
|
|
556
595
|
"/internal/session-close",
|
|
557
596
|
"/internal/active-sessions",
|
|
597
|
+
"/internal/permission-request",
|
|
558
598
|
}
|
|
559
599
|
INTERNAL_HOOK_TOKEN_FILE = COMPANION_DIR / "internal-hook-token"
|
|
560
600
|
|
|
@@ -586,6 +626,42 @@ def _ensure_internal_hook_token() -> str:
|
|
|
586
626
|
INTERNAL_HOOK_TOKEN = _ensure_internal_hook_token()
|
|
587
627
|
|
|
588
628
|
|
|
629
|
+
SPAWN_SETTINGS_PATH = COMPANION_DIR / "pairling-spawn-settings.json"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _ensure_spawn_settings() -> None:
|
|
633
|
+
"""Write the per-spawn claude settings overlay (the PermissionRequest producer
|
|
634
|
+
hook) into a Pairling-managed file passed to phone-spawned sessions via
|
|
635
|
+
--settings. This keeps the user's GLOBAL ~/.claude/settings.json UNTOUCHED:
|
|
636
|
+
the hook exists ONLY in sessions Pairling spawns, and the permission posture
|
|
637
|
+
is still inherited from the user's own settings (we add an observer hook,
|
|
638
|
+
never a mode). --settings hooks are auto-trusted (no review gate)."""
|
|
639
|
+
payload = {
|
|
640
|
+
"hooks": {
|
|
641
|
+
"PermissionRequest": [
|
|
642
|
+
{"hooks": [{
|
|
643
|
+
"type": "command",
|
|
644
|
+
"command": "node $HOME/.claude/hooks/dist/permission-request.mjs",
|
|
645
|
+
"timeout": 10,
|
|
646
|
+
}]}
|
|
647
|
+
]
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
try:
|
|
651
|
+
SPAWN_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
tmp = SPAWN_SETTINGS_PATH.with_name(SPAWN_SETTINGS_PATH.name + ".tmp")
|
|
653
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
654
|
+
json.dump(payload, fh, indent=2)
|
|
655
|
+
fh.flush()
|
|
656
|
+
os.fsync(fh.fileno())
|
|
657
|
+
os.replace(tmp, SPAWN_SETTINGS_PATH)
|
|
658
|
+
except OSError:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
_ensure_spawn_settings()
|
|
663
|
+
|
|
664
|
+
|
|
589
665
|
def _session_backend() -> str:
|
|
590
666
|
"""Which store serves claude session reads: 'pg' (Docker Postgres) or
|
|
591
667
|
'sqlite' (daemon-owned agent registry). Rollback at any point is
|
|
@@ -629,6 +705,9 @@ def _discard_session_ready_event(session_id: str) -> None:
|
|
|
629
705
|
POST_ONLY_ENDPOINTS = {
|
|
630
706
|
"/pair/start",
|
|
631
707
|
"/pair/claim",
|
|
708
|
+
"/pair/psk-claim",
|
|
709
|
+
"/pair/reauth-challenge",
|
|
710
|
+
"/pair/reauth-claim",
|
|
632
711
|
"/pair/revoke",
|
|
633
712
|
"/pair/rotate-token",
|
|
634
713
|
"/aperture-cli/open",
|
|
@@ -660,6 +739,7 @@ POST_ONLY_ENDPOINTS = {
|
|
|
660
739
|
"/cross-provider-action",
|
|
661
740
|
"/send-text",
|
|
662
741
|
"/terminal-control",
|
|
742
|
+
"/push/permission/allow",
|
|
663
743
|
"/sigint",
|
|
664
744
|
"/sigterm",
|
|
665
745
|
"/upload",
|
|
@@ -675,6 +755,7 @@ HIGH_RISK_ENDPOINTS = {
|
|
|
675
755
|
"/interrupt",
|
|
676
756
|
"/llm-route",
|
|
677
757
|
"/llm-route-stream",
|
|
758
|
+
"/push/permission/allow",
|
|
678
759
|
"/pairling-tools/run",
|
|
679
760
|
"/worker-kill",
|
|
680
761
|
"/push/preferences",
|
|
@@ -860,6 +941,8 @@ def _required_scopes_for_request(path: str, method: str) -> set[str]:
|
|
|
860
941
|
return {"health:read"}
|
|
861
942
|
if path in {"/push/preferences", "/push/test", "/push/live-activity-token", "/push/live-activity-test"}:
|
|
862
943
|
return {"pair:admin"}
|
|
944
|
+
if path == "/push/permission/allow":
|
|
945
|
+
return {"session:signal"}
|
|
863
946
|
if path == "/sentinel/preferences" and method == "POST":
|
|
864
947
|
return {"pair:admin"}
|
|
865
948
|
if path in {"/sentinel/status", "/sentinel/preferences", "/sentinel/events"}:
|
|
@@ -878,6 +961,8 @@ def _required_scopes_for_request(path: str, method: str) -> set[str]:
|
|
|
878
961
|
return {"session:signal"}
|
|
879
962
|
if path in {"/spawn-session", "/resume-session", "/cross-provider-action"}:
|
|
880
963
|
return {"session:spawn"}
|
|
964
|
+
if path == "/onestream-handoff":
|
|
965
|
+
return {"session:spawn"} if method == "POST" else {"sessions:read"}
|
|
881
966
|
if path in {"/llm-route", "/llm-route-stream"}:
|
|
882
967
|
return {"llm:route"}
|
|
883
968
|
if path == "/pairling-tools/run":
|
|
@@ -1111,6 +1196,34 @@ def _agent_registry_bootstrap_schema(conn) -> None:
|
|
|
1111
1196
|
"CREATE INDEX IF NOT EXISTS idx_agent_sessions_provider_live "
|
|
1112
1197
|
"ON agent_sessions(provider, closed_at, last_heartbeat)"
|
|
1113
1198
|
)
|
|
1199
|
+
# Per-tool approval queue (Lock-Screen "Permission request" card, Phase 2).
|
|
1200
|
+
# Same daemon-owned DB. NO deadline/expiry columns by design: an unanswered
|
|
1201
|
+
# prompt hangs at its native dialog forever until the user acts (Allow
|
|
1202
|
+
# keystroke / in-app). Rows are recorded by the PermissionRequest hook via
|
|
1203
|
+
# POST /internal/permission-request and resolved by the Allow path (Phase 3).
|
|
1204
|
+
conn.execute(
|
|
1205
|
+
"""
|
|
1206
|
+
CREATE TABLE IF NOT EXISTS pending_approvals (
|
|
1207
|
+
request_nonce TEXT PRIMARY KEY,
|
|
1208
|
+
provider TEXT NOT NULL,
|
|
1209
|
+
session_id TEXT NOT NULL,
|
|
1210
|
+
native_id TEXT,
|
|
1211
|
+
broker_id TEXT,
|
|
1212
|
+
terminal_tty TEXT,
|
|
1213
|
+
tool_name TEXT NOT NULL,
|
|
1214
|
+
tool_input_json TEXT NOT NULL,
|
|
1215
|
+
command_preview TEXT,
|
|
1216
|
+
permission_mode TEXT,
|
|
1217
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
1218
|
+
created_at REAL NOT NULL,
|
|
1219
|
+
resolved_at REAL
|
|
1220
|
+
)
|
|
1221
|
+
"""
|
|
1222
|
+
)
|
|
1223
|
+
conn.execute(
|
|
1224
|
+
"CREATE INDEX IF NOT EXISTS idx_pending_approvals_session "
|
|
1225
|
+
"ON pending_approvals(session_id, state)"
|
|
1226
|
+
)
|
|
1114
1227
|
if _AGENT_REGISTRY_SCHEMA_READY:
|
|
1115
1228
|
return
|
|
1116
1229
|
with _AGENT_REGISTRY_SCHEMA_LOCK:
|
|
@@ -1151,6 +1264,149 @@ def _agent_registry_conn():
|
|
|
1151
1264
|
conn.close()
|
|
1152
1265
|
|
|
1153
1266
|
|
|
1267
|
+
def _approval_command_preview(tool_name: str, tool_input: dict) -> str:
|
|
1268
|
+
"""Render the one-line card text from a tool call's structured input."""
|
|
1269
|
+
try:
|
|
1270
|
+
if tool_name == "Bash":
|
|
1271
|
+
return str(tool_input.get("command") or "").strip()[:300]
|
|
1272
|
+
if tool_name in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
1273
|
+
fp = str(tool_input.get("file_path") or tool_input.get("notebook_path") or "").strip()
|
|
1274
|
+
base = os.path.basename(fp) if fp else ""
|
|
1275
|
+
return f"{tool_name} {base}".strip()[:300]
|
|
1276
|
+
if tool_name == "WebFetch":
|
|
1277
|
+
url = str(tool_input.get("url") or "").strip()
|
|
1278
|
+
try:
|
|
1279
|
+
host = urlparse(url).netloc or url
|
|
1280
|
+
except Exception:
|
|
1281
|
+
host = url
|
|
1282
|
+
return f"Fetch {host}".strip()[:300]
|
|
1283
|
+
return tool_name[:300]
|
|
1284
|
+
except Exception:
|
|
1285
|
+
return tool_name[:300]
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def _approval_resolve_session(provider: str, session_id: str) -> tuple[str, str, str]:
|
|
1289
|
+
"""Best-effort map a hook's session_id -> (native_id, broker_id, terminal_tty)
|
|
1290
|
+
from the agent_sessions registry. For claude the hook session_id is the
|
|
1291
|
+
claude_uuid; for codex it is the registry native_id. Empties on miss — the
|
|
1292
|
+
Phase 3 Allow path re-resolves against the live broker before answering."""
|
|
1293
|
+
try:
|
|
1294
|
+
with _agent_registry_conn() as conn:
|
|
1295
|
+
if provider == "claude":
|
|
1296
|
+
row = conn.execute(
|
|
1297
|
+
"SELECT native_id, terminal_tty FROM agent_sessions "
|
|
1298
|
+
"WHERE provider='claude' AND claude_uuid=? AND closed_at IS NULL "
|
|
1299
|
+
"ORDER BY last_heartbeat DESC LIMIT 1",
|
|
1300
|
+
(session_id,),
|
|
1301
|
+
).fetchone()
|
|
1302
|
+
else:
|
|
1303
|
+
lookup_native = session_id
|
|
1304
|
+
parsed_provider, parsed_native = _parse_agent_session_ref(session_id)
|
|
1305
|
+
if parsed_provider == provider and parsed_native:
|
|
1306
|
+
lookup_native = parsed_native
|
|
1307
|
+
row = conn.execute(
|
|
1308
|
+
"SELECT native_id, terminal_tty FROM agent_sessions "
|
|
1309
|
+
"WHERE provider=? AND native_id=? AND closed_at IS NULL "
|
|
1310
|
+
"ORDER BY last_heartbeat DESC LIMIT 1",
|
|
1311
|
+
(provider, lookup_native),
|
|
1312
|
+
).fetchone()
|
|
1313
|
+
if not row:
|
|
1314
|
+
return ("", "", "")
|
|
1315
|
+
native_id = str(row["native_id"] or "")
|
|
1316
|
+
broker_id = _qualified_session_id(provider, native_id) if native_id else ""
|
|
1317
|
+
return (native_id, broker_id, str(row["terminal_tty"] or ""))
|
|
1318
|
+
except Exception:
|
|
1319
|
+
return ("", "", "")
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _pending_approval_record(*, request_nonce: str, provider: str, session_id: str,
|
|
1323
|
+
tool_name: str, tool_input: dict, command_preview: str = "",
|
|
1324
|
+
permission_mode: str = "", broker_id: str = "",
|
|
1325
|
+
state: str = "pending") -> bool:
|
|
1326
|
+
"""Idempotently record a pending tool approval (INSERT OR IGNORE on the
|
|
1327
|
+
hook-minted request_nonce). No deadline/expiry — by design. broker_id, when
|
|
1328
|
+
supplied by the hook (from the broker's PAIRLING_BROKER_SESSION_ID env), is the
|
|
1329
|
+
AUTHORITATIVE live broker session id — far more reliable than registry
|
|
1330
|
+
reconciliation, since the SessionStart tty capture fails for broker PTYs (the
|
|
1331
|
+
claude_uuid and the broker tty land on two unlinked rows)."""
|
|
1332
|
+
now = _time.time()
|
|
1333
|
+
native_id, resolved_broker, terminal_tty = _approval_resolve_session(provider, session_id)
|
|
1334
|
+
if not broker_id:
|
|
1335
|
+
broker_id = resolved_broker
|
|
1336
|
+
row_state = state if state in {"pending", "attention"} else "pending"
|
|
1337
|
+
try:
|
|
1338
|
+
with _agent_registry_conn() as conn:
|
|
1339
|
+
conn.execute(
|
|
1340
|
+
"""
|
|
1341
|
+
INSERT OR IGNORE INTO pending_approvals
|
|
1342
|
+
(request_nonce, provider, session_id, native_id, broker_id,
|
|
1343
|
+
terminal_tty, tool_name, tool_input_json, command_preview,
|
|
1344
|
+
permission_mode, state, created_at, resolved_at)
|
|
1345
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
1346
|
+
""",
|
|
1347
|
+
(request_nonce, provider, session_id, native_id, broker_id,
|
|
1348
|
+
terminal_tty, tool_name, json.dumps(tool_input)[:8000],
|
|
1349
|
+
(command_preview or "")[:300], permission_mode, row_state, now),
|
|
1350
|
+
)
|
|
1351
|
+
return True
|
|
1352
|
+
except Exception:
|
|
1353
|
+
return False
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _pending_approval_get(request_nonce: str) -> dict | None:
|
|
1357
|
+
try:
|
|
1358
|
+
with _agent_registry_conn() as conn:
|
|
1359
|
+
row = conn.execute(
|
|
1360
|
+
"SELECT * FROM pending_approvals WHERE request_nonce=?",
|
|
1361
|
+
(request_nonce,),
|
|
1362
|
+
).fetchone()
|
|
1363
|
+
return dict(row) if row else None
|
|
1364
|
+
except Exception:
|
|
1365
|
+
return None
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _pending_approval_cas(request_nonce: str, expected: str, new: str) -> bool:
|
|
1369
|
+
"""Compare-and-set the approval state. Returns True iff THIS call performed the
|
|
1370
|
+
transition — so a duplicate Allow (state already != expected) is a safe no-op."""
|
|
1371
|
+
try:
|
|
1372
|
+
with _agent_registry_conn() as conn:
|
|
1373
|
+
cur = conn.execute(
|
|
1374
|
+
"UPDATE pending_approvals SET state=?, resolved_at=? "
|
|
1375
|
+
"WHERE request_nonce=? AND state=?",
|
|
1376
|
+
(new, _time.time(), request_nonce, expected),
|
|
1377
|
+
)
|
|
1378
|
+
return cur.rowcount > 0
|
|
1379
|
+
except Exception:
|
|
1380
|
+
return False
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _pending_approvals_open() -> list[dict]:
|
|
1384
|
+
try:
|
|
1385
|
+
with _agent_registry_conn() as conn:
|
|
1386
|
+
rows = conn.execute(
|
|
1387
|
+
"SELECT * FROM pending_approvals WHERE state IN ('pending', 'attention') "
|
|
1388
|
+
"ORDER BY created_at ASC LIMIT 500"
|
|
1389
|
+
).fetchall()
|
|
1390
|
+
return [dict(row) for row in rows]
|
|
1391
|
+
except Exception:
|
|
1392
|
+
return []
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _pending_approval_resolve_terminal(request_nonce: str, new_state: str) -> bool:
|
|
1396
|
+
if new_state not in {"session_gone", "expired_session"}:
|
|
1397
|
+
return False
|
|
1398
|
+
try:
|
|
1399
|
+
with _agent_registry_conn() as conn:
|
|
1400
|
+
cur = conn.execute(
|
|
1401
|
+
"UPDATE pending_approvals SET state=?, resolved_at=? "
|
|
1402
|
+
"WHERE request_nonce=? AND state IN ('pending', 'attention')",
|
|
1403
|
+
(new_state, _time.time(), request_nonce),
|
|
1404
|
+
)
|
|
1405
|
+
return cur.rowcount > 0
|
|
1406
|
+
except Exception:
|
|
1407
|
+
return False
|
|
1408
|
+
|
|
1409
|
+
|
|
1154
1410
|
def _pretrust_claude_project(project: str) -> None:
|
|
1155
1411
|
"""Mark `project` trusted in ~/.claude.json before a headless claude spawn.
|
|
1156
1412
|
|
|
@@ -1823,7 +2079,7 @@ def _guardian_listener_entries(power_state: dict | None) -> list[str]:
|
|
|
1823
2079
|
|
|
1824
2080
|
|
|
1825
2081
|
def _probe_listener_entries() -> list[str]:
|
|
1826
|
-
host = BOUND_HOST or os.environ.get("
|
|
2082
|
+
host = BOUND_HOST or os.environ.get("PAIRLING_BOUND_HOST", "")
|
|
1827
2083
|
entries: list[str] = []
|
|
1828
2084
|
ok, out, _ = _run_text(["/usr/sbin/lsof", "-nP", f"-iTCP:{PORT}", "-sTCP:LISTEN"], timeout=3)
|
|
1829
2085
|
if ok:
|
|
@@ -1974,7 +2230,7 @@ def _normalize_guardian_state(state: dict | None) -> dict | None:
|
|
|
1974
2230
|
"lan_ips": [],
|
|
1975
2231
|
},
|
|
1976
2232
|
"daemon": {
|
|
1977
|
-
"
|
|
2233
|
+
"pairling_pid": os.getpid(),
|
|
1978
2234
|
"listen": (listener_entries := _listener_entries()),
|
|
1979
2235
|
"reachable_local": bool(listener_entries),
|
|
1980
2236
|
"reachable_tailnet": facts.get("daemon_reachable"),
|
|
@@ -2089,7 +2345,7 @@ def _health_routes(coordinator: dict, power_state: dict | None = None) -> list[d
|
|
|
2089
2345
|
"last_ok_at": None,
|
|
2090
2346
|
})
|
|
2091
2347
|
if not routes:
|
|
2092
|
-
host = os.environ.get("
|
|
2348
|
+
host = os.environ.get("PAIRLING_BOUND_HOST", f"0.0.0.0")
|
|
2093
2349
|
base_url = host if host.startswith("http") else f"http://{host}:{PORT}"
|
|
2094
2350
|
routes.append({
|
|
2095
2351
|
"kind": "manual",
|
|
@@ -2622,8 +2878,8 @@ def _inject_rate_check(session_id: str, max_per_min: int = 30) -> tuple[bool, in
|
|
|
2622
2878
|
return True, 0
|
|
2623
2879
|
|
|
2624
2880
|
# Project paths matching any of these glob-ish substrings are filtered out of
|
|
2625
|
-
# /corpus, /sessions, and bucket rollups.
|
|
2626
|
-
#
|
|
2881
|
+
# /corpus, /sessions, and bucket rollups. Users can accumulate many one-shot
|
|
2882
|
+
# research scratch dirs that drown out signal — exclude by default.
|
|
2627
2883
|
PROJECT_EXCLUDE_PATTERNS = [
|
|
2628
2884
|
"biotech-labs/synth-synth-", # ephemeral synth-* worktrees
|
|
2629
2885
|
"biotech-labs/crohns-research/scripts", # bench-research scratch
|
|
@@ -2731,8 +2987,8 @@ def _encode_project_dir(project_path: str) -> str:
|
|
|
2731
2987
|
directory name under ~/.claude/projects/.
|
|
2732
2988
|
|
|
2733
2989
|
Claude Code encodes ALL of `/`, `.`, and `_` as `-`. This means a path
|
|
2734
|
-
like /Users/
|
|
2735
|
-
becomes -Users-
|
|
2990
|
+
like /Users/example/.claude/state/sentinel/projects/onestream-378da5/terminals/orange_team
|
|
2991
|
+
becomes -Users-example--claude-state-sentinel-projects-onestream-378da5-terminals-orange-team
|
|
2736
2992
|
(note: `.claude` → `-claude` so two consecutive `-`; `orange_team` → `orange-team`).
|
|
2737
2993
|
|
|
2738
2994
|
The previous implementation only handled `/` and broke for sentinel
|
|
@@ -2744,7 +3000,7 @@ def _encode_project_dir(project_path: str) -> str:
|
|
|
2744
3000
|
|
|
2745
3001
|
|
|
2746
3002
|
# Sentinel session paths look like:
|
|
2747
|
-
# /Users/
|
|
3003
|
+
# /Users/example/.claude/state/sentinel/projects/<bucket>-<6hex>/terminals/<mode>
|
|
2748
3004
|
# We strip the sentinel prefix + the 6-hex suffix to recover the underlying
|
|
2749
3005
|
# project bucket name (e.g. "proofforge"). Regular projects use their basename.
|
|
2750
3006
|
_SENTINEL_PROJECTS_RE = re.compile(
|
|
@@ -4526,8 +4782,8 @@ def _terminal_surface_source(raw_session: str) -> dict:
|
|
|
4526
4782
|
"available": True,
|
|
4527
4783
|
"source": "broker_vt",
|
|
4528
4784
|
"reason": "broker_vt",
|
|
4529
|
-
"broker_id": session
|
|
4530
|
-
"tty": session
|
|
4785
|
+
"broker_id": _broker_session_id(session),
|
|
4786
|
+
"tty": _broker_slave_tty(session),
|
|
4531
4787
|
"can_control": True,
|
|
4532
4788
|
}
|
|
4533
4789
|
|
|
@@ -4544,13 +4800,13 @@ def _terminal_surface_source(raw_session: str) -> dict:
|
|
|
4544
4800
|
if broker_id and PTY_BROKER is not None:
|
|
4545
4801
|
session = PTY_BROKER.get(broker_id)
|
|
4546
4802
|
if session is not None:
|
|
4547
|
-
PTY_BROKER.register_alias(qualified, session)
|
|
4803
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
4548
4804
|
return {
|
|
4549
4805
|
"available": True,
|
|
4550
4806
|
"source": "broker_vt",
|
|
4551
4807
|
"reason": "broker_vt",
|
|
4552
|
-
"broker_id": session
|
|
4553
|
-
"tty": session
|
|
4808
|
+
"broker_id": _broker_session_id(session),
|
|
4809
|
+
"tty": _broker_slave_tty(session),
|
|
4554
4810
|
"can_control": True,
|
|
4555
4811
|
}
|
|
4556
4812
|
capture_path = _terminal_capture_from_metadata(metadata)
|
|
@@ -5573,7 +5829,9 @@ def _turn_state_path(provider: str, native_id: str) -> Path:
|
|
|
5573
5829
|
def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
|
|
5574
5830
|
tool: str | None = None, effort: str | None = None,
|
|
5575
5831
|
started_at: float | None = None,
|
|
5576
|
-
event: str = "daemon"
|
|
5832
|
+
event: str = "daemon",
|
|
5833
|
+
request_nonce: str | None = None,
|
|
5834
|
+
mac_install_id: str | None = None) -> dict:
|
|
5577
5835
|
now = _time.time()
|
|
5578
5836
|
prior: dict = {}
|
|
5579
5837
|
path = _turn_state_path(provider, native_id)
|
|
@@ -5583,7 +5841,7 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
|
|
|
5583
5841
|
except Exception:
|
|
5584
5842
|
prior = {}
|
|
5585
5843
|
payload = {
|
|
5586
|
-
"session_id": native_id,
|
|
5844
|
+
"session_id": _qualified_session_id(provider, native_id) if provider == "codex" else native_id,
|
|
5587
5845
|
"state": state,
|
|
5588
5846
|
"tool": tool,
|
|
5589
5847
|
"started_at": float(started_at or prior.get("started_at") or now),
|
|
@@ -5591,6 +5849,10 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
|
|
|
5591
5849
|
"effort": effort if effort is not None else prior.get("effort"),
|
|
5592
5850
|
"event": event,
|
|
5593
5851
|
}
|
|
5852
|
+
if request_nonce:
|
|
5853
|
+
payload["request_nonce"] = str(request_nonce)
|
|
5854
|
+
if mac_install_id:
|
|
5855
|
+
payload["mac_install_id"] = str(mac_install_id)
|
|
5594
5856
|
try:
|
|
5595
5857
|
TURN_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
5596
5858
|
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}")
|
|
@@ -5602,6 +5864,132 @@ def _write_agent_turn_state(provider: str, native_id: str, state: str, *,
|
|
|
5602
5864
|
return payload
|
|
5603
5865
|
|
|
5604
5866
|
|
|
5867
|
+
_CODEX_APPROVAL_NONCES: dict[str, str] = {}
|
|
5868
|
+
_CODEX_APPROVAL_SCREEN_KEYS: dict[str, str] = {}
|
|
5869
|
+
|
|
5870
|
+
|
|
5871
|
+
def _rows_from_broker_snapshot(snapshot: dict | None) -> list[str]:
|
|
5872
|
+
if not isinstance(snapshot, dict):
|
|
5873
|
+
return []
|
|
5874
|
+
rows = snapshot.get("rows")
|
|
5875
|
+
if isinstance(rows, list):
|
|
5876
|
+
text_rows: list[str] = []
|
|
5877
|
+
for row in rows:
|
|
5878
|
+
if isinstance(row, str):
|
|
5879
|
+
text_rows.append(row)
|
|
5880
|
+
elif isinstance(row, dict):
|
|
5881
|
+
cells = row.get("cells")
|
|
5882
|
+
if isinstance(cells, list):
|
|
5883
|
+
text_rows.append("".join(str(cell.get("text") or "") for cell in cells if isinstance(cell, dict)).rstrip())
|
|
5884
|
+
return text_rows
|
|
5885
|
+
return []
|
|
5886
|
+
|
|
5887
|
+
|
|
5888
|
+
def _approval_screen_key(snapshot: dict | None) -> str:
|
|
5889
|
+
if not isinstance(snapshot, dict):
|
|
5890
|
+
return ""
|
|
5891
|
+
return ":".join(str(snapshot.get(key) or "") for key in ("screen_hash", "generation", "raw_offset", "nonce"))
|
|
5892
|
+
|
|
5893
|
+
|
|
5894
|
+
def _clear_codex_approval(broker_id: str, session: dict) -> None:
|
|
5895
|
+
nonce = _CODEX_APPROVAL_NONCES.pop(broker_id, None)
|
|
5896
|
+
_CODEX_APPROVAL_SCREEN_KEYS.pop(broker_id, None)
|
|
5897
|
+
native_id = str(session.get("native_id") or "")
|
|
5898
|
+
if nonce:
|
|
5899
|
+
row = _pending_approval_get(nonce)
|
|
5900
|
+
row_state = str((row or {}).get("state") or "")
|
|
5901
|
+
if row_state in {"attention", "pending"}:
|
|
5902
|
+
_pending_approval_cas(nonce, row_state, "resolved_local")
|
|
5903
|
+
if native_id:
|
|
5904
|
+
_write_agent_turn_state("codex", native_id, "idle", event="codex_approval_cleared")
|
|
5905
|
+
|
|
5906
|
+
|
|
5907
|
+
def _scan_codex_approvals_once() -> None:
|
|
5908
|
+
if PTY_BROKER is None or classify_codex_approval is None:
|
|
5909
|
+
return
|
|
5910
|
+
try:
|
|
5911
|
+
live = {
|
|
5912
|
+
str(session.get("session_id") or ""): session
|
|
5913
|
+
for session in PTY_BROKER.list_sessions()
|
|
5914
|
+
if isinstance(session, dict)
|
|
5915
|
+
and session.get("provider") == "codex"
|
|
5916
|
+
and session.get("session_id")
|
|
5917
|
+
and session.get("native_id")
|
|
5918
|
+
}
|
|
5919
|
+
except Exception:
|
|
5920
|
+
return
|
|
5921
|
+
for broker_id, session in live.items():
|
|
5922
|
+
try:
|
|
5923
|
+
snapshot = PTY_BROKER.snapshot(broker_id)
|
|
5924
|
+
except Exception:
|
|
5925
|
+
continue
|
|
5926
|
+
rows = _rows_from_broker_snapshot(snapshot)
|
|
5927
|
+
screen_key = _approval_screen_key(snapshot)
|
|
5928
|
+
if _CODEX_APPROVAL_SCREEN_KEYS.get(broker_id) == screen_key and _CODEX_APPROVAL_NONCES.get(broker_id):
|
|
5929
|
+
continue
|
|
5930
|
+
pending = (snapshot or {}).get("pending_input") if isinstance(snapshot, dict) else None
|
|
5931
|
+
if not isinstance(pending, dict):
|
|
5932
|
+
pending = None
|
|
5933
|
+
approval = classify_codex_approval(pending, rows, screen_key=screen_key)
|
|
5934
|
+
if not approval:
|
|
5935
|
+
if broker_id in _CODEX_APPROVAL_NONCES:
|
|
5936
|
+
_clear_codex_approval(broker_id, session)
|
|
5937
|
+
continue
|
|
5938
|
+
summary = str(approval.get("summary") or approval.get("command") or "codex approval")[:300]
|
|
5939
|
+
dialog_material = "|".join([broker_id, str(approval.get("dialog_key") or screen_key), summary])
|
|
5940
|
+
nonce = "codex-scrape-" + hashlib.sha256(dialog_material.encode("utf-8")).hexdigest()[:24]
|
|
5941
|
+
_CODEX_APPROVAL_SCREEN_KEYS[broker_id] = screen_key
|
|
5942
|
+
if _CODEX_APPROVAL_NONCES.get(broker_id) == nonce:
|
|
5943
|
+
continue
|
|
5944
|
+
native_id = str(session.get("native_id") or "")
|
|
5945
|
+
_pending_approval_record(
|
|
5946
|
+
request_nonce=nonce,
|
|
5947
|
+
provider="codex",
|
|
5948
|
+
session_id=_qualified_session_id("codex", native_id),
|
|
5949
|
+
tool_name="Bash",
|
|
5950
|
+
tool_input={"command": str(approval.get("command") or ""), "summary": summary},
|
|
5951
|
+
command_preview=summary,
|
|
5952
|
+
permission_mode="",
|
|
5953
|
+
broker_id=broker_id,
|
|
5954
|
+
state="attention",
|
|
5955
|
+
)
|
|
5956
|
+
_CODEX_APPROVAL_NONCES[broker_id] = nonce
|
|
5957
|
+
_write_agent_turn_state(
|
|
5958
|
+
"codex",
|
|
5959
|
+
native_id,
|
|
5960
|
+
"attention",
|
|
5961
|
+
tool=summary[:80],
|
|
5962
|
+
event="codex_approval",
|
|
5963
|
+
request_nonce=nonce,
|
|
5964
|
+
mac_install_id=getattr(PAIRING_STORE, "install_id", "") if PAIRING_STORE else "",
|
|
5965
|
+
)
|
|
5966
|
+
for broker_id in list(_CODEX_APPROVAL_NONCES.keys()):
|
|
5967
|
+
if broker_id not in live:
|
|
5968
|
+
_CODEX_APPROVAL_NONCES.pop(broker_id, None)
|
|
5969
|
+
_CODEX_APPROVAL_SCREEN_KEYS.pop(broker_id, None)
|
|
5970
|
+
|
|
5971
|
+
|
|
5972
|
+
def _start_codex_approval_scanner() -> threading.Thread | None:
|
|
5973
|
+
if PTY_BROKER is None or classify_codex_approval is None:
|
|
5974
|
+
return None
|
|
5975
|
+
try:
|
|
5976
|
+
interval = max(0.25, float(os.environ.get("PAIRLING_CODEX_APPROVAL_POLL_S", "1.0")))
|
|
5977
|
+
except Exception:
|
|
5978
|
+
interval = 1.0
|
|
5979
|
+
|
|
5980
|
+
def run() -> None:
|
|
5981
|
+
while True:
|
|
5982
|
+
try:
|
|
5983
|
+
_scan_codex_approvals_once()
|
|
5984
|
+
except Exception as exc:
|
|
5985
|
+
print(f"[codex-approval-scan] skipped: {type(exc).__name__}: {str(exc)[:120]}", file=sys.stderr, flush=True)
|
|
5986
|
+
_time.sleep(interval)
|
|
5987
|
+
|
|
5988
|
+
thread = threading.Thread(target=run, name="pairling-codex-approval-scan", daemon=True)
|
|
5989
|
+
thread.start()
|
|
5990
|
+
return thread
|
|
5991
|
+
|
|
5992
|
+
|
|
5605
5993
|
def _publish_live_activity_turn_state(provider: str, native_id: str, payload: dict) -> None:
|
|
5606
5994
|
publisher = LIVE_ACTIVITY_PUBLISHER
|
|
5607
5995
|
if publisher is None or not hasattr(publisher, "publish_turn_state_payload"):
|
|
@@ -6846,6 +7234,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
6846
7234
|
self._handle_internal_session_close(q)
|
|
6847
7235
|
elif u.path == "/internal/active-sessions":
|
|
6848
7236
|
self._handle_internal_active_sessions(q)
|
|
7237
|
+
elif u.path == "/internal/permission-request":
|
|
7238
|
+
self._handle_internal_permission_request(q)
|
|
6849
7239
|
finally:
|
|
6850
7240
|
admission.release()
|
|
6851
7241
|
return
|
|
@@ -7006,6 +7396,12 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7006
7396
|
self._handle_pair_start(q)
|
|
7007
7397
|
elif u.path == "/pair/claim":
|
|
7008
7398
|
self._handle_pair_claim(q)
|
|
7399
|
+
elif u.path == "/pair/psk-claim":
|
|
7400
|
+
self._handle_pair_psk_claim(q)
|
|
7401
|
+
elif u.path == "/pair/reauth-challenge":
|
|
7402
|
+
self._handle_pair_reauth_challenge(q)
|
|
7403
|
+
elif u.path == "/pair/reauth-claim":
|
|
7404
|
+
self._handle_pair_reauth_claim(q)
|
|
7009
7405
|
elif u.path == "/pair/revoke":
|
|
7010
7406
|
self._handle_pair_revoke(q)
|
|
7011
7407
|
elif u.path == "/pair/rotate-token":
|
|
@@ -7088,6 +7484,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7088
7484
|
self._handle_push_preferences(q)
|
|
7089
7485
|
elif u.path == "/push/test":
|
|
7090
7486
|
self._handle_push_test(q)
|
|
7487
|
+
elif u.path == "/push/permission/allow":
|
|
7488
|
+
self._handle_push_permission_allow(q)
|
|
7091
7489
|
elif u.path == "/push/live-activity-token":
|
|
7092
7490
|
self._handle_push_live_activity_token(q)
|
|
7093
7491
|
elif u.path == "/push/live-activity-test":
|
|
@@ -7157,6 +7555,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7157
7555
|
self._handle_worker_kill(q)
|
|
7158
7556
|
elif u.path == "/spawn-session":
|
|
7159
7557
|
self._handle_spawn_session(q)
|
|
7558
|
+
elif u.path == "/onestream-handoff":
|
|
7559
|
+
self._handle_onestream_handoff(q)
|
|
7160
7560
|
elif u.path == "/resume-session":
|
|
7161
7561
|
self._handle_resume_session(q)
|
|
7162
7562
|
elif u.path == "/cross-provider-action":
|
|
@@ -7349,6 +7749,45 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7349
7749
|
items.sort(key=lambda r: r["started_at"], reverse=True)
|
|
7350
7750
|
self._send_json({"ok": True, "count": len(items), "sessions": items})
|
|
7351
7751
|
|
|
7752
|
+
def _handle_internal_permission_request(self, q):
|
|
7753
|
+
# PermissionRequest hook producer (claude + codex). Notify-only: record
|
|
7754
|
+
# the pending approval; the agent's native dialog is the durable block.
|
|
7755
|
+
# Phase 3 fires the APNs card + wires the Allow keystroke here.
|
|
7756
|
+
payload = self._read_internal_json()
|
|
7757
|
+
if payload is None:
|
|
7758
|
+
return
|
|
7759
|
+
provider = str(payload.get("provider") or "claude").strip().lower()
|
|
7760
|
+
if provider not in ("claude", "codex"):
|
|
7761
|
+
provider = "claude"
|
|
7762
|
+
session_id = str(payload.get("session_id") or "").strip()
|
|
7763
|
+
tool_name = str(payload.get("tool_name") or "").strip()
|
|
7764
|
+
tool_input = payload.get("tool_input")
|
|
7765
|
+
if not isinstance(tool_input, dict):
|
|
7766
|
+
tool_input = {}
|
|
7767
|
+
if not session_id or not tool_name:
|
|
7768
|
+
self._send_json({
|
|
7769
|
+
"ok": False,
|
|
7770
|
+
"error": {"code": "bad_request", "message": "session_id and tool_name required"},
|
|
7771
|
+
}, status=400)
|
|
7772
|
+
return
|
|
7773
|
+
request_nonce = str(payload.get("request_nonce") or "").strip() or secrets.token_hex(16)
|
|
7774
|
+
command_preview = _approval_command_preview(tool_name, tool_input)
|
|
7775
|
+
ok = _pending_approval_record(
|
|
7776
|
+
request_nonce=request_nonce,
|
|
7777
|
+
provider=provider,
|
|
7778
|
+
session_id=session_id,
|
|
7779
|
+
tool_name=tool_name,
|
|
7780
|
+
tool_input=tool_input,
|
|
7781
|
+
command_preview=command_preview,
|
|
7782
|
+
permission_mode=str(payload.get("permission_mode") or "")[:40],
|
|
7783
|
+
broker_id=str(payload.get("broker_session_id") or "").strip(),
|
|
7784
|
+
)
|
|
7785
|
+
self._send_json({
|
|
7786
|
+
"ok": bool(ok),
|
|
7787
|
+
"request_nonce": request_nonce,
|
|
7788
|
+
"command_preview": command_preview,
|
|
7789
|
+
})
|
|
7790
|
+
|
|
7352
7791
|
# ----- /open: open path on Mac (existing behavior) -----
|
|
7353
7792
|
def _handle_open(self, q):
|
|
7354
7793
|
path = q.get("path", [""])[0]
|
|
@@ -7604,6 +8043,21 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7604
8043
|
},
|
|
7605
8044
|
}, status=503)
|
|
7606
8045
|
return
|
|
8046
|
+
# P0-B: rate-limit unauthenticated pair starts per source IP so an
|
|
8047
|
+
# on-LAN attacker cannot mint a flood of invitations.
|
|
8048
|
+
allowed, retry_after = _request_rate_check(
|
|
8049
|
+
f"pair_start:{self.client_address[0]}", max_per_min=5
|
|
8050
|
+
)
|
|
8051
|
+
if not allowed:
|
|
8052
|
+
self.send_response(429)
|
|
8053
|
+
self.send_header("Retry-After", str(retry_after))
|
|
8054
|
+
self.send_header("Content-Type", "application/json")
|
|
8055
|
+
self.end_headers()
|
|
8056
|
+
self.wfile.write(json.dumps({
|
|
8057
|
+
"ok": False,
|
|
8058
|
+
"error": {"code": "rate_limited", "message": "too many pair starts"},
|
|
8059
|
+
}).encode("utf-8"))
|
|
8060
|
+
return
|
|
7607
8061
|
try:
|
|
7608
8062
|
payload = self._read_json_object()
|
|
7609
8063
|
ttl = int(payload.get("ttl_seconds") or DEFAULT_PAIR_TTL_SECONDS)
|
|
@@ -7621,6 +8075,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7621
8075
|
"ok": True,
|
|
7622
8076
|
"pair_id": started.pair_id,
|
|
7623
8077
|
"secret": started.secret,
|
|
8078
|
+
"attest_challenge": started.attest_challenge,
|
|
8079
|
+
"mac_ake_pub": started.mac_ake_pub,
|
|
7624
8080
|
"expires_at": started.expires_at,
|
|
7625
8081
|
"install_id": started.install_id,
|
|
7626
8082
|
"runtime_port": PORT,
|
|
@@ -7635,6 +8091,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7635
8091
|
"url": "pairling://pair",
|
|
7636
8092
|
"pair_id": started.pair_id,
|
|
7637
8093
|
"secret": started.secret,
|
|
8094
|
+
"pairing_nonce": started.pairing_nonce,
|
|
8095
|
+
"attest_challenge": started.attest_challenge,
|
|
8096
|
+
"mac_ake_pub": started.mac_ake_pub,
|
|
8097
|
+
"pv": "2" if started.mac_ake_pub else "1",
|
|
7638
8098
|
},
|
|
7639
8099
|
})
|
|
7640
8100
|
|
|
@@ -7657,6 +8117,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7657
8117
|
device_name=str(payload.get("device_name") or "Pairling iPhone"),
|
|
7658
8118
|
host_chain=host_chain,
|
|
7659
8119
|
cert_pin=None,
|
|
8120
|
+
pairing_nonce=str(payload.get("pairing_nonce") or ""),
|
|
8121
|
+
se_public_key_der=str(payload.get("se_public_key_der") or ""),
|
|
8122
|
+
attest_object=(payload.get("direct_attest_object") if isinstance(payload.get("direct_attest_object"), dict) else None),
|
|
8123
|
+
attest_key_id=str(payload.get("attest_key_id") or ""),
|
|
8124
|
+
attest_environment=str(payload.get("attest_environment") or ""),
|
|
7660
8125
|
attested_claim_ticket=payload.get("attested_claim_ticket"),
|
|
7661
8126
|
relay_device_id=payload.get("relay_device_id"),
|
|
7662
8127
|
relay_required=bool(relay_claims_required and relay_claims_required()),
|
|
@@ -7698,6 +8163,127 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
7698
8163
|
},
|
|
7699
8164
|
})
|
|
7700
8165
|
|
|
8166
|
+
def _handle_pair_psk_claim(self, q):
|
|
8167
|
+
# WS3: PSK-authenticated ECDH claim. The secret is NEVER received; the
|
|
8168
|
+
# phone proves knowledge of it by completing the key exchange. The
|
|
8169
|
+
# bearer token is returned AES-GCM-sealed under K_token, so a passive
|
|
8170
|
+
# on-LAN sniffer learns nothing.
|
|
8171
|
+
if PAIRING_STORE is None:
|
|
8172
|
+
self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "Pairing store is unavailable"}}, status=503)
|
|
8173
|
+
return
|
|
8174
|
+
allowed, retry_after = _request_rate_check(f"pair_psk:{self.client_address[0]}", max_per_min=5)
|
|
8175
|
+
if not allowed:
|
|
8176
|
+
self.send_response(429)
|
|
8177
|
+
self.send_header("Retry-After", str(retry_after))
|
|
8178
|
+
self.send_header("Content-Type", "application/json")
|
|
8179
|
+
self.end_headers()
|
|
8180
|
+
self.wfile.write(json.dumps({"ok": False, "error": {"code": "rate_limited", "message": "too many psk claims"}}).encode("utf-8"))
|
|
8181
|
+
return
|
|
8182
|
+
try:
|
|
8183
|
+
payload = self._read_json_object()
|
|
8184
|
+
host_chain = self._pairing_host_chain()
|
|
8185
|
+
claim, k_token, aad, mac_confirm = PAIRING_STORE.psk_claim_pair(
|
|
8186
|
+
pair_id=str(payload.get("pair_id") or ""),
|
|
8187
|
+
b_pub_b64=str(payload.get("b_pub") or ""),
|
|
8188
|
+
confirm_b64=str(payload.get("confirm") or ""),
|
|
8189
|
+
device_name=str(payload.get("device_name") or "Pairling iPhone"),
|
|
8190
|
+
host_chain=host_chain,
|
|
8191
|
+
se_public_key_der=str(payload.get("se_public_key_der") or ""),
|
|
8192
|
+
attest_object=(payload.get("direct_attest_object") if isinstance(payload.get("direct_attest_object"), dict) else None),
|
|
8193
|
+
attest_key_id=str(payload.get("attest_key_id") or ""),
|
|
8194
|
+
attest_environment=str(payload.get("attest_environment") or ""),
|
|
8195
|
+
attested_claim_ticket=payload.get("attested_claim_ticket"),
|
|
8196
|
+
relay_device_id=payload.get("relay_device_id"),
|
|
8197
|
+
relay_required=bool(relay_claims_required and relay_claims_required()),
|
|
8198
|
+
relay_claim_verifier=RELAY_CLAIM_VERIFIER,
|
|
8199
|
+
)
|
|
8200
|
+
nonce, enc_token = PAIRING_STORE.seal_psk_token(k_token, claim.device.token, aad)
|
|
8201
|
+
if PAIRING_ADVERTISER is not None:
|
|
8202
|
+
PAIRING_ADVERTISER.stop()
|
|
8203
|
+
except PairingError as exc:
|
|
8204
|
+
self._send_json({"ok": False, "error": {"code": exc.code, "message": exc.message}}, status=exc.status)
|
|
8205
|
+
return
|
|
8206
|
+
except ValueError as exc:
|
|
8207
|
+
self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
|
|
8208
|
+
return
|
|
8209
|
+
runtime_routes = self._pairing_runtime_routes(list(claim.host_chain))
|
|
8210
|
+
transport = "pairling-connect" if any(
|
|
8211
|
+
route.get("source") == "pairling_connectd" and route.get("status") == "ready"
|
|
8212
|
+
for route in runtime_routes
|
|
8213
|
+
) else "http-local"
|
|
8214
|
+
self._send_json({
|
|
8215
|
+
"ok": True,
|
|
8216
|
+
"device": {
|
|
8217
|
+
"id": claim.device.device_id,
|
|
8218
|
+
"proof_secret": claim.device.proof_secret,
|
|
8219
|
+
"scopes": list(claim.device.scopes),
|
|
8220
|
+
"relay_device_id": claim.relay_device_id,
|
|
8221
|
+
"attestation_status": claim.attestation_status,
|
|
8222
|
+
},
|
|
8223
|
+
"enc_token": base64.b64encode(enc_token).decode("ascii"),
|
|
8224
|
+
"nonce": base64.b64encode(nonce).decode("ascii"),
|
|
8225
|
+
"mac_confirm": base64.b64encode(mac_confirm).decode("ascii"),
|
|
8226
|
+
"install_id": claim.device.install_id,
|
|
8227
|
+
"runtime": {
|
|
8228
|
+
"port": claim.runtime_port,
|
|
8229
|
+
"host_chain": list(claim.host_chain),
|
|
8230
|
+
"cert_pin": claim.cert_pin,
|
|
8231
|
+
"transport": transport,
|
|
8232
|
+
"routes": runtime_routes,
|
|
8233
|
+
},
|
|
8234
|
+
})
|
|
8235
|
+
|
|
8236
|
+
def _handle_pair_reauth_challenge(self, q):
|
|
8237
|
+
if REAUTH_STORE is None:
|
|
8238
|
+
self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "reauth unavailable"}}, status=503)
|
|
8239
|
+
return
|
|
8240
|
+
allowed, retry_after = _request_rate_check(f"pair_reauth:{self.client_address[0]}", max_per_min=10)
|
|
8241
|
+
if not allowed:
|
|
8242
|
+
self.send_response(429)
|
|
8243
|
+
self.send_header("Retry-After", str(retry_after))
|
|
8244
|
+
self.send_header("Content-Type", "application/json")
|
|
8245
|
+
self.end_headers()
|
|
8246
|
+
self.wfile.write(json.dumps({"ok": False, "error": {"code": "rate_limited", "message": "too many reauth attempts"}}).encode("utf-8"))
|
|
8247
|
+
return
|
|
8248
|
+
try:
|
|
8249
|
+
payload = self._read_json_object()
|
|
8250
|
+
except ValueError as exc:
|
|
8251
|
+
self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
|
|
8252
|
+
return
|
|
8253
|
+
device_id = str(payload.get("device_id") or "")
|
|
8254
|
+
if not device_id:
|
|
8255
|
+
self._send_json({"ok": False, "error": {"code": "device_id_required", "message": "device_id required"}}, status=400)
|
|
8256
|
+
return
|
|
8257
|
+
# A challenge is issued for ANY device_id (even unknown / revoked) so
|
|
8258
|
+
# this endpoint never reveals whether a device exists.
|
|
8259
|
+
challenge = REAUTH_STORE.issue_challenge(device_id)
|
|
8260
|
+
self._send_json({"ok": True, "challenge": challenge, "ttl_seconds": REAUTH_STORE.ttl_seconds})
|
|
8261
|
+
|
|
8262
|
+
def _handle_pair_reauth_claim(self, q):
|
|
8263
|
+
if REAUTH_STORE is None or DEVICE_REGISTRY is None:
|
|
8264
|
+
self._send_json({"ok": False, "error": {"code": "pairing_unavailable", "message": "reauth unavailable"}}, status=503)
|
|
8265
|
+
return
|
|
8266
|
+
try:
|
|
8267
|
+
payload = self._read_json_object()
|
|
8268
|
+
except ValueError as exc:
|
|
8269
|
+
self._send_json({"ok": False, "error": {"code": "bad_request", "message": str(exc)}}, status=400)
|
|
8270
|
+
return
|
|
8271
|
+
device_id = str(payload.get("device_id") or "")
|
|
8272
|
+
challenge = str(payload.get("challenge") or "")
|
|
8273
|
+
signature_b64 = str(payload.get("signature") or "")
|
|
8274
|
+
try:
|
|
8275
|
+
signature = base64.b64decode(signature_b64) if signature_b64 else b""
|
|
8276
|
+
except Exception:
|
|
8277
|
+
signature = b""
|
|
8278
|
+
verified = REAUTH_STORE.verify_and_consume(device_id, challenge, signature)
|
|
8279
|
+
new_token = DEVICE_REGISTRY.rotate_token(device_id) if verified else None
|
|
8280
|
+
if not verified or not new_token:
|
|
8281
|
+
# Uniform failure: never distinguish unknown device / no SE key /
|
|
8282
|
+
# bad signature / expired-or-used challenge. No enumeration oracle.
|
|
8283
|
+
self._send_json({"ok": False, "error": {"code": "reauth_failed", "message": "reauth failed"}}, status=401)
|
|
8284
|
+
return
|
|
8285
|
+
self._send_json({"ok": True, "device": {"id": device_id, "token": new_token}})
|
|
8286
|
+
|
|
7701
8287
|
def _handle_pair_revoke(self, q):
|
|
7702
8288
|
if DEVICE_REGISTRY is None:
|
|
7703
8289
|
self._send_json({"ok": False, "error": {"code": "auth_unavailable"}}, status=503)
|
|
@@ -9814,13 +10400,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9814
10400
|
if broker_id:
|
|
9815
10401
|
session = PTY_BROKER.get(broker_id)
|
|
9816
10402
|
if session:
|
|
9817
|
-
PTY_BROKER.register_alias(qualified, session)
|
|
10403
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
9818
10404
|
return qualified, session
|
|
9819
10405
|
tty = reg.get("terminal_tty") or ""
|
|
9820
10406
|
if tty:
|
|
9821
10407
|
session = PTY_BROKER.get_by_tty(tty)
|
|
9822
10408
|
if session:
|
|
9823
|
-
PTY_BROKER.register_alias(qualified, session)
|
|
10409
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
9824
10410
|
return qualified, session
|
|
9825
10411
|
if provider == "claude":
|
|
9826
10412
|
session_id = _claude_native_session_id(raw_session)
|
|
@@ -9831,7 +10417,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9831
10417
|
session = PTY_BROKER.get_by_tty(tty)
|
|
9832
10418
|
if session:
|
|
9833
10419
|
qualified = _qualified_session_id("claude", session_id)
|
|
9834
|
-
PTY_BROKER.register_alias(qualified, session)
|
|
10420
|
+
PTY_BROKER.register_alias(qualified, _broker_session_id(session))
|
|
9835
10421
|
return qualified, session
|
|
9836
10422
|
return None
|
|
9837
10423
|
|
|
@@ -9840,16 +10426,16 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
9840
10426
|
if not found:
|
|
9841
10427
|
return None
|
|
9842
10428
|
public_session_id, session = found
|
|
9843
|
-
return
|
|
10429
|
+
return PTY_BROKER.snapshot(_broker_session_id(session), public_session_id=public_session_id) if PTY_BROKER else None
|
|
9844
10430
|
|
|
9845
10431
|
def _broker_surface_v2_snapshot(self, raw_session: str) -> dict | None:
|
|
9846
10432
|
found = self._broker_session_for(raw_session)
|
|
9847
10433
|
if not found:
|
|
9848
10434
|
return None
|
|
9849
10435
|
public_session_id, session = found
|
|
9850
|
-
if not hasattr(
|
|
10436
|
+
if PTY_BROKER is None or not hasattr(PTY_BROKER, "snapshot_v2"):
|
|
9851
10437
|
return None
|
|
9852
|
-
return
|
|
10438
|
+
return PTY_BROKER.snapshot_v2(_broker_session_id(session), public_session_id=public_session_id)
|
|
9853
10439
|
|
|
9854
10440
|
def _terminal_surface_tty(self, raw_session: str) -> tuple[str, str, str]:
|
|
9855
10441
|
provider, native_id = _parse_agent_session_ref(raw_session)
|
|
@@ -10152,10 +10738,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
10152
10738
|
"provider": provider,
|
|
10153
10739
|
"native_id": native_id,
|
|
10154
10740
|
"session_id": public_session_id,
|
|
10155
|
-
"broker_id": session
|
|
10156
|
-
"tty": session
|
|
10157
|
-
"tty_candidates": [session
|
|
10158
|
-
"pid": session
|
|
10741
|
+
"broker_id": _broker_session_id(session),
|
|
10742
|
+
"tty": _broker_slave_tty(session),
|
|
10743
|
+
"tty_candidates": [_broker_slave_tty(session)] if _broker_slave_tty(session) else [],
|
|
10744
|
+
"pid": _broker_pid(session),
|
|
10159
10745
|
}
|
|
10160
10746
|
|
|
10161
10747
|
if provider == "claude":
|
|
@@ -10529,7 +11115,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
10529
11115
|
2. **PG claude_uuid lookup**: for continuous-claude `s-…` ids, fetch
|
|
10530
11116
|
claude_uuid + project from PG and exact-match `<claude_uuid>.jsonl`
|
|
10531
11117
|
in the encoded project dir. This is essential when N sessions
|
|
10532
|
-
share a project (e.g. 4 terminals all in /Users/
|
|
11118
|
+
share a project (e.g. 4 terminals all in /Users/example) — without
|
|
10533
11119
|
it, all N collide on the most-recent-JSONL fallback.
|
|
10534
11120
|
3. Last-ditch: PG project lookup → most recent JSONL in dir. Only
|
|
10535
11121
|
fires when claude_uuid is unknown (pre-migration zombie sessions).
|
|
@@ -11717,6 +12303,109 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
11717
12303
|
return
|
|
11718
12304
|
self._send_json(result)
|
|
11719
12305
|
|
|
12306
|
+
def _handle_push_permission_allow(self, q):
|
|
12307
|
+
# "Allow" from the phone's Lock-Screen card: answer the waiting permission
|
|
12308
|
+
# dialog by injecting Enter into the broker PTY (the same key path
|
|
12309
|
+
# /terminal-control uses). Idempotent on request_nonce (duplicate Allow is a
|
|
12310
|
+
# no-op); deny lives in-app via "Open Session". NO timeout / NO auto-deny.
|
|
12311
|
+
try:
|
|
12312
|
+
payload = self._read_json_object()
|
|
12313
|
+
except Exception:
|
|
12314
|
+
self._send_json({"ok": False, "error": {"code": "bad_json", "message": "invalid JSON"}}, status=400)
|
|
12315
|
+
return
|
|
12316
|
+
request_nonce = str(payload.get("request_nonce") or "").strip()
|
|
12317
|
+
if not request_nonce:
|
|
12318
|
+
self._send_json({"ok": False, "error": {"code": "bad_request", "message": "request_nonce required"}}, status=400)
|
|
12319
|
+
return
|
|
12320
|
+
row = _pending_approval_get(request_nonce)
|
|
12321
|
+
if not row:
|
|
12322
|
+
self._send_json({"ok": False, "error": {"code": "not_found", "message": "unknown request_nonce"}}, status=404)
|
|
12323
|
+
return
|
|
12324
|
+
current_state = str(row.get("state") or "")
|
|
12325
|
+
if current_state == "allowed":
|
|
12326
|
+
self._send_json({
|
|
12327
|
+
"ok": False,
|
|
12328
|
+
"state": current_state,
|
|
12329
|
+
"error": {
|
|
12330
|
+
"code": "approval_in_progress",
|
|
12331
|
+
"message": "permission approval is already being injected",
|
|
12332
|
+
},
|
|
12333
|
+
}, status=409)
|
|
12334
|
+
return
|
|
12335
|
+
if current_state not in {"pending", "attention"}:
|
|
12336
|
+
# Already resolved (double-tap / re-delivery) — safe idempotent no-op.
|
|
12337
|
+
self._send_json({"ok": True, "state": str(row.get("state") or ""), "already_resolved": True})
|
|
12338
|
+
return
|
|
12339
|
+
if not _pending_approval_cas(request_nonce, current_state, "allowed"):
|
|
12340
|
+
latest = _pending_approval_get(request_nonce) or {}
|
|
12341
|
+
latest_state = str(latest.get("state") or "")
|
|
12342
|
+
if latest_state == "allowed":
|
|
12343
|
+
self._send_json({
|
|
12344
|
+
"ok": False,
|
|
12345
|
+
"state": latest_state,
|
|
12346
|
+
"error": {
|
|
12347
|
+
"code": "approval_in_progress",
|
|
12348
|
+
"message": "permission approval is already being injected",
|
|
12349
|
+
},
|
|
12350
|
+
}, status=409)
|
|
12351
|
+
else:
|
|
12352
|
+
self._send_json({"ok": True, "state": latest_state, "already_resolved": True})
|
|
12353
|
+
return
|
|
12354
|
+
provider = str(row.get("provider") or "claude")
|
|
12355
|
+
injected = {"ok": False, "reason": "no broker session"}
|
|
12356
|
+
# 1) Authoritative: the broker session id the hook captured from the broker's
|
|
12357
|
+
# own PAIRLING_BROKER_SESSION_ID env — no fragile tty reconciliation.
|
|
12358
|
+
broker_id = str(row.get("broker_id") or "")
|
|
12359
|
+
if broker_id and PTY_BROKER is not None:
|
|
12360
|
+
try:
|
|
12361
|
+
injected = PTY_BROKER.control(broker_id, {"type": "key", "key": "enter"})
|
|
12362
|
+
except Exception as e:
|
|
12363
|
+
injected = {"ok": False, "reason": f"{type(e).__name__}: {str(e)[:120]}"}
|
|
12364
|
+
# 2) Fallback: resolve via /terminal-control's resolver (registry -> broker).
|
|
12365
|
+
if not injected.get("ok"):
|
|
12366
|
+
native_id = str(row.get("native_id") or "")
|
|
12367
|
+
if not native_id:
|
|
12368
|
+
native_id, _b, _t = _approval_resolve_session(provider, str(row.get("session_id") or ""))
|
|
12369
|
+
if native_id and PTY_BROKER is not None:
|
|
12370
|
+
try:
|
|
12371
|
+
bid = self._terminal_control_target(_qualified_session_id(provider, native_id)).get("broker_id")
|
|
12372
|
+
if bid:
|
|
12373
|
+
injected = PTY_BROKER.control(bid, {"type": "key", "key": "enter"})
|
|
12374
|
+
except Exception:
|
|
12375
|
+
pass
|
|
12376
|
+
if not injected.get("ok"):
|
|
12377
|
+
_pending_approval_cas(request_nonce, "allowed", current_state)
|
|
12378
|
+
latest = _pending_approval_get(request_nonce) or {}
|
|
12379
|
+
self._send_json({
|
|
12380
|
+
"ok": False,
|
|
12381
|
+
"state": str(latest.get("state") or current_state),
|
|
12382
|
+
"broker_id": broker_id,
|
|
12383
|
+
"injected": injected,
|
|
12384
|
+
"error": {
|
|
12385
|
+
"code": "injection_failed",
|
|
12386
|
+
"message": "permission approval could not be delivered to the broker PTY",
|
|
12387
|
+
},
|
|
12388
|
+
}, status=409)
|
|
12389
|
+
return
|
|
12390
|
+
if not _pending_approval_cas(request_nonce, "allowed", "released"):
|
|
12391
|
+
latest = _pending_approval_get(request_nonce) or {}
|
|
12392
|
+
latest_state = str(latest.get("state") or "")
|
|
12393
|
+
if latest_state == "released":
|
|
12394
|
+
self._send_json({"ok": True, "state": "released", "broker_id": broker_id, "injected": injected})
|
|
12395
|
+
else:
|
|
12396
|
+
self._send_json({
|
|
12397
|
+
"ok": False,
|
|
12398
|
+
"state": latest_state or "allowed",
|
|
12399
|
+
"broker_id": broker_id,
|
|
12400
|
+
"injected": injected,
|
|
12401
|
+
"error": {
|
|
12402
|
+
"code": "release_state_failed",
|
|
12403
|
+
"message": "permission approval was injected but the state transition did not complete",
|
|
12404
|
+
},
|
|
12405
|
+
}, status=409)
|
|
12406
|
+
return
|
|
12407
|
+
self._send_json({"ok": True, "state": "released", "broker_id": broker_id, "injected": injected})
|
|
12408
|
+
|
|
11720
12409
|
def _handle_push_test(self, q):
|
|
11721
12410
|
if PUSH_DISPATCHER is None:
|
|
11722
12411
|
self._send_json({
|
|
@@ -13493,12 +14182,22 @@ Worker instructions:
|
|
|
13493
14182
|
broker_env = generated.get("env") if isinstance(generated.get("env"), dict) else None
|
|
13494
14183
|
command = _aperture_cli_command_for_context(launch_context, project) if _aperture_cli_command_for_context else ""
|
|
13495
14184
|
elif provider == "codex":
|
|
14185
|
+
# Inherit the user's OWN host posture (~/.codex/config.toml:
|
|
14186
|
+
# approval_policy + sandbox_mode). Pairling imposes NO permission flag
|
|
14187
|
+
# — we never twist the user's workspace to manufacture cards. The
|
|
14188
|
+
# approval card is opportunistic: it surfaces ONLY if the user's own
|
|
14189
|
+
# config prompts (codex approval detection is the Phase 5 screen-scrape,
|
|
14190
|
+
# which touches no config).
|
|
13496
14191
|
command = (
|
|
13497
|
-
f"exec codex
|
|
14192
|
+
f"exec codex "
|
|
13498
14193
|
f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)}"
|
|
13499
14194
|
)
|
|
13500
14195
|
else:
|
|
13501
|
-
|
|
14196
|
+
# Inherit the user's OWN host posture (~/.claude/settings.json). No
|
|
14197
|
+
# imposed permission flag. The PermissionRequest producer hook is
|
|
14198
|
+
# injected PER-SPAWN via --settings (phone sessions only) so the user's
|
|
14199
|
+
# global settings stay untouched; the hook is an observer, never a mode.
|
|
14200
|
+
command = f"exec claude --settings {shlex.quote(str(SPAWN_SETTINGS_PATH))}"
|
|
13502
14201
|
if not command:
|
|
13503
14202
|
self._send_json({"ok": False, "error": "Aperture CLI launch command unavailable"}, status=503)
|
|
13504
14203
|
return
|
|
@@ -13520,20 +14219,27 @@ Worker instructions:
|
|
|
13520
14219
|
command=command,
|
|
13521
14220
|
rows=30,
|
|
13522
14221
|
columns=120,
|
|
13523
|
-
|
|
14222
|
+
# Mark phone-spawned sessions so the global PermissionRequest hook
|
|
14223
|
+
# self-enables ONLY here (no-op for the user's own claude sessions).
|
|
14224
|
+
env={**(broker_env or {}), "PAIRLING_PHONE_SESSION": "1",
|
|
14225
|
+
"PAIRLING_BROKER_SESSION_ID": broker_session_id},
|
|
13524
14226
|
)
|
|
13525
14227
|
ok = True
|
|
13526
14228
|
except Exception as e:
|
|
13527
14229
|
reason = f"{type(e).__name__}: {e}"
|
|
13528
14230
|
|
|
13529
14231
|
if ok and session is not None:
|
|
13530
|
-
|
|
13531
|
-
|
|
13532
|
-
|
|
13533
|
-
|
|
13534
|
-
|
|
13535
|
-
|
|
13536
|
-
|
|
14232
|
+
session_tty = _broker_slave_tty(session)
|
|
14233
|
+
session_log = _broker_raw_log_path(session)
|
|
14234
|
+
session_pid = _broker_pid(session)
|
|
14235
|
+
if session_tty and session_log:
|
|
14236
|
+
_write_terminal_capture_mapping(
|
|
14237
|
+
session_tty,
|
|
14238
|
+
session_log,
|
|
14239
|
+
provider=provider,
|
|
14240
|
+
project=project,
|
|
14241
|
+
capture_id=capture_id,
|
|
14242
|
+
)
|
|
13537
14243
|
if launch_context is not None:
|
|
13538
14244
|
launch_meta = {
|
|
13539
14245
|
"spawned_by": "pairling",
|
|
@@ -13560,10 +14266,10 @@ Worker instructions:
|
|
|
13560
14266
|
provider,
|
|
13561
14267
|
native_id,
|
|
13562
14268
|
project,
|
|
13563
|
-
pid=
|
|
13564
|
-
terminal_tty=
|
|
14269
|
+
pid=session_pid,
|
|
14270
|
+
terminal_tty=session_tty,
|
|
13565
14271
|
metadata={
|
|
13566
|
-
"terminal_log": str(
|
|
14272
|
+
"terminal_log": str(session_log) if session_log else None,
|
|
13567
14273
|
"capture_backend": "pty_broker",
|
|
13568
14274
|
"capture_id": capture_id,
|
|
13569
14275
|
"broker_id": broker_session_id,
|
|
@@ -13583,9 +14289,9 @@ Worker instructions:
|
|
|
13583
14289
|
"project": project,
|
|
13584
14290
|
"provider": provider,
|
|
13585
14291
|
"native_id": native_id,
|
|
13586
|
-
"tty": session
|
|
13587
|
-
"pid": session
|
|
13588
|
-
"terminal_log": str(session
|
|
14292
|
+
"tty": _broker_slave_tty(session) if session else "",
|
|
14293
|
+
"pid": _broker_pid(session) if session else 0,
|
|
14294
|
+
"terminal_log": str(_broker_raw_log_path(session)) if session and _broker_raw_log_path(session) else None,
|
|
13589
14295
|
"capture_backend": "pty_broker",
|
|
13590
14296
|
"broker_id": broker_session_id,
|
|
13591
14297
|
"broker_socket": str(PTY_BROKER_SOCKET),
|
|
@@ -13614,9 +14320,9 @@ Worker instructions:
|
|
|
13614
14320
|
"provider": provider,
|
|
13615
14321
|
"native_id": native_id,
|
|
13616
14322
|
"session_id": broker_session_id,
|
|
13617
|
-
"tty": session
|
|
13618
|
-
"pid": session
|
|
13619
|
-
"terminal_log": str(session
|
|
14323
|
+
"tty": _broker_slave_tty(session),
|
|
14324
|
+
"pid": _broker_pid(session),
|
|
14325
|
+
"terminal_log": str(_broker_raw_log_path(session)) if _broker_raw_log_path(session) else None,
|
|
13620
14326
|
"capture_backend": "pty_broker",
|
|
13621
14327
|
"terminal_source": "broker_vt",
|
|
13622
14328
|
"broker_id": broker_session_id,
|
|
@@ -13634,6 +14340,98 @@ Worker instructions:
|
|
|
13634
14340
|
})
|
|
13635
14341
|
|
|
13636
14342
|
# ----- /spawn-session: open a new broker-owned agent CLI session -----
|
|
14343
|
+
def _handle_onestream_handoff(self, q):
|
|
14344
|
+
"""OneStream -> Pairling handoff ingestion (W1b).
|
|
14345
|
+
|
|
14346
|
+
POST: validate (fail-closed, mirroring the iOS PairlingHandoffReader),
|
|
14347
|
+
compose the steering draft, and store the handoff under
|
|
14348
|
+
HANDOFFS_DIR using the schemaVersion-1 record shape. Returns the
|
|
14349
|
+
composed draft so the caller can confirm what a session would
|
|
14350
|
+
ingest.
|
|
14351
|
+
GET: list pending (unconsumed) OneStream handoffs.
|
|
14352
|
+
|
|
14353
|
+
Auth: POST requires session:spawn, GET requires sessions:read (see
|
|
14354
|
+
_required_scopes_for_request). Additive route — does not spawn directly
|
|
14355
|
+
(OneStream has no Mac project path); a consumer composes/spawns later.
|
|
14356
|
+
"""
|
|
14357
|
+
if self.command == "GET":
|
|
14358
|
+
items = []
|
|
14359
|
+
try:
|
|
14360
|
+
paths = sorted(HANDOFFS_DIR.glob("onestream-*.json"))
|
|
14361
|
+
except OSError:
|
|
14362
|
+
paths = []
|
|
14363
|
+
for p in paths:
|
|
14364
|
+
try:
|
|
14365
|
+
rec = json.loads(p.read_text())
|
|
14366
|
+
except (OSError, json.JSONDecodeError):
|
|
14367
|
+
continue
|
|
14368
|
+
if not isinstance(rec, dict) or rec.get("consumed"):
|
|
14369
|
+
continue
|
|
14370
|
+
items.append({
|
|
14371
|
+
"handoff_id": rec.get("handoff_id") or p.stem,
|
|
14372
|
+
"source": rec.get("source") or "OneStream",
|
|
14373
|
+
"generatedAt": rec.get("generatedAt"),
|
|
14374
|
+
"suggestedPrompt": rec.get("suggestedPrompt") or "",
|
|
14375
|
+
"transcriptText": rec.get("transcriptText") or "",
|
|
14376
|
+
"composeDraft": rec.get("composeDraft") or "",
|
|
14377
|
+
"workflowHint": rec.get("workflowHint"),
|
|
14378
|
+
})
|
|
14379
|
+
self._send_json({"ok": True, "handoffs": items})
|
|
14380
|
+
return
|
|
14381
|
+
|
|
14382
|
+
# POST — store a new handoff.
|
|
14383
|
+
try:
|
|
14384
|
+
payload = json.loads(self._read_body() or b"{}")
|
|
14385
|
+
except json.JSONDecodeError:
|
|
14386
|
+
self.send_error(400, "body must be JSON")
|
|
14387
|
+
return
|
|
14388
|
+
if not isinstance(payload, dict):
|
|
14389
|
+
self.send_error(400, "body must be JSON object")
|
|
14390
|
+
return
|
|
14391
|
+
|
|
14392
|
+
# Fail-closed validation — mirror iOS PairlingHandoffReader.decode().
|
|
14393
|
+
schema_version = payload.get("schemaVersion")
|
|
14394
|
+
if schema_version != 1:
|
|
14395
|
+
self._send_json(
|
|
14396
|
+
{"ok": False, "error": f"unsupported schemaVersion {schema_version!r} (expected 1)"},
|
|
14397
|
+
status=400,
|
|
14398
|
+
)
|
|
14399
|
+
return
|
|
14400
|
+
transcript_text = str(payload.get("transcriptText") or "").strip()
|
|
14401
|
+
if not transcript_text:
|
|
14402
|
+
self._send_json({"ok": False, "error": "handoff contains no transcript"}, status=400)
|
|
14403
|
+
return
|
|
14404
|
+
|
|
14405
|
+
suggested_prompt = str(payload.get("suggestedPrompt") or "").strip()
|
|
14406
|
+
# composeDraft mirrors PairlingHandoffReader.composeDraft(from:).
|
|
14407
|
+
compose_draft = (
|
|
14408
|
+
f"{suggested_prompt}\n\n---\n{transcript_text}" if suggested_prompt else transcript_text
|
|
14409
|
+
)
|
|
14410
|
+
|
|
14411
|
+
handoff_id = "onestream-" + secrets.token_hex(6)
|
|
14412
|
+
record = {
|
|
14413
|
+
"schemaVersion": 1,
|
|
14414
|
+
"handoff_id": handoff_id,
|
|
14415
|
+
"source": str(payload.get("source") or "OneStream")[:64],
|
|
14416
|
+
"generatedAt": payload.get("generatedAt"),
|
|
14417
|
+
"receivedAt": _time.time(),
|
|
14418
|
+
"workflowHint": payload.get("workflowHint"),
|
|
14419
|
+
"suggestedPrompt": suggested_prompt,
|
|
14420
|
+
"transcriptText": transcript_text,
|
|
14421
|
+
"segments": payload.get("segments") if isinstance(payload.get("segments"), list) else [],
|
|
14422
|
+
"composeDraft": compose_draft,
|
|
14423
|
+
"consumed": False,
|
|
14424
|
+
}
|
|
14425
|
+
try:
|
|
14426
|
+
(HANDOFFS_DIR / f"{handoff_id}.json").write_text(
|
|
14427
|
+
json.dumps(record, indent=2, sort_keys=True)
|
|
14428
|
+
)
|
|
14429
|
+
except OSError as exc:
|
|
14430
|
+
self._send_json({"ok": False, "error": f"could not store handoff: {exc}"}, status=500)
|
|
14431
|
+
return
|
|
14432
|
+
|
|
14433
|
+
self._send_json({"ok": True, "handoff_id": handoff_id, "composeDraft": compose_draft})
|
|
14434
|
+
|
|
13637
14435
|
def _handle_spawn_session(self, q):
|
|
13638
14436
|
"""Spawn a new Claude/Codex session. Pairling-owned PTYs are the
|
|
13639
14437
|
default path; Terminal.app can attach as a client via `pairling attach`.
|
|
@@ -13767,20 +14565,20 @@ Worker instructions:
|
|
|
13767
14565
|
capture_log_path = TERMINAL_CAPTURE_DIR / f"codex-{capture_id}.log"
|
|
13768
14566
|
inner = (
|
|
13769
14567
|
f"cd {shlex.quote(project)} && "
|
|
13770
|
-
f"exec codex
|
|
14568
|
+
f"exec codex "
|
|
13771
14569
|
f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)}"
|
|
13772
14570
|
)
|
|
13773
14571
|
shell_cmd = _terminal_script_command(capture_log_path, inner, interactive_shell=True)
|
|
13774
14572
|
else:
|
|
13775
14573
|
capture_id = secrets.token_hex(12)
|
|
13776
14574
|
capture_log_path = TERMINAL_CAPTURE_DIR / f"claude-{capture_id}.log"
|
|
13777
|
-
inner = f"cd {shlex.quote(project)} && claude
|
|
14575
|
+
inner = f"cd {shlex.quote(project)} && claude"
|
|
13778
14576
|
shell_cmd = _terminal_script_command(capture_log_path, inner)
|
|
13779
14577
|
as_escaped_cmd = _as_escape(shell_cmd)
|
|
13780
14578
|
|
|
13781
14579
|
# Set Terminal's custom title to the project basename so /inject-now's
|
|
13782
14580
|
# window-title matcher can find this window later. Terminal's auto-
|
|
13783
|
-
# title shows running command + cwd (e.g. "
|
|
14581
|
+
# title shows running command + cwd (e.g. "project — claude") which
|
|
13784
14582
|
# rarely contains the project name; explicit custom title fixes that.
|
|
13785
14583
|
basename = os.path.basename(project.rstrip("/")) or provider
|
|
13786
14584
|
title = f"{provider}:{basename}" if provider == "codex" else basename
|
|
@@ -13942,12 +14740,12 @@ Worker instructions:
|
|
|
13942
14740
|
if provider == "codex":
|
|
13943
14741
|
shell_cmd = (
|
|
13944
14742
|
f"cd '{shell_safe_path}' && "
|
|
13945
|
-
f"codex
|
|
14743
|
+
f"codex -C '{shell_safe_path}' --add-dir '{shell_safe_path}' \"$(cat '{shell_safe_prompt}')\""
|
|
13946
14744
|
)
|
|
13947
14745
|
else:
|
|
13948
14746
|
shell_cmd = (
|
|
13949
14747
|
f"cd '{shell_safe_path}' && "
|
|
13950
|
-
f"claude
|
|
14748
|
+
f"claude \"$(cat '{shell_safe_prompt}')\""
|
|
13951
14749
|
)
|
|
13952
14750
|
script = f'''
|
|
13953
14751
|
tell application "Terminal"
|
|
@@ -14079,19 +14877,19 @@ Worker instructions:
|
|
|
14079
14877
|
"native_id": native_id,
|
|
14080
14878
|
"session_id": broker_session_id,
|
|
14081
14879
|
"project": project,
|
|
14082
|
-
"tty": existing
|
|
14083
|
-
"pid": existing
|
|
14084
|
-
"terminal_log": str(existing
|
|
14880
|
+
"tty": _broker_slave_tty(existing),
|
|
14881
|
+
"pid": _broker_pid(existing),
|
|
14882
|
+
"terminal_log": str(_broker_raw_log_path(existing)) if _broker_raw_log_path(existing) else None,
|
|
14085
14883
|
"capture_backend": "pty_broker",
|
|
14086
14884
|
"terminal_source": "broker_vt",
|
|
14087
|
-
"broker_id": existing
|
|
14885
|
+
"broker_id": _broker_session_id(existing),
|
|
14088
14886
|
"broker_socket": str(PTY_BROKER_SOCKET),
|
|
14089
14887
|
"attach_command": f"pairling attach {broker_session_id}",
|
|
14090
14888
|
})
|
|
14091
14889
|
return
|
|
14092
14890
|
|
|
14093
14891
|
command = (
|
|
14094
|
-
f"exec codex resume
|
|
14892
|
+
f"exec codex resume "
|
|
14095
14893
|
f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)} "
|
|
14096
14894
|
f"{shlex.quote(native_id)}"
|
|
14097
14895
|
)
|
|
@@ -14109,6 +14907,8 @@ Worker instructions:
|
|
|
14109
14907
|
command=command,
|
|
14110
14908
|
rows=30,
|
|
14111
14909
|
columns=120,
|
|
14910
|
+
env={"PAIRLING_PHONE_SESSION": "1",
|
|
14911
|
+
"PAIRLING_BROKER_SESSION_ID": broker_session_id},
|
|
14112
14912
|
)
|
|
14113
14913
|
ok = True
|
|
14114
14914
|
except Exception as e:
|
|
@@ -14116,23 +14916,27 @@ Worker instructions:
|
|
|
14116
14916
|
|
|
14117
14917
|
if ok and session is not None:
|
|
14118
14918
|
capture_id = secrets.token_hex(12)
|
|
14119
|
-
|
|
14120
|
-
|
|
14121
|
-
|
|
14122
|
-
|
|
14123
|
-
|
|
14124
|
-
|
|
14125
|
-
|
|
14919
|
+
session_tty = _broker_slave_tty(session)
|
|
14920
|
+
session_log = _broker_raw_log_path(session)
|
|
14921
|
+
session_pid = _broker_pid(session)
|
|
14922
|
+
if session_tty and session_log:
|
|
14923
|
+
_write_terminal_capture_mapping(
|
|
14924
|
+
session_tty,
|
|
14925
|
+
session_log,
|
|
14926
|
+
provider=provider,
|
|
14927
|
+
project=project,
|
|
14928
|
+
capture_id=capture_id,
|
|
14929
|
+
)
|
|
14126
14930
|
_agent_registry_upsert(
|
|
14127
14931
|
"codex",
|
|
14128
14932
|
native_id,
|
|
14129
14933
|
project,
|
|
14130
|
-
pid=
|
|
14131
|
-
terminal_tty=
|
|
14934
|
+
pid=session_pid,
|
|
14935
|
+
terminal_tty=session_tty,
|
|
14132
14936
|
metadata={
|
|
14133
14937
|
"spawned_by": "phone-companion",
|
|
14134
14938
|
"resume_target": native_id,
|
|
14135
|
-
"terminal_log": str(
|
|
14939
|
+
"terminal_log": str(session_log) if session_log else None,
|
|
14136
14940
|
"capture_backend": "pty_broker",
|
|
14137
14941
|
"capture_id": capture_id,
|
|
14138
14942
|
"terminal_source": "broker_vt",
|
|
@@ -14151,9 +14955,9 @@ Worker instructions:
|
|
|
14151
14955
|
"provider": provider,
|
|
14152
14956
|
"project": project,
|
|
14153
14957
|
"native_id": native_id,
|
|
14154
|
-
"tty": session
|
|
14155
|
-
"pid": session
|
|
14156
|
-
"terminal_log": str(session
|
|
14958
|
+
"tty": _broker_slave_tty(session) if session else "",
|
|
14959
|
+
"pid": _broker_pid(session) if session else 0,
|
|
14960
|
+
"terminal_log": str(_broker_raw_log_path(session)) if session and _broker_raw_log_path(session) else None,
|
|
14157
14961
|
"capture_backend": "pty_broker",
|
|
14158
14962
|
"terminal_source": "broker_vt",
|
|
14159
14963
|
"broker_id": broker_session_id,
|
|
@@ -14175,9 +14979,9 @@ Worker instructions:
|
|
|
14175
14979
|
"native_id": native_id,
|
|
14176
14980
|
"session_id": broker_session_id,
|
|
14177
14981
|
"project": project,
|
|
14178
|
-
"tty": session
|
|
14179
|
-
"pid": session
|
|
14180
|
-
"terminal_log": str(session
|
|
14982
|
+
"tty": _broker_slave_tty(session),
|
|
14983
|
+
"pid": _broker_pid(session),
|
|
14984
|
+
"terminal_log": str(_broker_raw_log_path(session)) if _broker_raw_log_path(session) else None,
|
|
14181
14985
|
"capture_backend": "pty_broker",
|
|
14182
14986
|
"terminal_source": "broker_vt",
|
|
14183
14987
|
"broker_id": broker_session_id,
|
|
@@ -14248,7 +15052,7 @@ Worker instructions:
|
|
|
14248
15052
|
capture_log_path = TERMINAL_CAPTURE_DIR / f"codex-{capture_id}.log"
|
|
14249
15053
|
inner = (
|
|
14250
15054
|
f"cd {shlex.quote(project)} && "
|
|
14251
|
-
f"exec codex resume
|
|
15055
|
+
f"exec codex resume "
|
|
14252
15056
|
f"-C {shlex.quote(project)} --add-dir {shlex.quote(project)} "
|
|
14253
15057
|
f"{shlex.quote(native_id)}"
|
|
14254
15058
|
)
|
|
@@ -14383,8 +15187,8 @@ Worker instructions:
|
|
|
14383
15187
|
_agent_registry_update_control(
|
|
14384
15188
|
"codex",
|
|
14385
15189
|
native_id,
|
|
14386
|
-
pid=session
|
|
14387
|
-
terminal_tty=session
|
|
15190
|
+
pid=_broker_pid(session),
|
|
15191
|
+
terminal_tty=_broker_slave_tty(session),
|
|
14388
15192
|
state="running",
|
|
14389
15193
|
reopen=True,
|
|
14390
15194
|
)
|
|
@@ -14399,16 +15203,16 @@ Worker instructions:
|
|
|
14399
15203
|
state="applied" if result.get("ok") else "failed",
|
|
14400
15204
|
phases=_receipt_phases(validated=True, applied=bool(result.get("ok")), pty_written=bool(result.get("ok"))),
|
|
14401
15205
|
backend="pty_broker",
|
|
14402
|
-
tty=session
|
|
14403
|
-
pid=session
|
|
15206
|
+
tty=_broker_slave_tty(session),
|
|
15207
|
+
pid=_broker_pid(session),
|
|
14404
15208
|
source_offset_after=source_offset_after,
|
|
14405
15209
|
)
|
|
14406
15210
|
_store_action_receipt(device_id, receipt_session_id, client_action_id, body_hash, receipt, action_kind="send_text", audit_action={"type": "send_text", "chars": len(text)})
|
|
14407
15211
|
body = json.dumps({
|
|
14408
15212
|
"ok": bool(result.get("ok")),
|
|
14409
|
-
"tty": session
|
|
14410
|
-
"pid": session
|
|
14411
|
-
"broker_id": session
|
|
15213
|
+
"tty": _broker_slave_tty(session),
|
|
15214
|
+
"pid": _broker_pid(session),
|
|
15215
|
+
"broker_id": _broker_session_id(session),
|
|
14412
15216
|
"reason": result.get("reason"),
|
|
14413
15217
|
"receipt": receipt,
|
|
14414
15218
|
}).encode()
|
|
@@ -14689,16 +15493,16 @@ Worker instructions:
|
|
|
14689
15493
|
state="applied" if result.get("ok") else "failed",
|
|
14690
15494
|
phases=_receipt_phases(validated=True, applied=bool(result.get("ok")), pty_written=bool(result.get("ok"))),
|
|
14691
15495
|
backend="pty_broker",
|
|
14692
|
-
tty=broker_session
|
|
14693
|
-
pid=broker_session
|
|
15496
|
+
tty=_broker_slave_tty(broker_session),
|
|
15497
|
+
pid=_broker_pid(broker_session),
|
|
14694
15498
|
source_offset_after=source_offset_after,
|
|
14695
15499
|
)
|
|
14696
15500
|
_store_action_receipt(receipt_context["device_id"], receipt_session_id, receipt_context["client_action_id"], receipt_context["body_hash"], receipt, action_kind="send_text", audit_action={"type": "send_text", "chars": len(text)})
|
|
14697
15501
|
body = json.dumps({
|
|
14698
15502
|
"ok": bool(result.get("ok")),
|
|
14699
|
-
"tty": broker_session
|
|
14700
|
-
"pid": broker_session
|
|
14701
|
-
"broker_id": broker_session
|
|
15503
|
+
"tty": _broker_slave_tty(broker_session),
|
|
15504
|
+
"pid": _broker_pid(broker_session),
|
|
15505
|
+
"broker_id": _broker_session_id(broker_session),
|
|
14702
15506
|
"reason": result.get("reason"),
|
|
14703
15507
|
"receipt": receipt,
|
|
14704
15508
|
}).encode()
|
|
@@ -14918,10 +15722,11 @@ Worker instructions:
|
|
|
14918
15722
|
broker_found = self._broker_session_for(_qualified_session_id("codex", native_id))
|
|
14919
15723
|
if broker_found and PTY_BROKER:
|
|
14920
15724
|
_, broker_session = broker_found
|
|
15725
|
+
broker_id = _broker_session_id(broker_session)
|
|
14921
15726
|
if sig == signal.SIGINT:
|
|
14922
|
-
result = PTY_BROKER.control(
|
|
15727
|
+
result = PTY_BROKER.control(broker_id, {"type": "key", "key": "ctrl_c"})
|
|
14923
15728
|
else:
|
|
14924
|
-
result = PTY_BROKER.terminate(
|
|
15729
|
+
result = PTY_BROKER.terminate(broker_id, sig)
|
|
14925
15730
|
ok = bool(result.get("ok"))
|
|
14926
15731
|
if ok:
|
|
14927
15732
|
_write_agent_turn_state("codex", native_id, "idle", event=sig_name.lower())
|
|
@@ -14929,10 +15734,10 @@ Worker instructions:
|
|
|
14929
15734
|
_agent_registry_mark_closed("codex", native_id)
|
|
14930
15735
|
send_signal_result(
|
|
14931
15736
|
ok,
|
|
14932
|
-
result.get("pid") or broker_session
|
|
15737
|
+
result.get("pid") or _broker_pid(broker_session),
|
|
14933
15738
|
result.get("error") or result.get("reason"),
|
|
14934
15739
|
200 if ok else int(result.get("status") or 502),
|
|
14935
|
-
broker_id=
|
|
15740
|
+
broker_id=broker_id,
|
|
14936
15741
|
)
|
|
14937
15742
|
return
|
|
14938
15743
|
|
|
@@ -14979,19 +15784,20 @@ Worker instructions:
|
|
|
14979
15784
|
broker_found = self._broker_session_for(_qualified_session_id("claude", session_id))
|
|
14980
15785
|
if broker_found and PTY_BROKER:
|
|
14981
15786
|
_, broker_session = broker_found
|
|
15787
|
+
broker_id = _broker_session_id(broker_session)
|
|
14982
15788
|
if sig == signal.SIGINT:
|
|
14983
|
-
result = PTY_BROKER.control(
|
|
15789
|
+
result = PTY_BROKER.control(broker_id, {"type": "key", "key": "ctrl_c"})
|
|
14984
15790
|
else:
|
|
14985
|
-
result = PTY_BROKER.terminate(
|
|
15791
|
+
result = PTY_BROKER.terminate(broker_id, sig)
|
|
14986
15792
|
ok = bool(result.get("ok"))
|
|
14987
15793
|
if ok and sig == signal.SIGTERM:
|
|
14988
15794
|
self._mark_session_closed(session_id)
|
|
14989
15795
|
send_signal_result(
|
|
14990
15796
|
ok,
|
|
14991
|
-
result.get("pid") or broker_session
|
|
15797
|
+
result.get("pid") or _broker_pid(broker_session),
|
|
14992
15798
|
result.get("error") or result.get("reason"),
|
|
14993
15799
|
200 if ok else int(result.get("status") or 502),
|
|
14994
|
-
broker_id=
|
|
15800
|
+
broker_id=broker_id,
|
|
14995
15801
|
)
|
|
14996
15802
|
return
|
|
14997
15803
|
|
|
@@ -17663,9 +18469,9 @@ Worker instructions:
|
|
|
17663
18469
|
params. Save to ~/Pairling/uploads/<bucket>/<8-hex>-<name>
|
|
17664
18470
|
where <bucket> is derived from the session's project path:
|
|
17665
18471
|
|
|
17666
|
-
- regular project (e.g. /Users/
|
|
18472
|
+
- regular project (e.g. /Users/example/projects/proofforge)
|
|
17667
18473
|
→ bucket = "proofforge"
|
|
17668
|
-
- sentinel session (e.g. /Users/
|
|
18474
|
+
- sentinel session (e.g. /Users/example/.claude/state/sentinel/projects/proofforge-079c4a/terminals/orange_team)
|
|
17669
18475
|
→ bucket = "proofforge" (regex strips -<6hex> suffix)
|
|
17670
18476
|
- fallback when session unknown
|
|
17671
18477
|
→ bucket = "misc"
|
|
@@ -18420,15 +19226,82 @@ def _maybe_backfill_claude_registry_from_pg() -> None:
|
|
|
18420
19226
|
print(f"[registry-backfill] skipped: {type(exc).__name__}", file=sys.stderr, flush=True)
|
|
18421
19227
|
|
|
18422
19228
|
|
|
19229
|
+
def _reconcile_broker_sessions_on_boot() -> None:
|
|
19230
|
+
if PTY_BROKER is None:
|
|
19231
|
+
return
|
|
19232
|
+
survivors: dict[str, dict] = {}
|
|
19233
|
+
deadline = _time.time() + 10
|
|
19234
|
+
last_error = ""
|
|
19235
|
+
while _time.time() < deadline:
|
|
19236
|
+
try:
|
|
19237
|
+
survivors = {
|
|
19238
|
+
str(item.get("session_id") or ""): item
|
|
19239
|
+
for item in PTY_BROKER.list_sessions()
|
|
19240
|
+
if isinstance(item, dict) and item.get("session_id")
|
|
19241
|
+
}
|
|
19242
|
+
break
|
|
19243
|
+
except Exception as exc:
|
|
19244
|
+
last_error = f"{type(exc).__name__}: {str(exc)[:120]}"
|
|
19245
|
+
_time.sleep(0.25)
|
|
19246
|
+
if last_error and not survivors:
|
|
19247
|
+
print(f"[broker-reconcile] deferred: {last_error}", file=sys.stderr, flush=True)
|
|
19248
|
+
return
|
|
19249
|
+
|
|
19250
|
+
for provider in ("claude", "codex"):
|
|
19251
|
+
for row in _agent_registry_live(provider):
|
|
19252
|
+
metadata = _registry_metadata_from_row(row)
|
|
19253
|
+
broker_id = str(metadata.get("broker_id") or "").strip()
|
|
19254
|
+
native_id = str(row.get("native_id") or "").strip()
|
|
19255
|
+
if not broker_id or not native_id:
|
|
19256
|
+
continue
|
|
19257
|
+
desc = survivors.get(broker_id)
|
|
19258
|
+
if desc is None:
|
|
19259
|
+
_agent_registry_mark_closed(provider, native_id)
|
|
19260
|
+
continue
|
|
19261
|
+
_agent_registry_update_control(
|
|
19262
|
+
provider,
|
|
19263
|
+
native_id,
|
|
19264
|
+
pid=_broker_pid(desc),
|
|
19265
|
+
terminal_tty=_broker_slave_tty(desc),
|
|
19266
|
+
state="running",
|
|
19267
|
+
reopen=True,
|
|
19268
|
+
)
|
|
19269
|
+
|
|
19270
|
+
for approval in _pending_approvals_open():
|
|
19271
|
+
broker_id = str(approval.get("broker_id") or "").strip()
|
|
19272
|
+
request_nonce = str(approval.get("request_nonce") or "").strip()
|
|
19273
|
+
provider = str(approval.get("provider") or "").strip() or "claude"
|
|
19274
|
+
native_id = str(approval.get("native_id") or "").strip()
|
|
19275
|
+
if not native_id:
|
|
19276
|
+
_native, _broker, _tty = _approval_resolve_session(provider, str(approval.get("session_id") or ""))
|
|
19277
|
+
native_id = _native
|
|
19278
|
+
if broker_id and broker_id in survivors:
|
|
19279
|
+
if native_id:
|
|
19280
|
+
_write_agent_turn_state(
|
|
19281
|
+
provider,
|
|
19282
|
+
native_id,
|
|
19283
|
+
"attention",
|
|
19284
|
+
tool=str(approval.get("command_preview") or approval.get("tool_name") or "")[:80],
|
|
19285
|
+
event="broker_reconcile",
|
|
19286
|
+
request_nonce=request_nonce,
|
|
19287
|
+
mac_install_id=getattr(PAIRING_STORE, "install_id", "") if PAIRING_STORE else "",
|
|
19288
|
+
)
|
|
19289
|
+
continue
|
|
19290
|
+
if request_nonce:
|
|
19291
|
+
_pending_approval_resolve_terminal(request_nonce, "session_gone")
|
|
19292
|
+
|
|
19293
|
+
|
|
18423
19294
|
if __name__ == "__main__":
|
|
18424
19295
|
host = _bind_host()
|
|
18425
19296
|
BOUND_HOST = host
|
|
18426
|
-
os.environ["
|
|
19297
|
+
os.environ["PAIRLING_BOUND_HOST"] = host
|
|
18427
19298
|
_maybe_backfill_claude_registry_from_pg()
|
|
19299
|
+
_reconcile_broker_sessions_on_boot()
|
|
18428
19300
|
LIVE_ACTIVITY_PUBLISHER = _start_live_activity_publisher()
|
|
18429
19301
|
STANDARD_TURN_PUSH_PUBLISHER = _start_standard_turn_push_publisher()
|
|
18430
19302
|
MAC_HEALTH_PUSH_PUBLISHER = _start_mac_health_push_publisher()
|
|
18431
19303
|
SENTINEL_PUSH_PUBLISHER = _start_sentinel_push_publisher()
|
|
19304
|
+
_start_codex_approval_scanner()
|
|
18432
19305
|
server = _PairlingThreadingHTTPServer((host, PORT), Handler)
|
|
18433
19306
|
server.daemon_threads = True
|
|
18434
19307
|
print(f"pairlingd listening on {host}:{PORT}", file=sys.stderr, flush=True)
|