portacode 1.4.15__py3-none-any.whl → 1.4.15.dev0__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.
portacode/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.4.15'
32
- __version_tuple__ = version_tuple = (1, 4, 15)
31
+ __version__ = version = '1.4.15.dev0'
32
+ __version_tuple__ = version_tuple = (1, 4, 15, 'dev0')
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -27,10 +27,6 @@ The Portacode server acts as a **routing middleman** between client sessions and
27
27
 
28
28
  - **`source_client_session`** (Server → Device): Server **adds this** when forwarding client commands to devices (so device knows which client sent the command and can target responses back). Clients never include this field.
29
29
 
30
- ### Proxying infrastructure updates
31
-
32
- Portacode infrastructure devices (like the proxmox host) can send events on behalf of the LXC Devices they manage. Such messages include the optional `on_behalf_of_device` field and the server silently replaces `device_id` with that child device before routing. The gateway enforces that the sender is the child’s `proxmox_parent` (via `Device.proxmox_parent`) so only the infrastructure owner can impersonate a child device. Messages that fail this check are dropped.
33
-
34
30
  This document describes the complete protocol for communicating with devices through the server, guiding app developers on how to get their client sessions to communicate with devices.
35
31
 
36
32
  ## Table of Contents
@@ -330,9 +326,10 @@ Configures a Proxmox node for Portacode infrastructure usage (API token validati
330
326
 
331
327
  **Payload Fields:**
332
328
 
333
- * `token_identifier` (string, required): API token identifier in the form `user@realm!tokenid`.
334
- * `token_value` (string, required): Secret value associated with the token.
335
- * `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`.
329
+ * `token_identifier` (string, optional when reconfiguring): API token identifier in the form `user@realm!tokenid`. Required on first configuration or when replacing the stored token.
330
+ * `token_value` (string, optional when reconfiguring): Secret value associated with the token. Required when `token_identifier` is supplied.
331
+ * `verify_ssl` (boolean, optional): When true, the handler verifies SSL certificates; defaults to `false`. When omitted, the last configured value is preserved.
332
+ * `cloudflare_api_token` (string, optional): Cloudflare API token the host can reuse later to provision tunnels.
336
333
 
337
334
  **Responses:**
338
335
 
@@ -365,7 +362,7 @@ Creates a Portacode-managed LXC container, starts it, and bootstraps the Portaco
365
362
  * `username` (string, optional): OS user to provision (defaults to `svcuser`).
366
363
  * `password` (string, optional): Password for the user (used only during provisioning).
367
364
  * `ssh_key` (string, optional): SSH public key to add to the user.
368
- * `device_id` (string, required): ID of the dashboard Device record that represents the container. The handler persists this value in the host metadata file so related events can always be correlated back to that Device.
365
+ * `device_id` (string, optional): ID of the Device record that already exists on the dashboard.
369
366
  * `device_public_key` (string, optional): PEM-encoded Portacode public key. When supplied together with `device_private_key` the handler injects the keypair, records the device metadata, and runs `portacode service install` automatically.
370
367
  * `device_private_key` (string, optional): PEM-encoded private key that pairs with `device_public_key`. Both key fields must be present for the automatic service-install mode.
371
368
 
@@ -425,8 +422,7 @@ Emitted after a successful `create_proxmox_container` action. Contains the new c
425
422
  * `public_key` (string): Portacode public auth key created inside the new container.
426
423
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
427
424
  * `setup_steps` (array[object]): Detailed bootstrap step results (name, stdout/stderr, elapsed time, and status).
428
- * `device_id` (string): Mirrors the dashboard Device ID supplied with `create_proxmox_container`. The handler records this value in the container metadata file so subsequent events can reference the same Device.
429
- * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device; only proxmox parents may include this field.
425
+ * `device_id` (string, optional): Mirrors the `device_id` supplied with `create_proxmox_container`, if any.
430
426
  * `service_installed` (boolean): True when the handler already ran `portacode service install` (with a provided keypair); otherwise it remains False and the dashboard can call `start_portacode_service`.
431
427
 
432
428
  ### `proxmox_container_progress`
@@ -444,7 +440,6 @@ Sent intermittently while `create_proxmox_container` is executing so callers can
444
440
  * `message` (string): Short description of what is happening or why a failure occurred.
445
441
  * `details` (object, optional): Contains `attempt` (if retries were needed) and `error_summary` when a step fails.
446
442
  * `request_id` (string, optional): Mirrors the request ID from the incoming `create_proxmox_container` payload when available.
447
- * `on_behalf_of_device` (string, optional): When present the proxmox device is reporting progress for the referenced dashboard device; the gateway verifies the proxmox node is the child’s `proxmox_parent` before routing the event.
448
443
 
449
444
  ### `start_portacode_service`
450
445
 
@@ -1132,6 +1127,8 @@ Provides system information in response to a `system_info` action. Handled by [`
1132
1127
  * `message` (string|null): Informational text about the network setup attempt.
1133
1128
  * `bridge` (string): The bridge interface configured (typically `vmbr1`).
1134
1129
  * `health` (string|null): `"healthy"` when the connectivity verification succeeded.
1130
+ * `cloudflare` (object): Optional Cloudflare metadata collected for future tunnels:
1131
+ * `configured` (boolean): True when a Cloudflare API token is stored.
1135
1132
  * `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
1136
1133
  * `managed_containers` (object): Cached summary of the Portacode-managed containers:
1137
1134
  * `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
@@ -1183,8 +1180,6 @@ Emitted after a successful `create_proxmox_container` action to report the newly
1183
1180
  * `public_key` (string): Portacode public auth key discovered inside the container.
1184
1181
  * `container` (object): Metadata such as `vmid`, `hostname`, `template`, `storage`, `disk_gib`, `ram_mib`, and `cpus`.
1185
1182
  * `setup_steps` (array[object]): Detailed bootstrap step reports including stdout/stderr, elapsed time, and pass/fail status.
1186
- * `device_id` (string): Mirrors the device ID supplied with `create_proxmox_container` and persisted inside the host metadata file for this CT.
1187
- * `on_behalf_of_device` (string): Same value as `device_id` when the container host is reporting progress for the child device.
1188
1183
 
1189
1184
  ### `proxmox_container_progress`
1190
1185
 
@@ -1201,7 +1196,6 @@ Sent continuously while `create_proxmox_container` runs so dashboards can show a
1201
1196
  * `message` (string): Short human-readable description of the action or failure.
1202
1197
  * `details` (object, optional): Contains `attempt` (when retries are used) and `error_summary` on failure.
1203
1198
  * `request_id` (string, optional): Mirrors the `create_proxmox_container` request when provided.
1204
- * `on_behalf_of_device` (string, optional): Mirrors the child device ID when a proxmox host reports progress for that child; only proxmox parents can supply this field.
1205
1199
 
1206
1200
  ### `proxmox_container_action`
1207
1201
 
@@ -21,24 +21,6 @@ from .git_manager import GitManager
21
21
  from .file_system_watcher import FileSystemWatcher
22
22
  from ....logging_categories import get_categorized_logger, LogCategory
23
23
 
24
- def _deterministic_file_tab_id(file_path: str) -> str:
25
- try:
26
- resolved = os.path.abspath(file_path)
27
- mtime = int(Path(resolved).stat().st_mtime)
28
- except OSError:
29
- mtime = "unknown"
30
- resolved = os.path.abspath(file_path)
31
- return f"{resolved}:{mtime}"
32
-
33
- def _deterministic_diff_tab_id(
34
- file_path: str,
35
- from_ref: str,
36
- to_ref: str,
37
- from_hash: Optional[str],
38
- to_hash: Optional[str]
39
- ) -> str:
40
- return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
41
-
42
24
  logger = get_categorized_logger(__name__)
43
25
 
44
26
  # Global singleton instance
@@ -675,10 +657,10 @@ class ProjectStateManager:
675
657
  # Create new file tab using tab factory
676
658
  from ..tab_factory import get_tab_factory
677
659
  tab_factory = get_tab_factory()
678
-
660
+
679
661
  try:
680
662
  logger.info(f"About to create tab for file: {file_path}")
681
- new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
663
+ new_tab = await tab_factory.create_file_tab(file_path)
682
664
  logger.info(f"Tab created successfully, adding to project state")
683
665
  project_state.open_tabs[tab_key] = new_tab
684
666
  if set_active:
@@ -860,11 +842,7 @@ class ProjectStateManager:
860
842
  diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
861
843
 
862
844
  diff_tab = await tab_factory.create_diff_tab_with_title(
863
- file_path,
864
- original_content,
865
- modified_content,
866
- diff_title,
867
- tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
845
+ file_path, original_content, modified_content, diff_title,
868
846
  diff_details=diff_details
869
847
  )
870
848
 
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- import math
9
8
  import os
10
9
  import secrets
11
10
  import shlex
@@ -16,7 +15,7 @@ import sys
16
15
  import tempfile
17
16
  import time
18
17
  import threading
19
- from datetime import datetime, timezone
18
+ from datetime import datetime
20
19
  from pathlib import Path
21
20
  from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
22
21
 
@@ -47,7 +46,6 @@ UNIT_DIR = Path("/etc/systemd/system")
47
46
  _MANAGED_CONTAINERS_CACHE_TTL_S = 30.0
48
47
  _MANAGED_CONTAINERS_CACHE: Dict[str, Any] = {"timestamp": 0.0, "summary": None}
49
48
  _MANAGED_CONTAINERS_CACHE_LOCK = threading.Lock()
50
- TEMPLATES_REFRESH_INTERVAL_S = 300
51
49
 
52
50
  ProgressCallback = Callable[[int, int, Dict[str, Any], str, Optional[Dict[str, Any]]], None]
53
51
 
@@ -64,7 +62,6 @@ def _emit_progress_event(
64
62
  phase: str,
65
63
  request_id: Optional[str],
66
64
  details: Optional[Dict[str, Any]] = None,
67
- on_behalf_of_device: Optional[str] = None,
68
65
  ) -> None:
69
66
  loop = handler.context.get("event_loop")
70
67
  if not loop or loop.is_closed():
@@ -89,8 +86,6 @@ def _emit_progress_event(
89
86
  payload["request_id"] = request_id
90
87
  if details:
91
88
  payload["details"] = details
92
- if on_behalf_of_device:
93
- payload["on_behalf_of_device"] = str(on_behalf_of_device)
94
89
 
95
90
  future = asyncio.run_coroutine_threadsafe(handler.send_response(payload), loop)
96
91
  future.add_done_callback(
@@ -180,64 +175,6 @@ def _list_templates(client: Any, node: str, storages: Iterable[Dict[str, Any]])
180
175
  return templates
181
176
 
182
177
 
183
- def _build_proxmox_client_from_config(config: Dict[str, Any]):
184
- user = config.get("user")
185
- token_name = config.get("token_name")
186
- token_value = config.get("token_value")
187
- if not user or not token_name or not token_value:
188
- raise RuntimeError("Proxmox API credentials are missing")
189
- ProxmoxAPI = _ensure_proxmoxer()
190
- return ProxmoxAPI(
191
- config.get("host", DEFAULT_HOST),
192
- user=user,
193
- token_name=token_name,
194
- token_value=token_value,
195
- verify_ssl=config.get("verify_ssl", False),
196
- timeout=30,
197
- )
198
-
199
-
200
- def _current_time_iso() -> str:
201
- return datetime.now(timezone.utc).isoformat()
202
-
203
-
204
- def _parse_iso_timestamp(value: str) -> Optional[datetime]:
205
- if not value:
206
- return None
207
- text = value
208
- if text.endswith("Z"):
209
- text = text[:-1] + "+00:00"
210
- try:
211
- return datetime.fromisoformat(text)
212
- except ValueError:
213
- return None
214
-
215
-
216
- def _templates_need_refresh(config: Dict[str, Any]) -> bool:
217
- if not config or not config.get("token_value"):
218
- return False
219
- last = _parse_iso_timestamp(config.get("templates_last_refreshed") or "")
220
- if not last:
221
- return True
222
- return (datetime.now(timezone.utc) - last).total_seconds() >= TEMPLATES_REFRESH_INTERVAL_S
223
-
224
-
225
- def _ensure_templates_refreshed_on_startup(config: Dict[str, Any]) -> None:
226
- if not _templates_need_refresh(config):
227
- return
228
- try:
229
- client = _build_proxmox_client_from_config(config)
230
- node = config.get("node") or _pick_node(client)
231
- storages = client.nodes(node).storage.get()
232
- templates = _list_templates(client, node, storages)
233
- if templates:
234
- config["templates"] = templates
235
- config["templates_last_refreshed"] = _current_time_iso()
236
- _save_config(config)
237
- except Exception as exc:
238
- logger.warning("Unable to refresh Proxmox templates on startup: %s", exc)
239
-
240
-
241
178
  def _pick_storage(storages: Iterable[Dict[str, Any]]) -> str:
242
179
  candidates = [s for s in storages if "rootdir" in s.get("content", "") and s.get("avail", 0) > 0]
243
180
  if not candidates:
@@ -496,171 +433,48 @@ def _friendly_step_label(step_name: str) -> str:
496
433
  return normalized.capitalize()
497
434
 
498
435
 
499
- _NETWORK_WAIT_CMD = (
500
- "count=0; "
501
- "while [ \"$count\" -lt 20 ]; do "
502
- " if command -v ip >/dev/null 2>&1 && ip route get 1.1.1.1 >/dev/null 2>&1; then break; fi; "
503
- " if [ -f /proc/net/route ] && grep -q '^00000000' /proc/net/route >/dev/null 2>&1; then break; fi; "
504
- " sleep 1; "
505
- " count=$((count+1)); "
506
- "done"
507
- )
508
-
509
- _PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
510
- "apt": {
511
- "update_cmd": "apt-get update -y",
512
- "update_step_name": "apt_update",
513
- "install_cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
514
- "install_step_name": "install_deps",
515
- "update_retries": 4,
516
- "install_retries": 5,
517
- },
518
- "dnf": {
519
- "update_cmd": "dnf check-update || true",
520
- "update_step_name": "dnf_update",
521
- "install_cmd": "dnf install -y python3 python3-pip sudo",
522
- "install_step_name": "install_deps",
523
- "update_retries": 3,
524
- "install_retries": 5,
525
- },
526
- "yum": {
527
- "update_cmd": "yum makecache",
528
- "update_step_name": "yum_update",
529
- "install_cmd": "yum install -y python3 python3-pip sudo",
530
- "install_step_name": "install_deps",
531
- "update_retries": 3,
532
- "install_retries": 5,
533
- },
534
- "apk": {
535
- "update_cmd": "apk update",
536
- "update_step_name": "apk_update",
537
- "install_cmd": "apk add --no-cache python3 py3-pip sudo shadow",
538
- "install_step_name": "install_deps",
539
- "update_retries": 3,
540
- "install_retries": 5,
541
- },
542
- "pacman": {
543
- "update_cmd": "pacman -Sy --noconfirm",
544
- "update_step_name": "pacman_update",
545
- "install_cmd": "pacman -S --noconfirm python python-pip sudo",
546
- "install_step_name": "install_deps",
547
- "update_retries": 3,
548
- "install_retries": 5,
549
- },
550
- "zypper": {
551
- "update_cmd": "zypper refresh",
552
- "update_step_name": "zypper_update",
553
- "install_cmd": "zypper install -y python3 python3-pip sudo",
554
- "install_step_name": "install_deps",
555
- "update_retries": 3,
556
- "install_retries": 5,
557
- },
558
- }
559
-
560
- _UPDATE_RETRY_ON = [
561
- "Temporary failure resolving",
562
- "Could not resolve",
563
- "Failed to fetch",
564
- ]
565
-
566
- _INSTALL_RETRY_ON = [
567
- "lock-frontend",
568
- "Unable to acquire the dpkg frontend lock",
569
- "Temporary failure resolving",
570
- "Could not resolve",
571
- "Failed to fetch",
572
- ]
573
-
574
-
575
436
  def _build_bootstrap_steps(
576
437
  user: str,
577
438
  password: str,
578
439
  ssh_key: str,
579
440
  include_portacode_connect: bool = True,
580
- package_manager: str = "apt",
581
441
  ) -> List[Dict[str, Any]]:
582
- profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
583
- steps: List[Dict[str, Any]] = [
584
- {"name": "wait_for_network", "cmd": _NETWORK_WAIT_CMD, "retries": 0},
585
- ]
586
- update_cmd = profile.get("update_cmd")
587
- if update_cmd:
588
- steps.append(
589
- {
590
- "name": profile.get("update_step_name", "package_update"),
591
- "cmd": update_cmd,
592
- "retries": profile.get("update_retries", 3),
593
- "retry_delay_s": 5,
594
- "retry_on": _UPDATE_RETRY_ON,
595
- }
596
- )
597
- install_cmd = profile.get("install_cmd")
598
- if install_cmd:
599
- steps.append(
600
- {
601
- "name": profile.get("install_step_name", "install_deps"),
602
- "cmd": install_cmd,
603
- "retries": profile.get("install_retries", 5),
604
- "retry_delay_s": 5,
605
- "retry_on": _INSTALL_RETRY_ON,
606
- }
607
- )
608
- steps.extend(
609
- [
610
- {
611
- "name": "user_exists",
612
- "cmd": (
613
- f"id -u {user} >/dev/null 2>&1 || "
614
- f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
615
- f" adduser --disabled-password --gecos '' {user}; "
616
- "else "
617
- f" useradd -m -s /bin/sh {user}; "
618
- "fi)"
619
- ),
620
- "retries": 0,
621
- },
622
- {
623
- "name": "add_sudo",
624
- "cmd": (
625
- f"if command -v usermod >/dev/null 2>&1; then "
626
- " if ! getent group sudo >/dev/null 2>&1; then "
627
- " if command -v groupadd >/dev/null 2>&1; then "
628
- " groupadd sudo >/dev/null 2>&1 || true; "
629
- " fi; "
630
- " fi; "
631
- f" usermod -aG sudo {user}; "
632
- "else "
633
- " for grp in wheel sudo; do "
634
- " if ! getent group \"$grp\" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then "
635
- " groupadd \"$grp\" >/dev/null 2>&1 || true; "
636
- " fi; "
637
- " addgroup \"$grp\" >/dev/null 2>&1 || true; "
638
- f" addgroup {user} \"$grp\" >/dev/null 2>&1 || true; "
639
- " done; "
640
- "fi"
641
- ),
642
- "retries": 0,
643
- },
442
+ steps = [
644
443
  {
645
- "name": "add_sudoers",
646
- "cmd": (
647
- f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
648
- "chmod 0440 /etc/sudoers.d/portacode"
649
- ),
650
- "retries": 0,
444
+ "name": "apt_update",
445
+ "cmd": "apt-get update -y",
446
+ "retries": 4,
447
+ "retry_delay_s": 5,
448
+ "retry_on": [
449
+ "Temporary failure resolving",
450
+ "Could not resolve",
451
+ "Failed to fetch",
452
+ ],
651
453
  },
652
- ]
653
- )
454
+ {
455
+ "name": "install_deps",
456
+ "cmd": "apt-get install -y python3 python3-pip sudo --fix-missing",
457
+ "retries": 5,
458
+ "retry_delay_s": 5,
459
+ "retry_on": [
460
+ "lock-frontend",
461
+ "Unable to acquire the dpkg frontend lock",
462
+ "Temporary failure resolving",
463
+ "Could not resolve",
464
+ "Failed to fetch",
465
+ ],
466
+ },
467
+ {"name": "user_exists", "cmd": f"id -u {user} >/dev/null 2>&1 || adduser --disabled-password --gecos '' {user}", "retries": 0},
468
+ {"name": "add_sudo", "cmd": f"usermod -aG sudo {user}", "retries": 0},
469
+ ]
654
470
  if password:
655
471
  steps.append({"name": "set_password", "cmd": f"echo '{user}:{password}' | chpasswd", "retries": 0})
656
472
  if ssh_key:
657
- steps.append(
658
- {
659
- "name": "add_ssh_key",
660
- "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
661
- "retries": 0,
662
- }
663
- )
473
+ steps.append({
474
+ "name": "add_ssh_key",
475
+ "cmd": f"install -d -m 700 /home/{user}/.ssh && echo '{ssh_key}' >> /home/{user}/.ssh/authorized_keys && chown -R {user}:{user} /home/{user}/.ssh",
476
+ "retries": 0,
477
+ })
664
478
  steps.extend(
665
479
  [
666
480
  {"name": "pip_upgrade", "cmd": "python3 -m pip install --upgrade pip", "retries": 0},
@@ -672,45 +486,6 @@ def _build_bootstrap_steps(
672
486
  return steps
673
487
 
674
488
 
675
- def _guess_package_manager_from_template(template: str) -> str:
676
- normalized = (template or "").lower()
677
- if "alpine" in normalized:
678
- return "apk"
679
- if "archlinux" in normalized:
680
- return "pacman"
681
- if "centos-7" in normalized:
682
- return "yum"
683
- if any(keyword in normalized for keyword in ("centos-8", "centos-9", "centos-9-stream", "centos-8-stream")):
684
- return "dnf"
685
- if any(keyword in normalized for keyword in ("rockylinux", "almalinux", "fedora")):
686
- return "dnf"
687
- if "opensuse" in normalized or "suse" in normalized:
688
- return "zypper"
689
- if any(keyword in normalized for keyword in ("debian", "ubuntu", "devuan", "turnkeylinux")):
690
- return "apt"
691
- if normalized.startswith("system/") and "linux" in normalized:
692
- return "apt"
693
- return "apt"
694
-
695
-
696
- def _detect_package_manager(vmid: int) -> str:
697
- candidates = [
698
- ("apt", "apt-get"),
699
- ("dnf", "dnf"),
700
- ("yum", "yum"),
701
- ("apk", "apk"),
702
- ("pacman", "pacman"),
703
- ("zypper", "zypper"),
704
- ]
705
- for name, binary in candidates:
706
- res = _run_pct(vmid, f"command -v {binary} >/dev/null 2>&1")
707
- if res.get("returncode") == 0:
708
- logger.debug("Detected package manager %s inside container %s", name, vmid)
709
- return name
710
- logger.warning("Unable to detect package manager inside container %s; defaulting to apt", vmid)
711
- return "apt"
712
-
713
-
714
489
  def _get_storage_type(storages: Iterable[Dict[str, Any]], storage_name: str) -> str:
715
490
  for entry in storages:
716
491
  if entry.get("storage") == storage_name:
@@ -898,8 +673,7 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
898
673
 
899
674
 
900
675
  def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
901
- shell = "/bin/sh"
902
- full = ["pct", "exec", str(vmid), "--", shell, "-c", cmd]
676
+ full = ["pct", "exec", str(vmid), "--", "bash", "-lc", cmd]
903
677
  start = time.time()
904
678
  proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
905
679
  return {
@@ -911,10 +685,6 @@ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str,
911
685
  }
912
686
 
913
687
 
914
- def _su_command(user: str, command: str) -> str:
915
- return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
916
-
917
-
918
688
  def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
919
689
  res = _run_pct(vmid, cmd)
920
690
  if res["returncode"] != 0:
@@ -974,7 +744,7 @@ def _push_bytes_to_container(
974
744
 
975
745
 
976
746
  def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
977
- data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
747
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
978
748
  data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
979
749
  portacode_dir = f"{data_home}/portacode"
980
750
  _run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
@@ -991,19 +761,18 @@ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: s
991
761
 
992
762
 
993
763
  def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
994
- su_connect_cmd = _su_command(user, "portacode connect")
995
- cmd = ["pct", "exec", str(vmid), "--", "/bin/sh", "-c", su_connect_cmd]
764
+ cmd = ["pct", "exec", str(vmid), "--", "bash", "-lc", f"su - {user} -c 'portacode connect'"]
996
765
  proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
997
766
  start = time.time()
998
767
 
999
- data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
768
+ data_dir_cmd = f"su - {user} -c 'echo -n ${{XDG_DATA_HOME:-$HOME/.local/share}}'"
1000
769
  data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
1001
770
  key_dir = f"{data_dir}/portacode/keys"
1002
771
  pub_path = f"{key_dir}/id_portacode.pub"
1003
772
  priv_path = f"{key_dir}/id_portacode"
1004
773
 
1005
774
  def file_size(path: str) -> Optional[int]:
1006
- stat_cmd = _su_command(user, f"test -s {path} && stat -c %s {path}")
775
+ stat_cmd = f"su - {user} -c 'test -s {path} && stat -c %s {path}'"
1007
776
  res = _run_pct(vmid, stat_cmd)
1008
777
  if res["returncode"] != 0:
1009
778
  return None
@@ -1061,7 +830,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
1061
830
  final_pub = file_size(pub_path)
1062
831
  final_priv = file_size(priv_path)
1063
832
  if final_pub and final_priv:
1064
- key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
833
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
1065
834
  if not process_exited:
1066
835
  proc.terminate()
1067
836
  try:
@@ -1101,7 +870,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
1101
870
  except subprocess.TimeoutExpired:
1102
871
  proc.kill()
1103
872
 
1104
- key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
873
+ key_res = _run_pct(vmid, f"su - {user} -c 'cat {pub_path}'")
1105
874
  return {
1106
875
  "ok": True,
1107
876
  "public_key": key_res["stdout"].strip(),
@@ -1194,16 +963,7 @@ def _bootstrap_portacode(
1194
963
  total_steps: Optional[int] = None,
1195
964
  default_public_key: Optional[str] = None,
1196
965
  ) -> Tuple[str, List[Dict[str, Any]]]:
1197
- if steps is not None:
1198
- actual_steps = steps
1199
- else:
1200
- detected_manager = _detect_package_manager(vmid)
1201
- actual_steps = _build_bootstrap_steps(
1202
- user,
1203
- password,
1204
- ssh_key,
1205
- package_manager=detected_manager,
1206
- )
966
+ actual_steps = steps if steps is not None else _build_bootstrap_steps(user, password, ssh_key)
1207
967
  results, ok = _run_setup_steps(
1208
968
  vmid,
1209
969
  actual_steps,
@@ -1227,15 +987,6 @@ def _bootstrap_portacode(
1227
987
  else:
1228
988
  command_text = str(command)
1229
989
  command_suffix = f" command={command_text}" if command_text else ""
1230
- stdout = details.get("stdout")
1231
- stderr = details.get("stderr")
1232
- if stdout or stderr:
1233
- logger.debug(
1234
- "Bootstrap command output%s%s%s",
1235
- f" stdout={stdout!r}" if stdout else "",
1236
- " " if stdout and stderr else "",
1237
- f"stderr={stderr!r}" if stderr else "",
1238
- )
1239
990
  if summary:
1240
991
  logger.warning(
1241
992
  "Portacode bootstrap failure summary=%s%s%s",
@@ -1243,15 +994,10 @@ def _bootstrap_portacode(
1243
994
  f" history_len={len(history)}" if history else "",
1244
995
  f" command={command_text}" if command_text else "",
1245
996
  )
1246
- logger.error(
1247
- "Portacode bootstrap command failed%s%s%s",
1248
- f" command={command_text}" if command_text else "",
1249
- f" stdout={stdout!r}" if stdout else "",
1250
- f" stderr={stderr!r}" if stderr else "",
1251
- )
1252
- raise RuntimeError(
1253
- f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
1254
- )
997
+ raise RuntimeError(
998
+ f"Portacode bootstrap steps failed: {summary}{history_snippet}{command_suffix}"
999
+ )
1000
+ raise RuntimeError("Portacode bootstrap steps failed.")
1255
1001
  key_step = next((entry for entry in results if entry.get("name") == "portacode_connect"), None)
1256
1002
  public_key = key_step.get("public_key") if key_step else default_public_key
1257
1003
  if not public_key:
@@ -1259,6 +1005,12 @@ def _bootstrap_portacode(
1259
1005
  return public_key, results
1260
1006
 
1261
1007
 
1008
+ def _build_cloudflare_snapshot(cloudflare_config: Dict[str, Any] | None) -> Dict[str, Any]:
1009
+ if not cloudflare_config:
1010
+ return {"configured": False}
1011
+ return {"configured": bool(cloudflare_config.get("api_token"))}
1012
+
1013
+
1262
1014
  def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1263
1015
  network = config.get("network", {})
1264
1016
  base_network = {
@@ -1266,9 +1018,13 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1266
1018
  "message": network.get("message"),
1267
1019
  "bridge": network.get("bridge", DEFAULT_BRIDGE),
1268
1020
  }
1021
+ cloudflare_snapshot = _build_cloudflare_snapshot(config.get("cloudflare"))
1269
1022
  if not config:
1270
- return {"configured": False, "network": base_network}
1271
- _ensure_templates_refreshed_on_startup(config)
1023
+ return {
1024
+ "configured": False,
1025
+ "network": base_network,
1026
+ "cloudflare": cloudflare_snapshot,
1027
+ }
1272
1028
  return {
1273
1029
  "configured": True,
1274
1030
  "host": config.get("host"),
@@ -1279,18 +1035,54 @@ def build_snapshot(config: Dict[str, Any]) -> Dict[str, Any]:
1279
1035
  "templates": config.get("templates") or [],
1280
1036
  "last_verified": config.get("last_verified"),
1281
1037
  "network": base_network,
1038
+ "cloudflare": cloudflare_snapshot,
1282
1039
  }
1283
1040
 
1284
1041
 
1285
- def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl: bool = False) -> Dict[str, Any]:
1042
+ def _resolve_proxmox_credentials(
1043
+ token_identifier: Optional[str],
1044
+ token_value: Optional[str],
1045
+ existing: Dict[str, Any],
1046
+ ) -> Tuple[str, str, str]:
1047
+ if token_identifier:
1048
+ if not token_value:
1049
+ raise ValueError("token_value is required when providing a new token_identifier")
1050
+ user, token_name = _parse_token(token_identifier)
1051
+ return user, token_name, token_value
1052
+ if existing and existing.get("user") and existing.get("token_name") and existing.get("token_value"):
1053
+ return existing["user"], existing["token_name"], existing["token_value"]
1054
+ raise ValueError("Proxmox token identifier and value are required when no existing configuration is available")
1055
+
1056
+
1057
+ def _build_cloudflare_config(existing: Dict[str, Any], api_token: Optional[str]) -> Dict[str, Any]:
1058
+ cloudflare: Dict[str, Any] = dict(existing.get("cloudflare", {}) or {})
1059
+ if api_token:
1060
+ cloudflare["api_token"] = api_token
1061
+ if cloudflare.get("api_token"):
1062
+ cloudflare["configured"] = True
1063
+ elif "configured" in cloudflare:
1064
+ cloudflare.pop("configured", None)
1065
+ return cloudflare
1066
+
1067
+
1068
+ def configure_infrastructure(
1069
+ token_identifier: Optional[str] = None,
1070
+ token_value: Optional[str] = None,
1071
+ verify_ssl: Optional[bool] = None,
1072
+ cloudflare_api_token: Optional[str] = None,
1073
+ ) -> Dict[str, Any]:
1286
1074
  ProxmoxAPI = _ensure_proxmoxer()
1287
- user, token_name = _parse_token(token_identifier)
1075
+ existing = _load_config()
1076
+ user, token_name, resolved_token_value = _resolve_proxmox_credentials(
1077
+ token_identifier, token_value, existing
1078
+ )
1079
+ actual_verify_ssl = verify_ssl if verify_ssl is not None else existing.get("verify_ssl", False)
1288
1080
  client = ProxmoxAPI(
1289
1081
  DEFAULT_HOST,
1290
1082
  user=user,
1291
1083
  token_name=token_name,
1292
- token_value=token_value,
1293
- verify_ssl=verify_ssl,
1084
+ token_value=resolved_token_value,
1085
+ verify_ssl=actual_verify_ssl,
1294
1086
  timeout=30,
1295
1087
  )
1296
1088
  node = _pick_node(client)
@@ -1298,32 +1090,35 @@ def configure_infrastructure(token_identifier: str, token_value: str, verify_ssl
1298
1090
  storages = client.nodes(node).storage.get()
1299
1091
  default_storage = _pick_storage(storages)
1300
1092
  templates = _list_templates(client, node, storages)
1301
- network: Dict[str, Any] = {}
1302
- try:
1303
- network = _ensure_bridge()
1304
- # Wait for network convergence before validating connectivity
1305
- time.sleep(2)
1306
- if not _verify_connectivity():
1307
- raise RuntimeError("Connectivity check failed; bridge reverted")
1308
- network["health"] = "healthy"
1309
- except Exception as exc:
1310
- logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1311
- _revert_bridge()
1312
- raise
1093
+ network = dict(existing.get("network", {}) or {})
1094
+ if not network.get("applied"):
1095
+ try:
1096
+ network = _ensure_bridge()
1097
+ # Wait for network convergence before validating connectivity
1098
+ time.sleep(2)
1099
+ if not _verify_connectivity():
1100
+ raise RuntimeError("Connectivity check failed; bridge reverted")
1101
+ network["health"] = "healthy"
1102
+ except Exception as exc:
1103
+ logger.warning("Bridge setup failed; reverting previous changes: %s", exc)
1104
+ _revert_bridge()
1105
+ raise
1313
1106
  config = {
1314
1107
  "host": DEFAULT_HOST,
1315
1108
  "node": node,
1316
1109
  "user": user,
1317
1110
  "token_name": token_name,
1318
- "token_value": token_value,
1319
- "verify_ssl": verify_ssl,
1111
+ "token_value": resolved_token_value,
1112
+ "verify_ssl": actual_verify_ssl,
1320
1113
  "default_storage": default_storage,
1321
- "last_verified": datetime.utcnow().isoformat() + "Z",
1322
1114
  "templates": templates,
1323
- "templates_last_refreshed": _current_time_iso(),
1115
+ "last_verified": datetime.utcnow().isoformat() + "Z",
1324
1116
  "network": network,
1325
1117
  "node_status": status,
1326
1118
  }
1119
+ cloudflare = _build_cloudflare_config(existing, cloudflare_api_token)
1120
+ if cloudflare:
1121
+ config["cloudflare"] = cloudflare
1327
1122
  _save_config(config)
1328
1123
  snapshot = build_snapshot(config)
1329
1124
  snapshot["node_status"] = status
@@ -1374,7 +1169,7 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1374
1169
  memory=int(payload["ram_mib"]),
1375
1170
  swap=int(payload.get("swap_mb", 0)),
1376
1171
  cores=max(int(payload.get("cores", 1)), 1),
1377
- cpulimit=float(payload.get("cpulimit", payload.get("cpus", 1))),
1172
+ cpuunits=int(payload.get("cpuunits", 256)),
1378
1173
  net0=payload["net0"],
1379
1174
  unprivileged=int(payload.get("unprivileged", 1)),
1380
1175
  description=payload.get("description", MANAGED_MARKER),
@@ -1382,13 +1177,6 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
1382
1177
  ssh_public_keys=payload.get("ssh_public_key") or None,
1383
1178
  )
1384
1179
  status, elapsed = _wait_for_task(proxmox, node, upid)
1385
- exitstatus = (status or {}).get("exitstatus")
1386
- if exitstatus and exitstatus.upper() != "OK":
1387
- msg = status.get("status") or "unknown error"
1388
- details = status.get("error") or status.get("errmsg") or status.get("description") or status
1389
- raise RuntimeError(
1390
- f"Container creation task failed ({exitstatus}): {msg} details={details}"
1391
- )
1392
1180
  return vmid, elapsed
1393
1181
  except ResourceException as exc:
1394
1182
  raise RuntimeError(f"Failed to create container: {exc}") from exc
@@ -1404,24 +1192,16 @@ class CreateProxmoxContainerHandler(SyncHandler):
1404
1192
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1405
1193
  logger.info("create_proxmox_container command received")
1406
1194
  request_id = message.get("request_id")
1407
- raw_device_id = message.get("device_id")
1408
- device_id = str(raw_device_id or "").strip()
1409
- if not device_id:
1410
- raise ValueError("device_id is required to create a container")
1195
+ device_id = message.get("device_id")
1411
1196
  device_public_key = (message.get("device_public_key") or "").strip()
1412
1197
  device_private_key = (message.get("device_private_key") or "").strip()
1413
1198
  has_device_keypair = bool(device_public_key and device_private_key)
1414
- config_guess = _load_config()
1415
- template_candidates = config_guess.get("templates") or []
1416
- template_hint = (message.get("template") or (template_candidates[0] if template_candidates else "")).strip()
1417
- package_manager = _guess_package_manager_from_template(template_hint)
1418
1199
  bootstrap_user, bootstrap_password, bootstrap_ssh_key = _get_provisioning_user_info(message)
1419
1200
  bootstrap_steps = _build_bootstrap_steps(
1420
1201
  bootstrap_user,
1421
1202
  bootstrap_password,
1422
1203
  bootstrap_ssh_key,
1423
1204
  include_portacode_connect=not has_device_keypair,
1424
- package_manager=package_manager,
1425
1205
  )
1426
1206
  total_steps = 3 + len(bootstrap_steps) + 2
1427
1207
  current_step_index = 1
@@ -1444,7 +1224,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1444
1224
  message=start_message,
1445
1225
  phase="lifecycle",
1446
1226
  request_id=request_id,
1447
- on_behalf_of_device=device_id,
1448
1227
  )
1449
1228
  try:
1450
1229
  result = action()
@@ -1460,7 +1239,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1460
1239
  phase="lifecycle",
1461
1240
  request_id=request_id,
1462
1241
  details={"error": str(exc)},
1463
- on_behalf_of_device=device_id,
1464
1242
  )
1465
1243
  raise
1466
1244
  _emit_progress_event(
@@ -1473,7 +1251,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1473
1251
  message=success_message,
1474
1252
  phase="lifecycle",
1475
1253
  request_id=request_id,
1476
- on_behalf_of_device=device_id,
1477
1254
  )
1478
1255
  current_step_index += 1
1479
1256
  return result
@@ -1500,8 +1277,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
1500
1277
  proxmox = _connect_proxmox(config)
1501
1278
  node = config.get("node") or DEFAULT_NODE_NAME
1502
1279
  payload = _build_container_payload(message, config)
1503
- payload["cpulimit"] = float(payload["cpus"])
1504
- payload["cores"] = int(max(math.ceil(payload["cpus"]), 1))
1280
+ payload["cpuunits"] = max(int(payload["cpus"] * 1024), 10)
1505
1281
  payload["memory"] = int(payload["ram_mib"])
1506
1282
  payload["node"] = node
1507
1283
  logger.debug(
@@ -1516,7 +1292,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1516
1292
  payload["vmid"] = vmid
1517
1293
  payload["created_at"] = datetime.utcnow().isoformat() + "Z"
1518
1294
  payload["status"] = "creating"
1519
- payload["device_id"] = device_id
1520
1295
  _write_container_record(vmid, payload)
1521
1296
  return proxmox, node, vmid, payload
1522
1297
 
@@ -1577,7 +1352,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1577
1352
  phase="bootstrap",
1578
1353
  request_id=request_id,
1579
1354
  details=details or None,
1580
- on_behalf_of_device=device_id,
1581
1355
  )
1582
1356
 
1583
1357
  public_key, steps = _bootstrap_portacode(
@@ -1621,7 +1395,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1621
1395
  message="Notifying the server of the new device…",
1622
1396
  phase="service",
1623
1397
  request_id=request_id,
1624
- on_behalf_of_device=device_id,
1625
1398
  )
1626
1399
  _emit_progress_event(
1627
1400
  self,
@@ -1633,7 +1406,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1633
1406
  message="Authentication metadata recorded.",
1634
1407
  phase="service",
1635
1408
  request_id=request_id,
1636
- on_behalf_of_device=device_id,
1637
1409
  )
1638
1410
 
1639
1411
  install_step = service_start_index + 1
@@ -1648,10 +1420,9 @@ class CreateProxmoxContainerHandler(SyncHandler):
1648
1420
  message="Running sudo portacode service install…",
1649
1421
  phase="service",
1650
1422
  request_id=request_id,
1651
- on_behalf_of_device=device_id,
1652
1423
  )
1653
1424
 
1654
- cmd = _su_command(payload["username"], "sudo -S portacode service install")
1425
+ cmd = f"su - {payload['username']} -c 'sudo -S portacode service install'"
1655
1426
  res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
1656
1427
 
1657
1428
  if res["returncode"] != 0:
@@ -1669,7 +1440,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1669
1440
  "stderr": res.get("stderr"),
1670
1441
  "stdout": res.get("stdout"),
1671
1442
  },
1672
- on_behalf_of_device=device_id,
1673
1443
  )
1674
1444
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1675
1445
 
@@ -1683,7 +1453,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1683
1453
  message="Portacode service install finished.",
1684
1454
  phase="service",
1685
1455
  request_id=request_id,
1686
- on_behalf_of_device=device_id,
1687
1456
  )
1688
1457
 
1689
1458
  logger.info("create_proxmox_container: portacode service install completed inside ct %s", vmid)
@@ -1707,7 +1476,6 @@ class CreateProxmoxContainerHandler(SyncHandler):
1707
1476
  },
1708
1477
  "setup_steps": steps,
1709
1478
  "device_id": device_id,
1710
- "on_behalf_of_device": device_id,
1711
1479
  "service_installed": service_installed,
1712
1480
  }
1713
1481
 
@@ -1733,9 +1501,6 @@ class StartPortacodeServiceHandler(SyncHandler):
1733
1501
  password = record.get("password")
1734
1502
  if not user or not password:
1735
1503
  raise RuntimeError("Container credentials unavailable")
1736
- on_behalf_of_device = record.get("device_id")
1737
- if on_behalf_of_device:
1738
- on_behalf_of_device = str(on_behalf_of_device)
1739
1504
 
1740
1505
  start_index = int(message.get("step_index", 1))
1741
1506
  total_steps = int(message.get("total_steps", start_index + 2))
@@ -1753,7 +1518,6 @@ class StartPortacodeServiceHandler(SyncHandler):
1753
1518
  message="Notifying the server of the new device…",
1754
1519
  phase="service",
1755
1520
  request_id=request_id,
1756
- on_behalf_of_device=on_behalf_of_device,
1757
1521
  )
1758
1522
  _emit_progress_event(
1759
1523
  self,
@@ -1765,7 +1529,6 @@ class StartPortacodeServiceHandler(SyncHandler):
1765
1529
  message="Authentication metadata recorded.",
1766
1530
  phase="service",
1767
1531
  request_id=request_id,
1768
- on_behalf_of_device=on_behalf_of_device,
1769
1532
  )
1770
1533
 
1771
1534
  install_step = start_index + 1
@@ -1780,10 +1543,9 @@ class StartPortacodeServiceHandler(SyncHandler):
1780
1543
  message="Running sudo portacode service install…",
1781
1544
  phase="service",
1782
1545
  request_id=request_id,
1783
- on_behalf_of_device=on_behalf_of_device,
1784
1546
  )
1785
1547
 
1786
- cmd = _su_command(user, "sudo -S portacode service install")
1548
+ cmd = f"su - {user} -c 'sudo -S portacode service install'"
1787
1549
  res = _run_pct(vmid, cmd, input_text=password + "\n")
1788
1550
 
1789
1551
  if res["returncode"] != 0:
@@ -1801,7 +1563,6 @@ class StartPortacodeServiceHandler(SyncHandler):
1801
1563
  "stderr": res.get("stderr"),
1802
1564
  "stdout": res.get("stdout"),
1803
1565
  },
1804
- on_behalf_of_device=on_behalf_of_device,
1805
1566
  )
1806
1567
  raise RuntimeError(res.get("stderr") or res.get("stdout") or "Service install failed")
1807
1568
 
@@ -1815,7 +1576,6 @@ class StartPortacodeServiceHandler(SyncHandler):
1815
1576
  message="Portacode service install finished.",
1816
1577
  phase="service",
1817
1578
  request_id=request_id,
1818
- on_behalf_of_device=on_behalf_of_device,
1819
1579
  )
1820
1580
 
1821
1581
  return {
@@ -1934,10 +1694,13 @@ class ConfigureProxmoxInfraHandler(SyncHandler):
1934
1694
  def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
1935
1695
  token_identifier = message.get("token_identifier")
1936
1696
  token_value = message.get("token_value")
1937
- verify_ssl = bool(message.get("verify_ssl"))
1938
- if not token_identifier or not token_value:
1939
- raise ValueError("token_identifier and token_value are required")
1940
- snapshot = configure_infrastructure(token_identifier, token_value, verify_ssl=verify_ssl)
1697
+ verify_ssl = message.get("verify_ssl")
1698
+ snapshot = configure_infrastructure(
1699
+ token_identifier=token_identifier,
1700
+ token_value=token_value,
1701
+ verify_ssl=verify_ssl,
1702
+ cloudflare_api_token=message.get("cloudflare_api_token"),
1703
+ )
1941
1704
  return {
1942
1705
  "event": "proxmox_infra_configured",
1943
1706
  "success": True,
@@ -685,7 +685,6 @@ class TerminalManager:
685
685
  try:
686
686
  # Initialize project state
687
687
  project_state = await manager.initialize_project_state(session_name, project_folder_path)
688
- await self._restore_tabs_from_session_metadata(manager, project_state, session)
689
688
 
690
689
  # Send initial project state to the client
691
690
  initial_state_payload = {
@@ -711,85 +710,6 @@ class TerminalManager:
711
710
 
712
711
  except Exception as exc:
713
712
  logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
714
-
715
- async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
716
- """Restore open tabs/active tab from client session metadata if available."""
717
- if not session or not project_state:
718
- return
719
-
720
- descriptors = session.get("open_tabs") or []
721
- if not descriptors:
722
- return
723
-
724
- session_id = project_state.client_session_id
725
- logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
726
-
727
- for descriptor in descriptors:
728
- parsed = self._parse_tab_descriptor(descriptor)
729
- if not parsed:
730
- continue
731
-
732
- tab_type = parsed.get("tab_type")
733
- file_path = parsed.get("file_path")
734
- metadata = parsed.get("metadata", {})
735
-
736
- if tab_type == "file" and file_path:
737
- try:
738
- await manager.open_file(session_id, file_path, set_active=False)
739
- except Exception as exc:
740
- logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
741
- continue
742
-
743
- if tab_type == "diff" and file_path:
744
- from_ref = metadata.get("from") or metadata.get("from_ref")
745
- to_ref = metadata.get("to") or metadata.get("to_ref")
746
- if not from_ref or not to_ref:
747
- logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
748
- continue
749
- from_hash = metadata.get("from_hash") or metadata.get("fromHash")
750
- to_hash = metadata.get("to_hash") or metadata.get("toHash")
751
- try:
752
- await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
753
- except Exception as exc:
754
- logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
755
- continue
756
-
757
- logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
758
-
759
- active_index = session.get("active_tab")
760
- try:
761
- active_index_int = int(active_index) if active_index is not None else None
762
- except (TypeError, ValueError):
763
- active_index_int = None
764
-
765
- if active_index_int is not None and active_index_int >= 0:
766
- current_tabs = list(project_state.open_tabs.values())
767
- if 0 <= active_index_int < len(current_tabs):
768
- try:
769
- await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
770
- except Exception as exc:
771
- logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
772
- else:
773
- logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
774
-
775
- def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
776
- """Parse a URL-friendly tab descriptor string."""
777
- if not descriptor:
778
- return None
779
-
780
- try:
781
- parts = descriptor.split("|")
782
- tab_type = parts[0] if parts else None
783
- file_path = parts[1] if len(parts) > 1 else None
784
- metadata = {}
785
- for part in parts[2:]:
786
- if "=" in part:
787
- key, value = part.split("=", 1)
788
- metadata[key] = value
789
- return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
790
- except Exception as exc:
791
- logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
792
- return None
793
713
 
794
714
  async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
795
715
  """Send terminal_list command to specific client sessions.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.4.15
3
+ Version: 1.4.15.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -1,7 +1,7 @@
1
1
  portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
2
2
  portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
3
3
  portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
4
- portacode/_version.py,sha256=etxcB5M9Bu6fDP8xB_u0QGAYtqm9iTbzbI4qxCPucxs,706
4
+ portacode/_version.py,sha256=ZC_kn1NEQ9Aet-DPiqGe5ZTNbDk0ZOpeb6OhHRUx77M,719
5
5
  portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
6
6
  portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
7
7
  portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
12
12
  portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
13
13
  portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
14
14
  portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
15
- portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
15
+ portacode/connection/terminal.py,sha256=oyLPOVLPlUuN_eRvHPGazB51yi8W8JEF3oOEYxucGTE,45069
16
16
  portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
17
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=OtObmoVAXlf0uU1HidTWNmyJYBS1Yl6Rpgyh6TOTjUQ,100590
17
+ portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=bW3coRPyolXX8GIFQXfOyonxg2ad7UOfmMenN23_5FQ,99509
18
18
  portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
19
19
  portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
20
20
  portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
22
22
  portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
23
23
  portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
24
24
  portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
25
- portacode/connection/handlers/proxmox_infra.py,sha256=DE2TUPKwW4bZ9L7vxyE_m74-ra0JR9DfUCkUOPg5tpk,71908
25
+ portacode/connection/handlers/proxmox_infra.py,sha256=K9s0e9lb97I9_kNmnpoJMpoO1uTrl2ef9KTUwIbiF-g,63287
26
26
  portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
27
27
  portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
28
28
  portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
35
35
  portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
36
36
  portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
37
37
  portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
38
- portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
38
+ portacode/connection/handlers/project_state/manager.py,sha256=pRMZqPOTK9YE3abNxiAbnERIJmRys673HFOEIBiKnm4,67184
39
39
  portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
40
40
  portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
41
41
  portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
65
65
  portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
66
66
  portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
67
67
  portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
68
- portacode-1.4.15.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
68
+ portacode-1.4.15.dev0.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
69
69
  test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
70
70
  test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
71
71
  test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
91
91
  testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
92
92
  testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
93
93
  testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
94
- portacode-1.4.15.dist-info/METADATA,sha256=RFm2fBFzusB-xJHakI-C1nr-5_lNUhRwFQea5lg7sX8,13046
95
- portacode-1.4.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
96
- portacode-1.4.15.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
- portacode-1.4.15.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
- portacode-1.4.15.dist-info/RECORD,,
94
+ portacode-1.4.15.dev0.dist-info/METADATA,sha256=R7FEzw07kw5YY1i3Sj0-W88K5pOVHVwMYGPE1kVDduA,13051
95
+ portacode-1.4.15.dev0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
96
+ portacode-1.4.15.dev0.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
97
+ portacode-1.4.15.dev0.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
98
+ portacode-1.4.15.dev0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.2)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5