pairling 0.2.11 → 0.2.12

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.
@@ -6,7 +6,6 @@ from __future__ import annotations
6
6
  import os
7
7
 
8
8
  SCHEMA_VERSION = 1
9
- POWER_STATE_SCHEMA_VERSION = 1
10
9
  RUNTIME_NAME = "pairling-mac-runtime"
11
10
  CONTRACT_VERSION = "pairling-runtime-v1"
12
11
  PAIRLING_CONTRACT_VERSION = CONTRACT_VERSION
@@ -14,9 +13,7 @@ COMPAT_MODE = "pairling-v1"
14
13
  PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
15
14
  LEGACY_PORT = 7723
16
15
  DAEMON_LABEL = "dev.pairling.companiond"
17
- GUARDIAN_LABEL = "dev.pairling.power-guardian"
18
16
  LEGACY_TOKEN_RELATIVE_PATH = ".claude/scripts/.notify-token"
19
- POWER_STATE_PATH = "/var/run/pairling-power-state.json"
20
17
  TAILSCALE_VARIANT = "standalone"
21
18
  AUTH_MODE = "scoped-device-bearer"
22
19
  PAIR_SERVICE_TYPE = "_pairling-pair._tcp"
@@ -71,7 +71,7 @@ def build_runtime_info(
71
71
  source_branch = os.environ.get("COMPANION_SOURCE_BRANCH", "unknown")
72
72
  source_dirty = None
73
73
  installed_at = os.environ.get("COMPANION_INSTALLED_AT")
74
- install_root = str(script.parent.parent) if script.parent.name in {"companiond", "guardian"} else str(script.parent)
74
+ install_root = str(script.parent.parent) if script.parent.name == "companiond" else str(script.parent)
75
75
  source_hash = None
76
76
  verified = False
77
77
  verification_error = manifest_error
@@ -122,6 +122,7 @@ def public_runtime_info(info: dict[str, Any]) -> dict[str, Any]:
122
122
  return {
123
123
  "name": info.get("name") or RUNTIME_NAME,
124
124
  "runtime_version": info.get("runtime_version"),
125
+ "source_revision": info.get("source_revision"),
125
126
  "contract_version": info.get("contract_version") or CONTRACT_VERSION,
126
127
  "compat_mode": info.get("compat_mode") or COMPAT_MODE,
127
128
  "launchd_label": info.get("launchd_label") or DAEMON_LABEL,
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
  import os
7
7
  from pathlib import Path
8
8
 
9
- from runtime_contract import LEGACY_TOKEN_RELATIVE_PATH, POWER_STATE_PATH
9
+ from runtime_contract import LEGACY_TOKEN_RELATIVE_PATH
10
10
 
11
11
 
12
12
  def home() -> Path:
@@ -69,10 +69,6 @@ def token_path() -> Path:
69
69
  return Path(os.environ.get("NOTIFY_TOKEN_FILE", str(home() / LEGACY_TOKEN_RELATIVE_PATH)))
70
70
 
71
71
 
72
- def guardian_state_path() -> Path:
73
- return Path(os.environ.get("COMPANION_POWER_STATE_PATH", POWER_STATE_PATH))
74
-
75
-
76
72
  def legacy_scripts_root() -> Path:
77
73
  return home() / ".claude" / "scripts"
78
74
 
@@ -80,7 +76,7 @@ def legacy_scripts_root() -> Path:
80
76
  def release_root_for(script_path: str | Path) -> Path | None:
81
77
  path = Path(script_path).resolve()
82
78
  parent = path.parent
83
- if parent.name in {"companiond", "guardian"}:
79
+ if parent.name == "companiond":
84
80
  root = parent.parent
85
81
  if (root / "manifest.json").is_file():
86
82
  return root
@@ -26,6 +26,10 @@ SAFETY_EVENTS_MAX_READ_BYTES = max(
26
26
  64 * 1024,
27
27
  int(os.environ.get("PAIRLING_SAFETY_EVENTS_MAX_READ_BYTES", str(1024 * 1024))),
28
28
  )
29
+ SAFETY_EVENTS_DISK_WARNING_BYTES = max(
30
+ 1024 * 1024,
31
+ int(os.environ.get("PAIRLING_SAFETY_EVENTS_DISK_WARNING_BYTES", str(256 * 1024 * 1024))),
32
+ )
29
33
  DEFAULT_STATUS = {
30
34
  "contract_version": SAFETY_BRIDGE_CONTRACT_VERSION,
31
35
  "mode": "absent",
@@ -152,11 +156,28 @@ class SafetyMonitorBridge:
152
156
  status["contract_version"] = SAFETY_CONTRACT_VERSION
153
157
  if status_error:
154
158
  status["status_error"] = status_error
155
- status["status_stale"] = self._status_stale(status, loaded is not None)
159
+ live_artifact = self._live_artifact_status()
160
+ if live_artifact:
161
+ status["live_artifact"] = live_artifact
162
+ if live_artifact.get("source") == "event_log":
163
+ status["mode"] = "system_extension"
164
+ status["installed"] = True
165
+ status["approved"] = True
166
+ status["running"] = True
167
+ if status.get("visibility") == "unavailable":
168
+ status["visibility"] = "limited"
169
+ status["updated_at"] = max(
170
+ float(status.get("updated_at") or 0),
171
+ float(live_artifact.get("events_log_mtime") or 0),
172
+ )
173
+ if status.get("summary") == DEFAULT_STATUS["summary"]:
174
+ status["summary"] = "Pairling Safety Monitor has live event-log evidence."
175
+ status["status_stale"] = self._status_stale(status, loaded is not None or bool(live_artifact))
156
176
  status["secure_mode_state"] = self._secure_mode_state(status)
157
177
  status["guarded_mode_state"] = "guarded_deferred"
158
178
  status["system_extension_status"] = self._system_extension_status(status)
159
179
  status["capabilities"] = self._capabilities(status)
180
+ status["disk_usage_warning"] = self._disk_usage_warning(live_artifact)
160
181
  status["evidence_test"] = self._read_evidence_test()
161
182
  events = self.events(limit=200)
162
183
  status["event_count"] = len(events)
@@ -362,6 +383,40 @@ class SafetyMonitorBridge:
362
383
  return None, "status_unreadable"
363
384
  return (payload, None) if isinstance(payload, dict) else (None, "status_shape_invalid")
364
385
 
386
+ def _live_artifact_status(self) -> dict[str, Any] | None:
387
+ candidates: list[dict[str, Any]] = []
388
+ for path in self._event_paths():
389
+ try:
390
+ stat = path.stat()
391
+ except OSError:
392
+ continue
393
+ if not path.is_file() or stat.st_size <= 0:
394
+ continue
395
+ candidates.append({
396
+ "source": "event_log",
397
+ "events_log_path": _redact_path(str(path), self.home),
398
+ "events_log_bytes": int(stat.st_size),
399
+ "events_log_mtime": float(stat.st_mtime),
400
+ "path_scope": "system" if path == self.system_events_path else "user",
401
+ })
402
+ if not candidates:
403
+ return None
404
+ candidates.sort(key=lambda item: (float(item["events_log_mtime"]), int(item["events_log_bytes"])), reverse=True)
405
+ return candidates[0]
406
+
407
+ def _disk_usage_warning(self, live_artifact: dict[str, Any] | None) -> dict[str, Any] | None:
408
+ if not live_artifact:
409
+ return None
410
+ size = int(live_artifact.get("events_log_bytes") or 0)
411
+ if size < SAFETY_EVENTS_DISK_WARNING_BYTES:
412
+ return None
413
+ return {
414
+ "code": "safety_events_log_large",
415
+ "events_log_bytes": size,
416
+ "threshold_bytes": SAFETY_EVENTS_DISK_WARNING_BYTES,
417
+ "message": "Safety Monitor event log is large and should be rotated or rebuilt with the bounded writer.",
418
+ }
419
+
365
420
  def _normalize_event(self, raw: dict[str, Any]) -> dict[str, Any]:
366
421
  path_display = _redact_path(
367
422
  raw.get("path") or raw.get("path_display") or raw.get("redacted_path"),
@@ -20,6 +20,7 @@ const prePairMaxBodyBytes int64 = 16 * 1024
20
20
  const pairDropSmallFileMaxBodyBytes int64 = 10 * 1024 * 1024
21
21
  const pairDropUploadChunkMaxBodyBytes int64 = 1024 * 1024
22
22
  const peerNodeHeader = "X-Pairling-Peer-Node"
23
+ const internalTokenHeader = "X-Pairling-Internal-Token"
23
24
 
24
25
  // peerProvenanceHeader tells pairlingd how connectd identified the peer node:
25
26
  // "tagged" (the old minted tag:pairling-phone path) or "interactive" (an
@@ -233,6 +234,7 @@ func (h *Handler) rewrite(r *httputil.ProxyRequest) {
233
234
  }
234
235
  r.Out.Host = h.upstream.Host
235
236
  r.Out.Header.Del("X-Forwarded-For")
237
+ r.Out.Header.Del(internalTokenHeader)
236
238
  r.Out.Header.Del(peerNodeHeader)
237
239
  r.Out.Header.Del(peerProvenanceHeader)
238
240
  r.Out.Header.Del(funnelOriginHeader)
@@ -513,6 +515,9 @@ func Allowed(method, path string) bool {
513
515
  if !supportedMethod(method) {
514
516
  return false
515
517
  }
518
+ if containsEscapedPathSeparator(path) {
519
+ return false
520
+ }
516
521
  switch method {
517
522
  case http.MethodGet:
518
523
  return getPaths[path] || dynamicGETPath(path)
@@ -528,9 +533,17 @@ func Allowed(method, path string) bool {
528
533
  }
529
534
 
530
535
  func allowedForAnyMethod(path string) bool {
536
+ if containsEscapedPathSeparator(path) {
537
+ return false
538
+ }
531
539
  return getPaths[path] || postPaths[path] || dynamicGETPath(path) || dynamicPOSTPath(path) || dynamicPUTPath(path) || dynamicDELETEPath(path)
532
540
  }
533
541
 
542
+ func containsEscapedPathSeparator(path string) bool {
543
+ lower := strings.ToLower(path)
544
+ return strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c")
545
+ }
546
+
534
547
  func localUpstream(upstream *url.URL) bool {
535
548
  host := upstream.Hostname()
536
549
  if host == "localhost" {
@@ -672,7 +685,6 @@ var getPaths = map[string]bool{
672
685
  "/pickers/permissions": true,
673
686
  "/pickers/resume": true,
674
687
  "/pickers/resume/preview": true,
675
- "/power-state": true,
676
688
  "/provider-status": true,
677
689
  "/push/status": true,
678
690
  "/recent-projects": true,
@@ -220,6 +220,8 @@ func TestAllowedPairDropContentRouteIsGetOnlyAndPathStrict(t *testing.T) {
220
220
  {http.MethodPost, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/content"},
221
221
  {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/content/extra"},
222
222
  {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef/extra/content"},
223
+ {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef%2fextra/content"},
224
+ {http.MethodGet, "/pairdrop/files/pd_0123456789abcdef0123456789abcdef%5cextra/content"},
223
225
  }
224
226
  for _, tc := range rejected {
225
227
  if Allowed(tc.method, tc.path) {
@@ -449,6 +451,28 @@ func TestPairlingConnectStripsForgedPeerNodeHeader(t *testing.T) {
449
451
  }
450
452
  }
451
453
 
454
+ func TestPairlingConnectStripsInternalTokenHeader(t *testing.T) {
455
+ var forwardedHeader string
456
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
457
+ forwardedHeader = r.Header.Get("X-Pairling-Internal-Token")
458
+ w.WriteHeader(http.StatusOK)
459
+ }))
460
+ defer upstream.Close()
461
+ handler := newTestHandlerWithMode(t, upstream.URL, 1024, nil, ExposureModePairlingConnect, nil)
462
+
463
+ req := httptest.NewRequest(http.MethodPost, "http://pairling-connect.local/send-text", strings.NewReader(`{}`))
464
+ req.Header.Set("Authorization", "Bearer device-token")
465
+ req.Header.Set("X-Pairling-Internal-Token", "forged-internal-token")
466
+ rec := httptest.NewRecorder()
467
+ handler.ServeHTTP(rec, req)
468
+ if rec.Code != http.StatusOK {
469
+ t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
470
+ }
471
+ if forwardedHeader != "" {
472
+ t.Fatalf("internal token header forwarded to upstream: %q", forwardedHeader)
473
+ }
474
+ }
475
+
452
476
  func TestPairlingConnectSetsPeerNodeHeaderFromResolver(t *testing.T) {
453
477
  var forwardedHeader string
454
478
  var forwardedProvenance string
@@ -98,6 +98,37 @@ run_step() {
98
98
  printf '%s' "$status"
99
99
  }
100
100
 
101
+ run_step_setup() {
102
+ # The setup step renders the guided screen, so its output must reach the
103
+ # controlling terminal while a copy is still written to setup.log for the audit
104
+ # record. This wrapper sends the live view to the controlling terminal through
105
+ # tee and writes a full transcript into the log. It reads the setup exit code
106
+ # from PIPESTATUS, the left side of the pipe, not tee, and emits only the
107
+ # status digit on its own stdout, so the surrounding command substitution reads
108
+ # a clean status. The terminal guard actually writes one empty line to
109
+ # /dev/tty, because the device node can test as writable when there is no
110
+ # controlling terminal. When that write fails, the wrapper falls back to the
111
+ # plain capture, so a headless launch still writes setup.log and never errors.
112
+ local name="$1"
113
+ shift
114
+ local log_file="$ARTIFACT_ROOT/$name.log"
115
+ set +e
116
+ if { : >/dev/tty; } 2>/dev/null; then
117
+ # PAIRLING_WIZARD tells install-runtime.sh to render even though its own
118
+ # stdout is piped here. tee writes the transcript to the log and sends its
119
+ # own stdout to the controlling terminal, so the screen is shown and the log
120
+ # stays a complete audit artifact. Stdin is inherited, so an interactive
121
+ # first-run still drives the recovery menu, which is gated on [ -t 0 ].
122
+ PAIRLING_WIZARD=1 "$@" 2>&1 | tee "$log_file" >/dev/tty
123
+ local status="${PIPESTATUS[0]}"
124
+ else
125
+ "$@" >"$log_file" 2>&1
126
+ local status=$?
127
+ fi
128
+ set -e
129
+ printf '%s' "$status"
130
+ }
131
+
101
132
  run_json_step() {
102
133
  local name="$1"
103
134
  local output_file="$ARTIFACT_ROOT/$name.json"
@@ -109,7 +140,7 @@ run_json_step() {
109
140
  printf '%s' "$status"
110
141
  }
111
142
 
112
- setup_status="$(run_step setup "$REPO_ROOT/mac/install/install-runtime.sh" setup)"
143
+ setup_status="$(run_step_setup setup "$REPO_ROOT/mac/install/install-runtime.sh" setup)"
113
144
  doctor_before_status="$(run_json_step doctor-before "$REPO_ROOT/mac/install/doctor.sh" --first-run --json)"
114
145
 
115
146
  pair_status="0"
@@ -60,7 +60,6 @@ home = Path.home()
60
60
 
61
61
  PAIRLING_PORT = int(os.environ.get("PAIRLING_RUNTIME_PORT", "7773"))
62
62
  PAIRLING_LABEL = "dev.pairling.companiond"
63
- PAIRLING_GUARDIAN_LABEL = "dev.pairling.power-guardian"
64
63
  PAIRLING_CONNECTD_LABEL = "dev.pairling.connectd"
65
64
  PAIRLING_PTYBROKER_LABEL = "dev.pairling.ptybroker"
66
65
  TEAM_ID = os.environ.get("PAIRLING_TEAM_ID", os.environ.get("PAIRLING_CONNECTD_TEAM_ID", "965AVD34A3"))
@@ -77,7 +76,7 @@ PAIR_ROOT = APP_SUPPORT / "pair"
77
76
  USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_LABEL}.plist"
78
77
  CONNECTD_USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_CONNECTD_LABEL}.plist"
79
78
  PTYBROKER_USER_PLIST = home / "Library" / "LaunchAgents" / f"{PAIRLING_PTYBROKER_LABEL}.plist"
80
- SYSTEM_PLIST = Path("/Library/LaunchDaemons") / f"{PAIRLING_GUARDIAN_LABEL}.plist"
79
+ CLAUDE_INJECTOR = home / "Applications" / "ClaudeInjector.app" / "Contents" / "MacOS" / "ClaudeInjector"
81
80
 
82
81
  sys.path.insert(0, str(repo_root / "mac" / "companiond"))
83
82
  from pairling_connectd_status import fetch_connectd_status, redacted_connectd_summary
@@ -175,6 +174,8 @@ def detected_tailnet_ip() -> str | None:
175
174
 
176
175
 
177
176
  def permission_readiness() -> dict:
177
+ helper_installed = CLAUDE_INJECTOR.exists()
178
+ grantee_path = str(CLAUDE_INJECTOR if helper_installed else Path("/usr/bin/osascript"))
178
179
  return {
179
180
  "ios_local_network": {
180
181
  "required_for": ["bonjour_pairing", "lan_route_validation"],
@@ -187,10 +188,16 @@ def permission_readiness() -> dict:
187
188
  "mac_accessibility": {
188
189
  "required_for": ["terminal_ui_synthesis"],
189
190
  "status": "not_required_until_terminal_control",
191
+ "grantee_path": grantee_path,
192
+ "helper_installed": helper_installed,
193
+ "helper_path": str(CLAUDE_INJECTOR),
194
+ "doctor_probe": "reports_required_grantee",
190
195
  },
191
196
  "mac_automation": {
192
197
  "required_for": ["terminal_app_control"],
193
198
  "status": "not_required_by_default",
199
+ "grantee_path": grantee_path,
200
+ "doctor_probe": "reports_required_grantee",
194
201
  },
195
202
  "privacy_database": "not_modified",
196
203
  }
@@ -250,6 +257,37 @@ def ptybroker_status_rpc() -> tuple[dict | None, str | None]:
250
257
  return None, f"{type(exc).__name__}: {exc}"
251
258
 
252
259
 
260
+ def safety_monitor_status() -> dict:
261
+ # Report the live SafetyMonitorBridge status, not a guess from local files.
262
+ # The bridge is imported from the staged companiond, the same copy the daemon
263
+ # runs. When no runtime is staged or the import fails, report a structured
264
+ # value with installed false, so the doctor JSON stays pure and never leaks a
265
+ # traceback. The bridge itself defaults to installed false when the future
266
+ # PairlingSafety.app is not present, which is today's true value.
267
+ try:
268
+ sys.path.insert(0, str(CURRENT / "companiond"))
269
+ from safety_monitor import SafetyMonitorBridge
270
+ bridge = SafetyMonitorBridge(APP_SUPPORT, home)
271
+ status = bridge.status()
272
+ return {
273
+ "installed": bool(status.get("installed")),
274
+ "full_disk_access": status.get("full_disk_access") or "unknown",
275
+ "system_extension_status": status.get("system_extension_status"),
276
+ "secure_mode_state": status.get("secure_mode_state"),
277
+ "live_artifact": status.get("live_artifact"),
278
+ "disk_usage_warning": status.get("disk_usage_warning"),
279
+ "summary": status.get("summary") or "",
280
+ "source": "live_bridge_status",
281
+ }
282
+ except Exception as exc:
283
+ return {
284
+ "installed": False,
285
+ "full_disk_access": "unknown",
286
+ "source": "live_bridge_status",
287
+ "error": str(exc)[:200],
288
+ }
289
+
290
+
253
291
  def ptybroker_deployment_status(*, launchd_loaded: bool) -> dict:
254
292
  desired = desired_ptybroker_identity()
255
293
  base = {
@@ -387,9 +425,8 @@ if manifest:
387
425
  "daemon_label": launchd.get("daemon_label"),
388
426
  "ptybroker_label": launchd.get("ptybroker_label"),
389
427
  "connectd_label": launchd.get("connectd_label"),
390
- "guardian_label": launchd.get("guardian_label"),
391
428
  }
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)
429
+ 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, "error", "Manifest launchd labels are Pairling labels.", launchd_evidence)
393
430
  mismatches = []
394
431
  for item in manifest.get("files") or []:
395
432
  rel = item.get("path")
@@ -413,15 +450,13 @@ else:
413
450
 
414
451
  compile_targets = [
415
452
  repo_root / "mac" / "install" / "render-launchd.py",
416
- repo_root / "mac" / "guardian" / "guardian_contract.py",
417
- repo_root / "mac" / "guardian" / "companion-power-guardian.py",
418
453
  ]
419
454
  compile_errors = []
420
455
  for target in compile_targets:
421
456
  code, out, err = run(["python3", "-m", "py_compile", str(target)])
422
457
  if code != 0:
423
458
  compile_errors.append(f"{target}: {err or out}")
424
- add("lifecycle_sources_compile", not compile_errors, "error", "Lifecycle/guardian sources compile." if not compile_errors else "Lifecycle/guardian compile failed.", compile_errors)
459
+ add("lifecycle_sources_compile", not compile_errors, "error", "Lifecycle sources compile." if not compile_errors else "Lifecycle compile failed.", compile_errors)
425
460
 
426
461
  ok, evidence = writable_dir(APP_SUPPORT)
427
462
  add("app_support_writable", ok, "error", "App support directory is writable.", evidence)
@@ -545,12 +580,6 @@ try:
545
580
  except Exception as exc:
546
581
  add("ptybroker_launchagent_plist", False, "error", f"Pairling PTY broker LaunchAgent plist unreadable: {type(exc).__name__}: {exc}", str(PTYBROKER_USER_PLIST))
547
582
 
548
- try:
549
- payload = load_plist(SYSTEM_PLIST)
550
- add("guardian_plist", payload.get("Label") == PAIRLING_GUARDIAN_LABEL, "warning", "Pairling guardian LaunchDaemon is rendered/installed.", {"label": payload.get("Label")})
551
- except Exception as exc:
552
- add("guardian_plist", False, "warning", f"Pairling guardian LaunchDaemon is not installed: {type(exc).__name__}: {exc}", str(SYSTEM_PLIST))
553
-
554
583
  code, out, err = run(["launchctl", "print", f"gui/{os.getuid()}/{PAIRLING_LABEL}"])
555
584
  add("launchagent_loaded", code == 0 and "state = running" in out, "error", "Pairling LaunchAgent is running." if code == 0 else "Pairling LaunchAgent is not loaded.", (out or err)[:2000])
556
585
  add("launchagent_loaded_from_current", str(CURRENT / "companiond" / "pairlingd.py") in out, "error", "Loaded Pairling LaunchAgent uses runtime/current.", out[:2000])
@@ -790,13 +819,13 @@ first_run = {
790
819
  result = {
791
820
  "ok": not errors,
792
821
  "product": "Pairling",
822
+ "safety_monitor": safety_monitor_status(),
793
823
  "schema_version": 1,
794
824
  "contract_version": "pairling-runtime-v1",
795
825
  "runtime": {
796
826
  "name": "pairlingd",
797
827
  "port": PAIRLING_PORT,
798
828
  "launchd_label": PAIRLING_LABEL,
799
- "guardian_label": PAIRLING_GUARDIAN_LABEL,
800
829
  },
801
830
  "ptybroker": ptybroker_deployment,
802
831
  "paths": {