tescmd 0.3.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

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.
tescmd/cli/setup.py CHANGED
@@ -29,10 +29,11 @@ TIER_FULL = "full"
29
29
 
30
30
 
31
31
  @click.command("setup")
32
+ @click.option("--force", is_flag=True, help="Reconfigure everything from scratch.")
32
33
  @global_options
33
- def setup_cmd(app_ctx: AppContext) -> None:
34
+ def setup_cmd(app_ctx: AppContext, force: bool) -> None:
34
35
  """Interactive setup wizard for first-time configuration."""
35
- run_async(_cmd_setup(app_ctx))
36
+ run_async(_cmd_setup(app_ctx, force=force))
36
37
 
37
38
 
38
39
  # ---------------------------------------------------------------------------
@@ -40,29 +41,35 @@ def setup_cmd(app_ctx: AppContext) -> None:
40
41
  # ---------------------------------------------------------------------------
41
42
 
42
43
 
43
- async def _cmd_setup(app_ctx: AppContext) -> None:
44
+ async def _cmd_setup(app_ctx: AppContext, *, force: bool = False) -> None:
44
45
  """Run the tiered onboarding wizard."""
45
46
  formatter = app_ctx.formatter
46
47
  settings = AppSettings()
47
48
 
48
49
  # Phase 0: Welcome + tier selection
49
- tier = _prompt_tier(formatter, settings)
50
+ tier = _prompt_tier(formatter, settings, force=force)
50
51
  if not tier:
51
52
  return
52
53
 
53
54
  # Phase 1: Domain setup via GitHub Pages (must happen before developer
54
55
  # portal because Tesla requires the Allowed Origin URL to match the
55
56
  # registration domain)
56
- domain = _domain_setup(formatter, settings)
57
+ domain = _domain_setup(formatter, settings, force=force)
57
58
  if not domain:
58
59
  return
59
60
 
60
61
  # Re-read settings after potential .env changes
61
62
  settings = AppSettings()
62
63
 
64
+ # Early check: warn if remote key differs from local
65
+ if tier == TIER_FULL:
66
+ _check_key_mismatch(formatter, settings, domain)
67
+
63
68
  # Phase 2: Developer portal walkthrough (credentials — uses domain for
64
69
  # the Allowed Origin URL instructions)
65
- client_id, client_secret = _developer_portal_setup(formatter, app_ctx, settings, domain=domain)
70
+ client_id, client_secret = _developer_portal_setup(
71
+ formatter, app_ctx, settings, domain=domain, force=force, tier=tier
72
+ )
66
73
  if not client_id:
67
74
  return
68
75
 
@@ -71,17 +78,17 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
71
78
 
72
79
  # Phase 3: Key generation + deployment (full tier only)
73
80
  if tier == TIER_FULL:
74
- _key_setup(formatter, settings, domain)
81
+ _key_setup(formatter, settings, domain, force=force)
75
82
 
76
- # Phase 3.5: Key enrollment (full tier only)
77
- if tier == TIER_FULL:
78
- await _enrollment_step(formatter, app_ctx, settings)
79
-
80
- # Phase 4: Fleet API partner registration
83
+ # Phase 4: Fleet API partner registration (closes out the setup phases)
81
84
  await _registration_step(formatter, app_ctx, settings, client_id, client_secret, domain)
82
85
 
83
86
  # Phase 5: OAuth login
84
- await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret)
87
+ await _oauth_login_step(formatter, app_ctx, settings, client_id, client_secret, force=force)
88
+
89
+ # Phase 3.5: Key enrollment (full tier only — after login)
90
+ if tier == TIER_FULL:
91
+ await _enrollment_step(formatter, app_ctx, settings)
85
92
 
86
93
  # Phase 6: Summary
87
94
  _print_next_steps(formatter, tier)
@@ -92,13 +99,13 @@ async def _cmd_setup(app_ctx: AppContext) -> None:
92
99
  # ---------------------------------------------------------------------------
93
100
 
94
101
 
95
- def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
102
+ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings, *, force: bool = False) -> str:
96
103
  """Ask the user which tier they want and persist the choice."""
97
104
  info = formatter.rich.info
98
105
 
99
106
  # If already configured, offer to keep or change
100
107
  existing_tier = settings.setup_tier
101
- if existing_tier in (TIER_READONLY, TIER_FULL):
108
+ if existing_tier in (TIER_READONLY, TIER_FULL) and not force:
102
109
  info(f"Setup tier: [cyan]{existing_tier}[/cyan] (previously configured)")
103
110
  info("")
104
111
 
@@ -157,6 +164,47 @@ def _prompt_tier(formatter: OutputFormatter, settings: AppSettings) -> str:
157
164
  return tier
158
165
 
159
166
 
167
+ # ---------------------------------------------------------------------------
168
+ # Key mismatch warning (between Phase 1 and Phase 2)
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _check_key_mismatch(
173
+ formatter: OutputFormatter,
174
+ settings: AppSettings,
175
+ domain: str,
176
+ ) -> None:
177
+ """Warn early if the remote public key differs from the local key."""
178
+ info = formatter.rich.info
179
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
180
+
181
+ from tescmd.crypto.keys import has_key_pair, load_public_key_pem
182
+
183
+ if not has_key_pair(key_dir):
184
+ return
185
+
186
+ pem = load_public_key_pem(key_dir)
187
+
188
+ # Fetch remote key (method-aware)
189
+ if settings.hosting_method == "tailscale":
190
+ from tescmd.deploy.tailscale_serve import fetch_tailscale_key_pem, get_key_url
191
+
192
+ url = get_key_url(domain)
193
+ remote_pem = fetch_tailscale_key_pem(domain)
194
+ else:
195
+ from tescmd.deploy.github_pages import fetch_key_pem, get_key_url
196
+
197
+ url = get_key_url(domain)
198
+ remote_pem = fetch_key_pem(domain)
199
+
200
+ if remote_pem is not None and remote_pem != pem.strip():
201
+ info("[yellow]The public key on your domain differs from your local key.[/yellow]")
202
+ info(f" Remote: {url}")
203
+ info(" This can happen after regenerating your key pair.")
204
+ info(" The key will be redeployed in Phase 3.")
205
+ info("")
206
+
207
+
160
208
  # ---------------------------------------------------------------------------
161
209
  # Phase 2: Developer portal walkthrough
162
210
  # ---------------------------------------------------------------------------
@@ -168,6 +216,8 @@ def _developer_portal_setup(
168
216
  settings: AppSettings,
169
217
  *,
170
218
  domain: str = "",
219
+ force: bool = False,
220
+ tier: str = "",
171
221
  ) -> tuple[str, str]:
172
222
  """Walk through Tesla Developer Portal setup if credentials are missing."""
173
223
  info = formatter.rich.info
@@ -175,7 +225,7 @@ def _developer_portal_setup(
175
225
  client_id = settings.client_id
176
226
  client_secret = settings.client_secret
177
227
 
178
- if client_id:
228
+ if client_id and not force:
179
229
  info(f"Client ID: [cyan]{client_id[:8]}...[/cyan] (already configured)")
180
230
  return (client_id, client_secret or "")
181
231
 
@@ -196,7 +246,13 @@ def _developer_portal_setup(
196
246
  from tescmd.cli.auth import _interactive_setup
197
247
 
198
248
  return _interactive_setup(
199
- formatter, port, redirect_uri, domain=domain, tailscale_hostname=ts_hostname
249
+ formatter,
250
+ port,
251
+ redirect_uri,
252
+ domain=domain,
253
+ tailscale_hostname=ts_hostname,
254
+ full_tier=(tier == TIER_FULL),
255
+ force=force,
200
256
  )
201
257
 
202
258
 
@@ -205,11 +261,13 @@ def _developer_portal_setup(
205
261
  # ---------------------------------------------------------------------------
206
262
 
207
263
 
208
- def _domain_setup(formatter: OutputFormatter, settings: AppSettings) -> str:
264
+ def _domain_setup(
265
+ formatter: OutputFormatter, settings: AppSettings, *, force: bool = False
266
+ ) -> str:
209
267
  """Set up a domain via GitHub Pages, Tailscale Funnel, or manual entry."""
210
268
  info = formatter.rich.info
211
269
 
212
- if settings.domain:
270
+ if settings.domain and not force:
213
271
  info(f"Domain: [cyan]{settings.domain}[/cyan] (already configured)")
214
272
  info("")
215
273
  return settings.domain
@@ -256,10 +314,10 @@ def _automated_domain_setup(formatter: OutputFormatter, settings: AppSettings) -
256
314
  info(f"GitHub CLI detected. Logged in as [cyan]{username}[/cyan].")
257
315
  info(f"Suggested domain: [cyan]{suggested_domain}[/cyan]")
258
316
  info("")
259
- info("[dim]Note: GitHub Pages provides always-on key hosting but cannot[/dim]")
260
- info("[dim]serve as a Fleet Telemetry server. If you plan to use telemetry[/dim]")
261
- info("[dim]streaming, choose Tailscale instead (install Tailscale, then[/dim]")
262
- info("[dim]re-run setup).[/dim]")
317
+ info("[dim]Note: GitHub Pages provides always-on key hosting. For Fleet[/dim]")
318
+ info("[dim]Telemetry streaming, Tailscale will also be used alongside[/dim]")
319
+ info("[dim]GitHub Pages just add your Tailscale hostname as an extra[/dim]")
320
+ info("[dim]Allowed Origin URL in the developer portal.[/dim]")
263
321
  info("")
264
322
 
265
323
  try:
@@ -350,7 +408,9 @@ def _manual_domain_setup(formatter: OutputFormatter) -> str:
350
408
  # ---------------------------------------------------------------------------
351
409
 
352
410
 
353
- def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -> None:
411
+ def _key_setup(
412
+ formatter: OutputFormatter, settings: AppSettings, domain: str, *, force: bool = False
413
+ ) -> None:
354
414
  """Generate keys and deploy via the configured hosting method (full tier only)."""
355
415
  info = formatter.rich.info
356
416
 
@@ -365,12 +425,12 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
365
425
  has_key_pair,
366
426
  )
367
427
 
368
- # Generate keys if needed
369
- if has_key_pair(key_dir):
428
+ # Generate keys if needed (force → overwrite existing keys)
429
+ if has_key_pair(key_dir) and not force:
370
430
  info(f"Key pair: [cyan]exists[/cyan] (fingerprint: {get_key_fingerprint(key_dir)})")
371
431
  else:
372
432
  info("Generating EC P-256 key pair...")
373
- generate_ec_key_pair(key_dir)
433
+ generate_ec_key_pair(key_dir, overwrite=force)
374
434
  info("[green]Key pair generated.[/green]")
375
435
  info(f" Fingerprint: {get_key_fingerprint(key_dir)}")
376
436
 
@@ -380,9 +440,9 @@ def _key_setup(formatter: OutputFormatter, settings: AppSettings, domain: str) -
380
440
  hosting = settings.hosting_method
381
441
 
382
442
  if hosting == "tailscale":
383
- _deploy_key_tailscale(formatter, settings, key_dir, domain)
443
+ _deploy_key_tailscale(formatter, settings, key_dir, domain, force=force)
384
444
  else:
385
- _deploy_key_github(formatter, settings, key_dir, domain)
445
+ _deploy_key_github(formatter, settings, key_dir, domain, force=force)
386
446
 
387
447
 
388
448
  def _deploy_key_tailscale(
@@ -390,6 +450,8 @@ def _deploy_key_tailscale(
390
450
  settings: AppSettings,
391
451
  key_dir: Path,
392
452
  domain: str,
453
+ *,
454
+ force: bool = False,
393
455
  ) -> None:
394
456
  """Deploy key via Tailscale Funnel."""
395
457
  info = formatter.rich.info
@@ -404,7 +466,7 @@ def _deploy_key_tailscale(
404
466
  )
405
467
 
406
468
  # Check if key is already deployed
407
- if run_async(validate_tailscale_key_url(domain)):
469
+ if run_async(validate_tailscale_key_url(domain)) and not force:
408
470
  info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
409
471
  info("")
410
472
  return
@@ -434,6 +496,8 @@ def _deploy_key_github(
434
496
  settings: AppSettings,
435
497
  key_dir: Path,
436
498
  domain: str,
499
+ *,
500
+ force: bool = False,
437
501
  ) -> None:
438
502
  """Deploy key via GitHub Pages."""
439
503
  info = formatter.rich.info
@@ -441,10 +505,10 @@ def _deploy_key_github(
441
505
  from tescmd.crypto.keys import load_public_key_pem
442
506
  from tescmd.deploy.github_pages import (
443
507
  deploy_public_key,
508
+ fetch_key_pem,
444
509
  get_key_url,
445
510
  is_gh_authenticated,
446
511
  is_gh_available,
447
- validate_key_url,
448
512
  wait_for_pages_deployment,
449
513
  )
450
514
 
@@ -461,14 +525,33 @@ def _deploy_key_github(
461
525
  info("")
462
526
  return
463
527
 
464
- # Check if key is already deployed
465
- if validate_key_url(domain):
466
- info(f"Public key: [green]already accessible[/green] at {get_key_url(domain)}")
528
+ # Compare remote key to local key
529
+ pem = load_public_key_pem(key_dir)
530
+ remote_pem = fetch_key_pem(domain)
531
+
532
+ if remote_pem is not None and remote_pem == pem.strip() and not force:
533
+ info(f"Public key: [green]matches GitHub[/green] at {get_key_url(domain)}")
467
534
  info("")
468
535
  return
469
536
 
470
- info("Deploying public key to GitHub Pages...")
471
- pem = load_public_key_pem(key_dir)
537
+ if remote_pem is not None:
538
+ # Remote key exists but differs — confirm before overwriting
539
+ info("[yellow]The public key on GitHub differs from your local key.[/yellow]")
540
+ info(f" Remote: {get_key_url(domain)}")
541
+ info(" This can happen after regenerating your key pair.")
542
+ info("")
543
+ try:
544
+ answer = input("Update GitHub with the local key? [y/N] ").strip()
545
+ except (EOFError, KeyboardInterrupt):
546
+ answer = "n"
547
+ if answer.lower() != "y":
548
+ info("[dim]Skipped — GitHub key left unchanged.[/dim]")
549
+ info("")
550
+ return
551
+ info("Updating public key on GitHub Pages...")
552
+ else:
553
+ info("Deploying public key to GitHub Pages...")
554
+
472
555
  deploy_public_key(pem, github_repo)
473
556
 
474
557
  info("[green]Key committed and pushed.[/green]")
@@ -550,34 +633,24 @@ async def _enrollment_step(
550
633
  info(f" Public key: [green]accessible[/green] at {key_url}")
551
634
  info("")
552
635
 
553
- try:
554
- answer = input(" Open enrollment URL in your browser? [Y/n] ").strip()
555
- except (EOFError, KeyboardInterrupt):
556
- info("")
557
- return
558
-
559
- if answer.lower() not in ("n", "no"):
560
- info("")
561
- info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
562
- webbrowser.open(enroll_url)
563
- info("")
636
+ info(f" Opening [link={enroll_url}]{enroll_url}[/link]…")
637
+ webbrowser.open(enroll_url)
638
+ info("")
564
639
 
565
640
  info(" " + "━" * 49)
566
641
  info(" [bold yellow]ACTION REQUIRED: Add virtual key in the Tesla app[/bold yellow]")
567
642
  info("")
568
- info(f" Enrollment URL: {enroll_url}")
569
- info("")
570
- info(" 1. Open the URL above [bold]on your phone[/bold]")
643
+ info(" 1. Scan the QR code with your phone (iOS / Android)")
571
644
  info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
572
645
  info(" 3. The Tesla app shows an [bold]Add Virtual Key[/bold] prompt")
573
646
  info(" 4. Approve it")
574
647
  info("")
575
- info(" [dim]If the prompt doesn't appear, force-quit the Tesla app,[/dim]")
576
- info(" [dim]go back to your browser, and tap Finish Setup again.[/dim]")
648
+ info(
649
+ " [dim]If the prompt doesn't appear, force-quit the Tesla app,"
650
+ " scan the QR code again, and tap Finish Setup.[/dim]"
651
+ )
577
652
  info(" " + "━" * 49)
578
653
  info("")
579
- info(" After approving, try: [cyan]tescmd charge status --wake[/cyan]")
580
- info("")
581
654
 
582
655
 
583
656
  # ---------------------------------------------------------------------------
@@ -626,7 +699,10 @@ async def _registration_step(
626
699
  domain=domain,
627
700
  region=region,
628
701
  )
629
- info("[green]Registration successful.[/green]")
702
+ if _result.get("already_registered"):
703
+ info("[green]Already registered — no action needed.[/green]")
704
+ else:
705
+ info("[green]Registration successful.[/green]")
630
706
  except Exception as exc:
631
707
  status_code = getattr(exc, "status_code", None)
632
708
  exc_text = str(exc)
@@ -895,6 +971,8 @@ async def _oauth_login_step(
895
971
  settings: AppSettings,
896
972
  client_id: str,
897
973
  client_secret: str,
974
+ *,
975
+ force: bool = False,
898
976
  ) -> None:
899
977
  """Run the OAuth2 login flow."""
900
978
  info = formatter.rich.info
@@ -908,7 +986,7 @@ async def _oauth_login_step(
908
986
  config_dir=settings.config_dir,
909
987
  )
910
988
 
911
- if store.has_token:
989
+ if store.has_token and not force:
912
990
  # Check whether the stored scopes cover what we need.
913
991
  # A readonly→full upgrade requires vehicle_cmds + vehicle_charging_cmds
914
992
  # that the original readonly token may not have.
@@ -223,12 +223,23 @@ def get_key_url(domain: str) -> str:
223
223
 
224
224
  def validate_key_url(domain: str) -> bool:
225
225
  """Return True if the public key is accessible at the expected URL."""
226
+ return fetch_key_pem(domain) is not None
227
+
228
+
229
+ def fetch_key_pem(domain: str) -> str | None:
230
+ """Fetch the public key PEM from the hosted ``.well-known`` URL.
231
+
232
+ Returns the PEM string (stripped), or ``None`` if the key is not
233
+ accessible or does not look like a PEM public key.
234
+ """
226
235
  url = get_key_url(domain)
227
236
  try:
228
237
  resp = httpx.get(url, follow_redirects=True, timeout=10)
229
- return resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text
238
+ if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
239
+ return resp.text.strip()
230
240
  except httpx.HTTPError:
231
- return False
241
+ pass
242
+ return None
232
243
 
233
244
 
234
245
  def wait_for_pages_deployment(
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import logging
12
+ import threading
12
13
  import time
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
13
15
  from pathlib import Path
14
16
 
15
17
  import httpx
@@ -27,6 +29,71 @@ DEFAULT_DEPLOY_TIMEOUT = 60 # seconds (faster than GitHub Pages)
27
29
  POLL_INTERVAL = 3 # seconds
28
30
 
29
31
 
32
+ # ---------------------------------------------------------------------------
33
+ # In-process key server (used by interactive setup)
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ class _KeyRequestHandler(BaseHTTPRequestHandler):
38
+ """Serve the root (200 OK) and the ``.well-known`` PEM path."""
39
+
40
+ server: KeyServer # type: ignore[assignment]
41
+
42
+ def do_GET(self) -> None:
43
+ if self.path == "/":
44
+ self._respond(200, "")
45
+ elif self.path == f"/{WELL_KNOWN_PATH}":
46
+ self._respond(
47
+ 200,
48
+ self.server.pem_content,
49
+ content_type="application/x-pem-file",
50
+ )
51
+ else:
52
+ self._respond(404, "Not found")
53
+
54
+ def _respond(
55
+ self,
56
+ status: int,
57
+ body: str,
58
+ content_type: str = "text/html; charset=utf-8",
59
+ ) -> None:
60
+ encoded = body.encode()
61
+ self.send_response(status)
62
+ self.send_header("Content-Type", content_type)
63
+ self.send_header("Content-Length", str(len(encoded)))
64
+ self.end_headers()
65
+ self.wfile.write(encoded)
66
+
67
+ def log_message(self, format: str, *args: object) -> None:
68
+ """Silence default stderr logging."""
69
+
70
+
71
+ class KeyServer(HTTPServer):
72
+ """Ephemeral HTTP server that serves a PEM public key.
73
+
74
+ Runs in a daemon thread so the main process can continue interacting
75
+ with the user. Tailscale Funnel proxies external HTTPS traffic to
76
+ this local server.
77
+ """
78
+
79
+ def __init__(self, pem_content: str, port: int) -> None:
80
+ super().__init__(("127.0.0.1", port), _KeyRequestHandler)
81
+ self.pem_content = pem_content
82
+ self._thread: threading.Thread | None = None
83
+
84
+ def start(self) -> None:
85
+ """Start serving in a background daemon thread."""
86
+ self._thread = threading.Thread(target=self.serve_forever, daemon=True)
87
+ self._thread.start()
88
+
89
+ def stop(self) -> None:
90
+ """Shut down the server and wait for the thread to exit."""
91
+ self.shutdown()
92
+ self.server_close()
93
+ if self._thread is not None:
94
+ self._thread.join(timeout=5)
95
+
96
+
30
97
  # ---------------------------------------------------------------------------
31
98
  # Key file management
32
99
  # ---------------------------------------------------------------------------
@@ -46,6 +113,7 @@ async def deploy_public_key_tailscale(
46
113
  key_path = base / WELL_KNOWN_PATH
47
114
  key_path.parent.mkdir(parents=True, exist_ok=True)
48
115
  key_path.write_text(public_key_pem)
116
+
49
117
  logger.info("Public key written to %s", key_path)
50
118
  return key_path
51
119
 
@@ -56,7 +124,11 @@ async def deploy_public_key_tailscale(
56
124
 
57
125
 
58
126
  async def start_key_serving(serve_dir: Path | None = None) -> str:
59
- """Start ``tailscale serve`` for ``.well-known`` and enable Funnel.
127
+ """Start ``tailscale serve`` with Funnel for ``.well-known``.
128
+
129
+ Uses a single ``tailscale serve --bg --funnel --set-path / <dir>``
130
+ command so that the static-file handler and public Funnel access are
131
+ configured atomically on HTTPS port 443.
60
132
 
61
133
  Returns the public hostname (e.g. ``machine.tailnet.ts.net``).
62
134
 
@@ -75,20 +147,20 @@ async def start_key_serving(serve_dir: Path | None = None) -> str:
75
147
  await ts.check_available()
76
148
  hostname = await ts.get_hostname()
77
149
 
78
- # Serve the .well-known directory at /.well-known/
79
- await ts.start_serve("/.well-known/", str(well_known_dir))
80
-
81
- # Enable Funnel to make it publicly accessible
82
- await ts.enable_funnel()
150
+ # Serve the entire base directory at / with Funnel enabled so that:
151
+ # - The origin URL (https://host/) returns 200
152
+ # - The key at /.well-known/appspecific/com.tesla.3p.public-key.pem is reachable
153
+ # Tesla verifies both during Developer Portal app configuration.
154
+ await ts.start_serve("/", str(base), funnel=True)
83
155
 
84
156
  logger.info("Key serving started at https://%s/%s", hostname, WELL_KNOWN_PATH)
85
157
  return hostname
86
158
 
87
159
 
88
160
  async def stop_key_serving() -> None:
89
- """Remove the ``.well-known`` serve handler."""
161
+ """Remove the key-serving handler."""
90
162
  ts = TailscaleManager()
91
- await ts.stop_serve("/.well-known/")
163
+ await ts.stop_serve("/")
92
164
  logger.info("Key serving stopped")
93
165
 
94
166
 
@@ -121,6 +193,22 @@ def get_key_url(hostname: str) -> str:
121
193
  return f"https://{hostname}/{WELL_KNOWN_PATH}"
122
194
 
123
195
 
196
+ def fetch_tailscale_key_pem(hostname: str) -> str | None:
197
+ """Fetch the public key PEM from a Tailscale Funnel ``.well-known`` URL.
198
+
199
+ Returns the PEM string (stripped), or ``None`` if the key is not
200
+ accessible or does not look like a PEM public key.
201
+ """
202
+ url = get_key_url(hostname)
203
+ try:
204
+ resp = httpx.get(url, follow_redirects=True, timeout=10)
205
+ if resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text:
206
+ return resp.text.strip()
207
+ except httpx.HTTPError:
208
+ pass
209
+ return None
210
+
211
+
124
212
  async def validate_tailscale_key_url(hostname: str) -> bool:
125
213
  """HTTP GET to verify key is accessible.
126
214
 
@@ -424,10 +424,17 @@ class CommandDispatcher:
424
424
 
425
425
  Accepts both OpenClaw-style (``door.lock``) and API-style
426
426
  (``door_lock``) method names via :data:`_METHOD_ALIASES`.
427
+
428
+ The target method can be specified as ``method`` or ``command``
429
+ (the latter mirrors the gateway protocol's field name).
427
430
  """
428
- method = params.get("method", "")
431
+ raw = params.get("method", "") or params.get("command", "")
432
+ # Normalize: bots may send a list like ["door.lock"] instead of a string
433
+ if isinstance(raw, list):
434
+ raw = raw[0] if raw else ""
435
+ method = str(raw).strip() if raw else ""
429
436
  if not method:
430
- raise ValueError("system.run requires 'method' parameter")
437
+ raise ValueError("system.run requires 'method' (or 'command') parameter")
431
438
  resolved = _METHOD_ALIASES.get(method, method)
432
439
  if resolved == "system.run":
433
440
  raise ValueError("system.run cannot invoke itself")
@@ -280,8 +280,21 @@ class GatewayClient:
280
280
  so gateways that enforce authentication at the transport layer
281
281
  accept the connection before the OpenClaw handshake begins.
282
282
 
283
+ If a receive loop is already running (e.g. from a previous
284
+ connection or a concurrent reconnect attempt), it is cancelled
285
+ before establishing the new connection to prevent duplicate
286
+ ``recv()`` calls on the same WebSocket.
287
+
283
288
  Raises :class:`GatewayConnectionError` on failure.
284
289
  """
290
+ import contextlib
291
+
292
+ if self._recv_task is not None and not self._recv_task.done():
293
+ self._recv_task.cancel()
294
+ with contextlib.suppress(asyncio.CancelledError):
295
+ await self._recv_task
296
+ self._recv_task = None
297
+
285
298
  await self._establish_connection()
286
299
 
287
300
  if self._on_request is not None: