portacode 1.3.32__py3-none-any.whl → 1.4.11.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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +119 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +301 -4
- portacode/connection/handlers/__init__.py +10 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +307 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +140 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +51 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev0.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev0.dist-info}/top_level.txt +0 -0
|
@@ -1,17 +1,148 @@
|
|
|
1
1
|
"""System command handlers."""
|
|
2
2
|
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import getpass
|
|
5
|
+
import importlib.util
|
|
3
6
|
import logging
|
|
4
7
|
import os
|
|
5
8
|
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import threading
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
from typing import Any, Dict
|
|
13
|
+
|
|
8
14
|
from portacode import __version__
|
|
9
15
|
import psutil
|
|
10
16
|
|
|
17
|
+
try:
|
|
18
|
+
from importlib import metadata as importlib_metadata
|
|
19
|
+
except ImportError: # pragma: no cover - py<3.8
|
|
20
|
+
import importlib_metadata
|
|
21
|
+
|
|
11
22
|
from .base import SyncHandler
|
|
23
|
+
from .proxmox_infra import get_infra_snapshot
|
|
12
24
|
|
|
13
25
|
logger = logging.getLogger(__name__)
|
|
14
26
|
|
|
27
|
+
# Global CPU monitoring
|
|
28
|
+
_cpu_percent = 0.0
|
|
29
|
+
_cpu_thread = None
|
|
30
|
+
_cpu_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
def _cpu_monitor():
|
|
33
|
+
"""Background thread to update CPU usage every 5 seconds."""
|
|
34
|
+
global _cpu_percent
|
|
35
|
+
while True:
|
|
36
|
+
_cpu_percent = psutil.cpu_percent(interval=5.0)
|
|
37
|
+
|
|
38
|
+
def _ensure_cpu_thread():
|
|
39
|
+
"""Ensure CPU monitoring thread is running (singleton)."""
|
|
40
|
+
global _cpu_thread
|
|
41
|
+
with _cpu_lock:
|
|
42
|
+
if _cpu_thread is None or not _cpu_thread.is_alive():
|
|
43
|
+
_cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
|
|
44
|
+
_cpu_thread.start()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_user_context() -> Dict[str, Any]:
|
|
48
|
+
"""Gather current CLI user plus permission hints."""
|
|
49
|
+
context = {}
|
|
50
|
+
login_source = "os.getlogin"
|
|
51
|
+
try:
|
|
52
|
+
username = os.getlogin()
|
|
53
|
+
except Exception:
|
|
54
|
+
login_source = "getpass"
|
|
55
|
+
username = getpass.getuser()
|
|
56
|
+
|
|
57
|
+
context["username"] = username
|
|
58
|
+
context["username_source"] = login_source
|
|
59
|
+
context["home"] = str(Path.home())
|
|
60
|
+
|
|
61
|
+
uid = getattr(os, "getuid", None)
|
|
62
|
+
euid = getattr(os, "geteuid", None)
|
|
63
|
+
context["uid"] = uid() if uid else None
|
|
64
|
+
context["euid"] = euid() if euid else context["uid"]
|
|
65
|
+
if os.name == "nt":
|
|
66
|
+
try:
|
|
67
|
+
import ctypes
|
|
68
|
+
|
|
69
|
+
context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
70
|
+
except Exception:
|
|
71
|
+
context["is_root"] = None
|
|
72
|
+
else:
|
|
73
|
+
context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
|
|
74
|
+
|
|
75
|
+
context["has_sudo"] = shutil.which("sudo") is not None
|
|
76
|
+
context["sudo_user"] = os.environ.get("SUDO_USER")
|
|
77
|
+
context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
|
|
78
|
+
return context
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_playwright_info() -> Dict[str, Any]:
|
|
82
|
+
"""Return Playwright presence, version, and browser binaries if available."""
|
|
83
|
+
result: Dict[str, Any] = {
|
|
84
|
+
"installed": False,
|
|
85
|
+
"version": None,
|
|
86
|
+
"browsers": {},
|
|
87
|
+
"error": None,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if importlib.util.find_spec("playwright") is None:
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
result["installed"] = True
|
|
94
|
+
try:
|
|
95
|
+
result["version"] = importlib_metadata.version("playwright")
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.debug("Unable to read Playwright version metadata: %s", exc)
|
|
98
|
+
|
|
99
|
+
def _inspect_browsers() -> Dict[str, Any]:
|
|
100
|
+
from playwright.sync_api import sync_playwright
|
|
101
|
+
|
|
102
|
+
browsers_data: Dict[str, Any] = {}
|
|
103
|
+
with sync_playwright() as p:
|
|
104
|
+
for name in ("chromium", "firefox", "webkit"):
|
|
105
|
+
browser_type = getattr(p, name, None)
|
|
106
|
+
if browser_type is None:
|
|
107
|
+
continue
|
|
108
|
+
exec_path = getattr(browser_type, "executable_path", None)
|
|
109
|
+
browsers_data[name] = {
|
|
110
|
+
"available": bool(exec_path),
|
|
111
|
+
"executable_path": exec_path,
|
|
112
|
+
}
|
|
113
|
+
return browsers_data
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
117
|
+
future = executor.submit(_inspect_browsers)
|
|
118
|
+
browsers = future.result(timeout=5)
|
|
119
|
+
result["browsers"] = browsers
|
|
120
|
+
except concurrent.futures.TimeoutError:
|
|
121
|
+
msg = "Playwright inspection timed out"
|
|
122
|
+
logger.warning(msg)
|
|
123
|
+
result["error"] = msg
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.warning("Playwright browser inspection failed: %s", exc)
|
|
126
|
+
result["error"] = str(exc)
|
|
127
|
+
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_proxmox_info() -> Dict[str, Any]:
|
|
132
|
+
"""Detect if the current host is a Proxmox node."""
|
|
133
|
+
info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
|
|
134
|
+
release_file = Path("/etc/proxmox-release")
|
|
135
|
+
if release_file.exists():
|
|
136
|
+
info["is_proxmox_node"] = True
|
|
137
|
+
try:
|
|
138
|
+
info["version"] = release_file.read_text().strip()
|
|
139
|
+
except Exception:
|
|
140
|
+
info["version"] = None
|
|
141
|
+
elif Path("/etc/pve").exists():
|
|
142
|
+
info["is_proxmox_node"] = True
|
|
143
|
+
info["infra"] = get_infra_snapshot()
|
|
144
|
+
return info
|
|
145
|
+
|
|
15
146
|
|
|
16
147
|
def _get_os_info() -> Dict[str, Any]:
|
|
17
148
|
"""Get operating system information with robust error handling."""
|
|
@@ -98,15 +229,13 @@ class SystemInfoHandler(SyncHandler):
|
|
|
98
229
|
"""Get system information including OS details."""
|
|
99
230
|
logger.debug("Collecting system information...")
|
|
100
231
|
|
|
232
|
+
# Ensure CPU monitoring thread is running
|
|
233
|
+
_ensure_cpu_thread()
|
|
234
|
+
|
|
101
235
|
# Collect basic system metrics
|
|
102
236
|
info = {}
|
|
103
237
|
|
|
104
|
-
|
|
105
|
-
info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
|
|
106
|
-
logger.debug("CPU usage: %s%%", info["cpu_percent"])
|
|
107
|
-
except Exception as e:
|
|
108
|
-
logger.warning("Failed to get CPU info: %s", e)
|
|
109
|
-
info["cpu_percent"] = 0.0
|
|
238
|
+
info["cpu_percent"] = _cpu_percent
|
|
110
239
|
|
|
111
240
|
try:
|
|
112
241
|
info["memory"] = psutil.virtual_memory()._asdict()
|
|
@@ -124,11 +253,14 @@ class SystemInfoHandler(SyncHandler):
|
|
|
124
253
|
|
|
125
254
|
# Add OS information - this is critical for proper shell detection
|
|
126
255
|
info["os_info"] = _get_os_info()
|
|
127
|
-
|
|
256
|
+
info["user_context"] = _get_user_context()
|
|
257
|
+
info["playwright"] = _get_playwright_info()
|
|
258
|
+
info["proxmox"] = _get_proxmox_info()
|
|
259
|
+
# logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
|
|
128
260
|
|
|
129
261
|
info["portacode_version"] = __version__
|
|
130
262
|
|
|
131
263
|
return {
|
|
132
264
|
"event": "system_info",
|
|
133
265
|
"info": info,
|
|
134
|
-
}
|
|
266
|
+
}
|
|
@@ -162,53 +162,7 @@ class TabFactory:
|
|
|
162
162
|
await self._load_binary_content(file_path, tab_info, file_size)
|
|
163
163
|
|
|
164
164
|
return TabInfo(**tab_info)
|
|
165
|
-
|
|
166
|
-
async def create_diff_tab(self, file_path: str, original_content: str,
|
|
167
|
-
modified_content: str, tab_id: Optional[str] = None,
|
|
168
|
-
diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
|
|
169
|
-
"""Create a diff tab for comparing file versions.
|
|
170
|
-
|
|
171
|
-
Args:
|
|
172
|
-
file_path: Path to the file being compared
|
|
173
|
-
original_content: Original version of the file
|
|
174
|
-
modified_content: Modified version of the file
|
|
175
|
-
tab_id: Optional tab ID, will generate UUID if not provided
|
|
176
|
-
diff_details: Optional detailed diff information from diff-match-patch
|
|
177
|
-
|
|
178
|
-
Returns:
|
|
179
|
-
TabInfo object configured for diff viewing
|
|
180
|
-
"""
|
|
181
|
-
if tab_id is None:
|
|
182
|
-
tab_id = str(uuid.uuid4())
|
|
183
|
-
|
|
184
|
-
file_path = Path(file_path)
|
|
185
|
-
|
|
186
|
-
metadata = {'diff_mode': True}
|
|
187
|
-
if diff_details:
|
|
188
|
-
metadata['diff_details'] = diff_details
|
|
189
|
-
|
|
190
|
-
# Cache diff content
|
|
191
|
-
original_hash = generate_content_hash(original_content)
|
|
192
|
-
modified_hash = generate_content_hash(modified_content)
|
|
193
|
-
cache_content(original_hash, original_content)
|
|
194
|
-
cache_content(modified_hash, modified_content)
|
|
195
|
-
|
|
196
|
-
return TabInfo(
|
|
197
|
-
tab_id=tab_id,
|
|
198
|
-
tab_type='diff',
|
|
199
|
-
title=f"{file_path.name} (diff)",
|
|
200
|
-
file_path=str(file_path),
|
|
201
|
-
content=None, # Diff tabs don't use regular content
|
|
202
|
-
original_content=original_content,
|
|
203
|
-
modified_content=modified_content,
|
|
204
|
-
original_content_hash=original_hash,
|
|
205
|
-
modified_content_hash=modified_hash,
|
|
206
|
-
is_dirty=False,
|
|
207
|
-
mime_type=None,
|
|
208
|
-
encoding='utf-8',
|
|
209
|
-
metadata=metadata
|
|
210
|
-
)
|
|
211
|
-
|
|
165
|
+
|
|
212
166
|
async def create_diff_tab_with_title(self, file_path: str, original_content: str,
|
|
213
167
|
modified_content: str, title: str,
|
|
214
168
|
tab_id: Optional[str] = None,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Update handler for Portacode CLI."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
from .base import AsyncHandler
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UpdatePortacodeHandler(AsyncHandler):
|
|
13
|
+
"""Handler for updating Portacode CLI."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def command_name(self) -> str:
|
|
17
|
+
return "update_portacode_cli"
|
|
18
|
+
|
|
19
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
20
|
+
"""Update Portacode package and restart process."""
|
|
21
|
+
try:
|
|
22
|
+
logger.info("Starting Portacode CLI update...")
|
|
23
|
+
|
|
24
|
+
# Update the package
|
|
25
|
+
result = subprocess.run([
|
|
26
|
+
sys.executable, "-m", "pip", "install", "--upgrade", "portacode"
|
|
27
|
+
], capture_output=True, text=True, timeout=120)
|
|
28
|
+
|
|
29
|
+
if result.returncode != 0:
|
|
30
|
+
logger.error("Update failed: %s", result.stderr)
|
|
31
|
+
return {
|
|
32
|
+
"event": "update_portacode_response",
|
|
33
|
+
"success": False,
|
|
34
|
+
"error": f"Update failed: {result.stderr}"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info("Update successful, restarting process...")
|
|
38
|
+
|
|
39
|
+
# Send success response before exit
|
|
40
|
+
await self.send_response({
|
|
41
|
+
"event": "update_portacode_response",
|
|
42
|
+
"success": True,
|
|
43
|
+
"message": "Update completed. Process restarting..."
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
# Exit with special code to trigger restart
|
|
47
|
+
sys.exit(42)
|
|
48
|
+
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
return {
|
|
51
|
+
"event": "update_portacode_response",
|
|
52
|
+
"success": False,
|
|
53
|
+
"error": "Update timed out after 120 seconds"
|
|
54
|
+
}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.exception("Update failed with exception")
|
|
57
|
+
return {
|
|
58
|
+
"event": "update_portacode_response",
|
|
59
|
+
"success": False,
|
|
60
|
+
"error": str(e)
|
|
61
|
+
}
|
portacode/connection/terminal.py
CHANGED
|
@@ -21,6 +21,8 @@ import time
|
|
|
21
21
|
from dataclasses import asdict
|
|
22
22
|
from typing import Any, Dict, Optional, List
|
|
23
23
|
|
|
24
|
+
from websockets.exceptions import ConnectionClosedError
|
|
25
|
+
|
|
24
26
|
from .multiplex import Multiplexer, Channel
|
|
25
27
|
from .handlers import (
|
|
26
28
|
CommandRegistry,
|
|
@@ -33,8 +35,11 @@ from .handlers import (
|
|
|
33
35
|
DirectoryListHandler,
|
|
34
36
|
FileInfoHandler,
|
|
35
37
|
FileDeleteHandler,
|
|
38
|
+
FileSearchHandler,
|
|
36
39
|
FileRenameHandler,
|
|
37
40
|
ContentRequestHandler,
|
|
41
|
+
FileApplyDiffHandler,
|
|
42
|
+
FilePreviewDiffHandler,
|
|
38
43
|
ProjectStateFolderExpandHandler,
|
|
39
44
|
ProjectStateFolderCollapseHandler,
|
|
40
45
|
ProjectStateFileOpenHandler,
|
|
@@ -46,6 +51,8 @@ from .handlers import (
|
|
|
46
51
|
ProjectStateGitUnstageHandler,
|
|
47
52
|
ProjectStateGitRevertHandler,
|
|
48
53
|
ProjectStateGitCommitHandler,
|
|
54
|
+
UpdatePortacodeHandler,
|
|
55
|
+
ConfigureProxmoxInfraHandler,
|
|
49
56
|
)
|
|
50
57
|
from .handlers.project_aware_file_handlers import (
|
|
51
58
|
ProjectAwareFileWriteHandler,
|
|
@@ -108,7 +115,7 @@ class ClientSessionManager:
|
|
|
108
115
|
self._write_debug_file()
|
|
109
116
|
return newly_added_sessions
|
|
110
117
|
|
|
111
|
-
def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
118
|
+
async def cleanup_client_session_explicitly(self, client_session_id: str):
|
|
112
119
|
"""Explicitly clean up resources for a client session when notified by server."""
|
|
113
120
|
logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
|
|
114
121
|
|
|
@@ -124,7 +131,7 @@ class ClientSessionManager:
|
|
|
124
131
|
if control_channel:
|
|
125
132
|
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
126
133
|
logger.info("Cleaning up project state for client session: %s", client_session_id)
|
|
127
|
-
project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
134
|
+
await project_manager.cleanup_projects_by_client_session(client_session_id)
|
|
128
135
|
else:
|
|
129
136
|
logger.warning("No control channel available for project state cleanup")
|
|
130
137
|
else:
|
|
@@ -173,7 +180,7 @@ class ClientSessionManager:
|
|
|
173
180
|
for session_id in existing_project_states:
|
|
174
181
|
if session_id not in current_project_sessions:
|
|
175
182
|
logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
|
|
176
|
-
project_manager.cleanup_project(session_id)
|
|
183
|
+
await project_manager.cleanup_project(session_id)
|
|
177
184
|
|
|
178
185
|
# Initialize project states for new project sessions
|
|
179
186
|
for session_name in newly_added_sessions:
|
|
@@ -185,6 +192,9 @@ class ClientSessionManager:
|
|
|
185
192
|
project_folder_path = session.get('project_folder_path')
|
|
186
193
|
|
|
187
194
|
if project_id is not None and project_folder_path:
|
|
195
|
+
if session_name in project_manager.projects:
|
|
196
|
+
logger.info("Project state already exists for session %s, skipping re-init", session_name)
|
|
197
|
+
continue
|
|
188
198
|
logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
|
|
189
199
|
|
|
190
200
|
try:
|
|
@@ -197,7 +207,6 @@ class ClientSessionManager:
|
|
|
197
207
|
|
|
198
208
|
except Exception as e:
|
|
199
209
|
logger.error(f"Failed to initialize project state for {session_name}: {e}")
|
|
200
|
-
|
|
201
210
|
except Exception as e:
|
|
202
211
|
logger.error("Error managing project states for session changes: %s", e)
|
|
203
212
|
|
|
@@ -216,7 +225,7 @@ class ClientSessionManager:
|
|
|
216
225
|
control_channel = getattr(self._terminal_manager, '_control_channel', None)
|
|
217
226
|
if control_channel:
|
|
218
227
|
project_manager = _get_or_create_project_state_manager(context, control_channel)
|
|
219
|
-
project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
228
|
+
await project_manager.cleanup_orphaned_project_states(current_sessions)
|
|
220
229
|
else:
|
|
221
230
|
logger.warning("No control channel available for orphaned project state cleanup")
|
|
222
231
|
else:
|
|
@@ -419,6 +428,14 @@ class TerminalManager:
|
|
|
419
428
|
pass
|
|
420
429
|
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
421
430
|
|
|
431
|
+
# Start periodic system info sender
|
|
432
|
+
if getattr(self, "_system_info_task", None):
|
|
433
|
+
try:
|
|
434
|
+
self._system_info_task.cancel()
|
|
435
|
+
except Exception:
|
|
436
|
+
pass
|
|
437
|
+
self._system_info_task = asyncio.create_task(self._periodic_system_info())
|
|
438
|
+
|
|
422
439
|
# For initial connections, request client sessions after control loop starts
|
|
423
440
|
if is_initial:
|
|
424
441
|
asyncio.create_task(self._initial_connection_setup())
|
|
@@ -439,7 +456,10 @@ class TerminalManager:
|
|
|
439
456
|
self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
|
|
440
457
|
self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
|
|
441
458
|
self._command_registry.register(FileRenameHandler)
|
|
459
|
+
self._command_registry.register(FileSearchHandler)
|
|
442
460
|
self._command_registry.register(ContentRequestHandler)
|
|
461
|
+
self._command_registry.register(FileApplyDiffHandler)
|
|
462
|
+
self._command_registry.register(FilePreviewDiffHandler)
|
|
443
463
|
# Project state handlers
|
|
444
464
|
self._command_registry.register(ProjectStateFolderExpandHandler)
|
|
445
465
|
self._command_registry.register(ProjectStateFolderCollapseHandler)
|
|
@@ -452,6 +472,9 @@ class TerminalManager:
|
|
|
452
472
|
self._command_registry.register(ProjectStateGitUnstageHandler)
|
|
453
473
|
self._command_registry.register(ProjectStateGitRevertHandler)
|
|
454
474
|
self._command_registry.register(ProjectStateGitCommitHandler)
|
|
475
|
+
# System management handlers
|
|
476
|
+
self._command_registry.register(ConfigureProxmoxInfraHandler)
|
|
477
|
+
self._command_registry.register(UpdatePortacodeHandler)
|
|
455
478
|
|
|
456
479
|
# ---------------------------------------------------------------------
|
|
457
480
|
# Control loop – receives commands from gateway
|
|
@@ -516,6 +539,20 @@ class TerminalManager:
|
|
|
516
539
|
# Continue processing other messages
|
|
517
540
|
continue
|
|
518
541
|
|
|
542
|
+
async def _periodic_system_info(self) -> None:
|
|
543
|
+
"""Send system_info event every 10 seconds when clients are connected."""
|
|
544
|
+
while True:
|
|
545
|
+
try:
|
|
546
|
+
await asyncio.sleep(10)
|
|
547
|
+
if self._client_session_manager.has_interested_clients():
|
|
548
|
+
from .handlers.system_handlers import SystemInfoHandler
|
|
549
|
+
handler = SystemInfoHandler(self._control_channel, self._context)
|
|
550
|
+
system_info = handler.execute({})
|
|
551
|
+
await self._send_session_aware(system_info)
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
logger.exception("Error in periodic system info: %s", exc)
|
|
554
|
+
continue
|
|
555
|
+
|
|
519
556
|
async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
|
|
520
557
|
"""Send initial system info and terminal list to connected clients.
|
|
521
558
|
|
|
@@ -767,11 +804,15 @@ class TerminalManager:
|
|
|
767
804
|
terminal_id = payload.get("channel", "unknown")
|
|
768
805
|
logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
|
|
769
806
|
event_type, terminal_id, data_size, len(target_sessions))
|
|
770
|
-
else:
|
|
771
|
-
|
|
772
|
-
|
|
807
|
+
# else:
|
|
808
|
+
# logger.info("terminal_manager: Dispatching %s event to %d client sessions",
|
|
809
|
+
# event_type, len(target_sessions))
|
|
773
810
|
|
|
774
|
-
|
|
811
|
+
try:
|
|
812
|
+
await self._control_channel.send(enhanced_payload)
|
|
813
|
+
except ConnectionClosedError as exc:
|
|
814
|
+
logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
|
|
815
|
+
return
|
|
775
816
|
|
|
776
817
|
async def _send_terminal_list(self) -> None:
|
|
777
818
|
"""Send terminal list for reconnection reconciliation."""
|
|
@@ -828,4 +869,4 @@ class TerminalManager:
|
|
|
828
869
|
await self._request_client_sessions()
|
|
829
870
|
logger.info("Client session request sent after reconnection")
|
|
830
871
|
except Exception as exc:
|
|
831
|
-
logger.error("Failed to handle reconnection: %s", exc)
|
|
872
|
+
logger.error("Failed to handle reconnection: %s", exc)
|
portacode/keypair.py
CHANGED
|
@@ -98,4 +98,66 @@ def fingerprint_public_key(pem: bytes) -> str:
|
|
|
98
98
|
"""Return a short fingerprint for display purposes (SHA-256)."""
|
|
99
99
|
digest = hashes.Hash(hashes.SHA256())
|
|
100
100
|
digest.update(pem)
|
|
101
|
-
return digest.finalize().hex()[:16]
|
|
101
|
+
return digest.finalize().hex()[:16]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InMemoryKeyPair:
|
|
105
|
+
"""Keypair kept purely in memory until explicitly persisted."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, private_pem: bytes, public_pem: bytes, key_dir: Path):
|
|
108
|
+
self._private_pem = private_pem
|
|
109
|
+
self._public_pem = public_pem
|
|
110
|
+
self._key_dir = key_dir
|
|
111
|
+
self._is_new = True
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def private_key_pem(self) -> bytes:
|
|
115
|
+
return self._private_pem
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def public_key_pem(self) -> bytes:
|
|
119
|
+
return self._public_pem
|
|
120
|
+
|
|
121
|
+
def sign_challenge(self, challenge: str) -> bytes:
|
|
122
|
+
private_key = serialization.load_pem_private_key(self._private_pem, password=None)
|
|
123
|
+
return private_key.sign(
|
|
124
|
+
challenge.encode(),
|
|
125
|
+
padding.PKCS1v15(),
|
|
126
|
+
hashes.SHA256(),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def public_key_der_b64(self) -> str:
|
|
130
|
+
pubkey = serialization.load_pem_public_key(self._public_pem)
|
|
131
|
+
der = pubkey.public_bytes(
|
|
132
|
+
encoding=serialization.Encoding.DER,
|
|
133
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
134
|
+
)
|
|
135
|
+
return base64.b64encode(der).decode()
|
|
136
|
+
|
|
137
|
+
def persist(self) -> KeyPair:
|
|
138
|
+
"""Write the keypair to disk and return a regular KeyPair."""
|
|
139
|
+
key_dir = self._key_dir
|
|
140
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
priv_path = key_dir / PRIVATE_KEY_FILE
|
|
142
|
+
pub_path = key_dir / PUBLIC_KEY_FILE
|
|
143
|
+
priv_path.write_bytes(self._private_pem)
|
|
144
|
+
pub_path.write_bytes(self._public_pem)
|
|
145
|
+
keypair = KeyPair(priv_path, pub_path)
|
|
146
|
+
keypair._is_new = True # type: ignore[attr-defined]
|
|
147
|
+
keypair._key_dir = key_dir # type: ignore[attr-defined]
|
|
148
|
+
return keypair
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def generate_in_memory_keypair() -> InMemoryKeyPair:
|
|
152
|
+
"""Generate a new keypair but keep it in memory until pairing succeeds."""
|
|
153
|
+
private_pem, public_pem = _generate_keypair()
|
|
154
|
+
key_dir = get_key_dir()
|
|
155
|
+
return InMemoryKeyPair(private_pem, public_pem, key_dir)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def keypair_files_exist() -> bool:
|
|
159
|
+
"""Return True if the persisted keypair already exists on disk."""
|
|
160
|
+
key_dir = get_key_dir()
|
|
161
|
+
priv_path = key_dir / PRIVATE_KEY_FILE
|
|
162
|
+
pub_path = key_dir / PUBLIC_KEY_FILE
|
|
163
|
+
return priv_path.exists() and pub_path.exists()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Helpers for the link capture wrapper scripts."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
# Use the stdlib helpers when they are available (Python ≥3.9).
|
|
10
|
+
from importlib.resources import as_file, files
|
|
11
|
+
except ImportError: # pragma: no cover
|
|
12
|
+
# Fall back to the backport for older Python 3.x runtimes (>=3.6).
|
|
13
|
+
from importlib_resources import as_file, files
|
|
14
|
+
|
|
15
|
+
_LINK_CAPTURE_TEMP_DIR: Optional[Path] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def prepare_link_capture_bin() -> Optional[Path]:
|
|
19
|
+
"""Extract the packaged link capture wrappers into a temporary dir and return it."""
|
|
20
|
+
global _LINK_CAPTURE_TEMP_DIR
|
|
21
|
+
if _LINK_CAPTURE_TEMP_DIR:
|
|
22
|
+
return _LINK_CAPTURE_TEMP_DIR
|
|
23
|
+
|
|
24
|
+
bin_source = files(__package__) / "bin"
|
|
25
|
+
if not bin_source.is_dir():
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="portacode-link-capture-"))
|
|
29
|
+
for entry in bin_source.iterdir():
|
|
30
|
+
if not entry.is_file():
|
|
31
|
+
continue
|
|
32
|
+
with as_file(entry) as file_path:
|
|
33
|
+
dest = temp_dir / entry.name
|
|
34
|
+
shutil.copyfile(file_path, dest)
|
|
35
|
+
dest.chmod(dest.stat().st_mode | 0o111)
|
|
36
|
+
|
|
37
|
+
_LINK_CAPTURE_TEMP_DIR = temp_dir
|
|
38
|
+
return _LINK_CAPTURE_TEMP_DIR
|
|
Binary file
|