portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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.

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -18,8 +18,11 @@ import json
18
18
  import logging
19
19
  import os
20
20
  import time
21
+ from dataclasses import asdict
21
22
  from typing import Any, Dict, Optional, List
22
23
 
24
+ from websockets.exceptions import ConnectionClosedError
25
+
23
26
  from .multiplex import Multiplexer, Channel
24
27
  from .handlers import (
25
28
  CommandRegistry,
@@ -29,16 +32,33 @@ from .handlers import (
29
32
  TerminalListHandler,
30
33
  SystemInfoHandler,
31
34
  FileReadHandler,
32
- FileWriteHandler,
33
35
  DirectoryListHandler,
34
36
  FileInfoHandler,
35
37
  FileDeleteHandler,
38
+ FileSearchHandler,
39
+ FileRenameHandler,
40
+ ContentRequestHandler,
41
+ FileApplyDiffHandler,
42
+ FilePreviewDiffHandler,
36
43
  ProjectStateFolderExpandHandler,
37
44
  ProjectStateFolderCollapseHandler,
38
45
  ProjectStateFileOpenHandler,
39
46
  ProjectStateTabCloseHandler,
40
47
  ProjectStateSetActiveTabHandler,
41
48
  ProjectStateDiffOpenHandler,
49
+ ProjectStateDiffContentHandler,
50
+ ProjectStateGitStageHandler,
51
+ ProjectStateGitUnstageHandler,
52
+ ProjectStateGitRevertHandler,
53
+ ProjectStateGitCommitHandler,
54
+ UpdatePortacodeHandler,
55
+ ConfigureProxmoxInfraHandler,
56
+ RevertProxmoxInfraHandler,
57
+ )
58
+ from .handlers.project_aware_file_handlers import (
59
+ ProjectAwareFileWriteHandler,
60
+ ProjectAwareFileCreateHandler,
61
+ ProjectAwareFolderCreateHandler,
42
62
  )
43
63
  from .handlers.session import SessionManager
44
64
 
@@ -74,21 +94,36 @@ class ClientSessionManager:
74
94
  logger.info(f"Newly added sessions: {newly_added_sessions}")
75
95
  if disconnected_sessions:
76
96
  logger.info(f"Disconnected sessions: {disconnected_sessions}")
77
- # Trigger cleanup for disconnected sessions
78
- self._cleanup_disconnected_sessions(disconnected_sessions)
97
+ # NOTE: Not automatically cleaning up project states for disconnected sessions
98
+ # to handle temporary disconnections gracefully. Project states will be cleaned
99
+ # up only when explicitly notified by the server of permanent disconnection.
100
+ logger.info("Project states preserved for potential reconnection of these sessions")
101
+
102
+ # Handle project state management based on session changes
103
+ if newly_added_sessions or disconnected_sessions:
104
+ # Schedule project state management to run asynchronously
105
+ import asyncio
106
+ try:
107
+ loop = asyncio.get_event_loop()
108
+ if loop.is_running():
109
+ # Schedule both cleanup and initialization
110
+ loop.create_task(self._manage_project_states_for_session_changes(newly_added_sessions, sessions))
111
+ else:
112
+ logger.debug("No event loop running, skipping project state management")
113
+ except Exception as e:
114
+ logger.debug("Could not schedule project state management: %s", e)
79
115
 
80
116
  self._write_debug_file()
81
117
  return newly_added_sessions
82
118
 
83
- def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
84
- """Clean up resources for disconnected client sessions."""
85
- logger.info("Cleaning up resources for %d disconnected sessions", len(disconnected_sessions))
119
+ async def cleanup_client_session_explicitly(self, client_session_id: str):
120
+ """Explicitly clean up resources for a client session when notified by server."""
121
+ logger.info("Explicitly cleaning up resources for client session: %s", client_session_id)
86
122
 
87
123
  # Import here to avoid circular imports
88
124
  from .handlers.project_state_handlers import _get_or_create_project_state_manager
89
125
 
90
126
  # Get the project state manager from context if it exists
91
- # We need to get the context from the terminal manager
92
127
  if hasattr(self, '_terminal_manager') and self._terminal_manager:
93
128
  context = getattr(self._terminal_manager, '_context', {})
94
129
  if context:
@@ -96,17 +131,116 @@ class ClientSessionManager:
96
131
  control_channel = getattr(self._terminal_manager, '_control_channel', None)
97
132
  if control_channel:
98
133
  project_manager = _get_or_create_project_state_manager(context, control_channel)
99
-
100
- # Clean up project states for each disconnected session
101
- for client_session_id in disconnected_sessions:
102
- logger.info("Cleaning up project states for disconnected session: %s", client_session_id)
103
- project_manager.cleanup_projects_by_client_session(client_session_id)
134
+ logger.info("Cleaning up project state for client session: %s", client_session_id)
135
+ await project_manager.cleanup_projects_by_client_session(client_session_id)
104
136
  else:
105
137
  logger.warning("No control channel available for project state cleanup")
106
138
  else:
107
139
  logger.warning("No context available for project state cleanup")
108
140
  else:
109
141
  logger.warning("No terminal manager available for project state cleanup")
142
+
143
+ async def _manage_project_states_for_session_changes(self, newly_added_sessions: List[str], all_sessions: List[Dict]):
144
+ """Comprehensive project state management for session changes."""
145
+ try:
146
+ # Import here to avoid circular imports
147
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
148
+
149
+ if not hasattr(self, '_terminal_manager') or not self._terminal_manager:
150
+ logger.warning("No terminal manager available for project state management")
151
+ return
152
+
153
+ context = getattr(self._terminal_manager, '_context', {})
154
+ if not context:
155
+ logger.warning("No context available for project state management")
156
+ return
157
+
158
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
159
+ if not control_channel:
160
+ logger.warning("No control channel available for project state management")
161
+ return
162
+
163
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
164
+
165
+ # Convert sessions list to dict for easier lookup
166
+ sessions_dict = {session.get('channel_name'): session for session in all_sessions if session.get('channel_name')}
167
+
168
+ # First, clean up project states for sessions that are no longer project sessions or don't exist
169
+ current_project_sessions = set()
170
+ for session in all_sessions:
171
+ channel_name = session.get('channel_name')
172
+ project_id = session.get('project_id')
173
+ project_folder_path = session.get('project_folder_path')
174
+
175
+ if channel_name and project_id is not None and project_folder_path:
176
+ current_project_sessions.add(channel_name)
177
+ logger.debug(f"Active project session: {channel_name} -> {project_folder_path} (project_id: {project_id})")
178
+
179
+ # Clean up project states that don't match current project sessions
180
+ existing_project_states = list(project_manager.projects.keys())
181
+ for session_id in existing_project_states:
182
+ if session_id not in current_project_sessions:
183
+ logger.info(f"Cleaning up project state for session {session_id} (no longer a project session)")
184
+ await project_manager.cleanup_project(session_id)
185
+
186
+ # Initialize project states for new project sessions
187
+ for session_name in newly_added_sessions:
188
+ session = sessions_dict.get(session_name)
189
+ if not session:
190
+ continue
191
+
192
+ project_id = session.get('project_id')
193
+ project_folder_path = session.get('project_folder_path')
194
+
195
+ if project_id is not None and project_folder_path:
196
+ if session_name in project_manager.projects:
197
+ logger.info("Project state already exists for session %s, skipping re-init", session_name)
198
+ continue
199
+ logger.info(f"Initializing project state for new project session {session_name}: {project_folder_path}")
200
+
201
+ try:
202
+ # Initialize project state (this includes migration logic)
203
+ project_state = await project_manager.initialize_project_state(session_name, project_folder_path)
204
+ logger.info(f"Successfully initialized project state for {session_name}")
205
+
206
+ # Send initial project state to the client
207
+ # (implementation can be added here if needed)
208
+
209
+ except Exception as e:
210
+ logger.error(f"Failed to initialize project state for {session_name}: {e}")
211
+ except Exception as e:
212
+ logger.error("Error managing project states for session changes: %s", e)
213
+
214
+ async def _cleanup_orphaned_project_states(self):
215
+ """Clean up project states that don't match any current client session."""
216
+ try:
217
+ # Import here to avoid circular imports
218
+ from .handlers.project_state_handlers import _get_or_create_project_state_manager
219
+
220
+ # Get current client session IDs
221
+ current_sessions = list(self._client_sessions.keys())
222
+
223
+ if hasattr(self, '_terminal_manager') and self._terminal_manager:
224
+ context = getattr(self._terminal_manager, '_context', {})
225
+ if context:
226
+ control_channel = getattr(self._terminal_manager, '_control_channel', None)
227
+ if control_channel:
228
+ project_manager = _get_or_create_project_state_manager(context, control_channel)
229
+ await project_manager.cleanup_orphaned_project_states(current_sessions)
230
+ else:
231
+ logger.warning("No control channel available for orphaned project state cleanup")
232
+ else:
233
+ logger.warning("No context available for orphaned project state cleanup")
234
+ else:
235
+ logger.warning("No terminal manager available for orphaned project state cleanup")
236
+
237
+ except Exception as e:
238
+ logger.error("Error cleaning up orphaned project states: %s", e)
239
+
240
+ def _cleanup_disconnected_sessions(self, disconnected_sessions: List[str]):
241
+ """Legacy method - now just logs disconnections without cleanup."""
242
+ logger.info("Sessions disconnected (but preserving project states): %s", disconnected_sessions)
243
+ # Project states are preserved to handle reconnections gracefully
110
244
 
111
245
  def set_terminal_manager(self, terminal_manager):
112
246
  """Set reference to terminal manager for cleanup purposes."""
@@ -277,6 +411,7 @@ class TerminalManager:
277
411
  "session_manager": self._session_manager,
278
412
  "client_session_manager": self._client_session_manager,
279
413
  "mux": mux,
414
+ "use_content_caching": True, # Enable content caching optimization
280
415
  "debug": self.debug,
281
416
  }
282
417
 
@@ -294,6 +429,14 @@ class TerminalManager:
294
429
  pass
295
430
  self._ctl_task = asyncio.create_task(self._control_loop())
296
431
 
432
+ # Start periodic system info sender
433
+ if getattr(self, "_system_info_task", None):
434
+ try:
435
+ self._system_info_task.cancel()
436
+ except Exception:
437
+ pass
438
+ self._system_info_task = asyncio.create_task(self._periodic_system_info())
439
+
297
440
  # For initial connections, request client sessions after control loop starts
298
441
  if is_initial:
299
442
  asyncio.create_task(self._initial_connection_setup())
@@ -307,10 +450,17 @@ class TerminalManager:
307
450
  self._command_registry.register(SystemInfoHandler)
308
451
  # File operation handlers
309
452
  self._command_registry.register(FileReadHandler)
310
- self._command_registry.register(FileWriteHandler)
453
+ self._command_registry.register(ProjectAwareFileWriteHandler) # Use project-aware version
311
454
  self._command_registry.register(DirectoryListHandler)
312
455
  self._command_registry.register(FileInfoHandler)
313
456
  self._command_registry.register(FileDeleteHandler)
457
+ self._command_registry.register(ProjectAwareFileCreateHandler) # Use project-aware version
458
+ self._command_registry.register(ProjectAwareFolderCreateHandler) # Use project-aware version
459
+ self._command_registry.register(FileRenameHandler)
460
+ self._command_registry.register(FileSearchHandler)
461
+ self._command_registry.register(ContentRequestHandler)
462
+ self._command_registry.register(FileApplyDiffHandler)
463
+ self._command_registry.register(FilePreviewDiffHandler)
314
464
  # Project state handlers
315
465
  self._command_registry.register(ProjectStateFolderExpandHandler)
316
466
  self._command_registry.register(ProjectStateFolderCollapseHandler)
@@ -318,6 +468,15 @@ class TerminalManager:
318
468
  self._command_registry.register(ProjectStateTabCloseHandler)
319
469
  self._command_registry.register(ProjectStateSetActiveTabHandler)
320
470
  self._command_registry.register(ProjectStateDiffOpenHandler)
471
+ self._command_registry.register(ProjectStateDiffContentHandler)
472
+ self._command_registry.register(ProjectStateGitStageHandler)
473
+ self._command_registry.register(ProjectStateGitUnstageHandler)
474
+ self._command_registry.register(ProjectStateGitRevertHandler)
475
+ self._command_registry.register(ProjectStateGitCommitHandler)
476
+ # System management handlers
477
+ self._command_registry.register(ConfigureProxmoxInfraHandler)
478
+ self._command_registry.register(RevertProxmoxInfraHandler)
479
+ self._command_registry.register(UpdatePortacodeHandler)
321
480
 
322
481
  # ---------------------------------------------------------------------
323
482
  # Control loop – receives commands from gateway
@@ -363,9 +522,10 @@ class TerminalManager:
363
522
  logger.info("terminal_manager: ✅ Updated client sessions (%d sessions)", len(sessions))
364
523
 
365
524
  # Auto-send initial data only to newly added clients
525
+ # Create a background task so it doesn't block the control loop
366
526
  if newly_added_sessions:
367
- logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients")
368
- await self._send_initial_data_to_clients(newly_added_sessions)
527
+ logger.info("terminal_manager: 🚀 Triggering auto-send of initial data to newly added clients (non-blocking)")
528
+ asyncio.create_task(self._send_initial_data_to_clients(newly_added_sessions))
369
529
  else:
370
530
  logger.info("terminal_manager: ℹ️ No new sessions to send data to")
371
531
  continue
@@ -381,6 +541,20 @@ class TerminalManager:
381
541
  # Continue processing other messages
382
542
  continue
383
543
 
544
+ async def _periodic_system_info(self) -> None:
545
+ """Send system_info event every 10 seconds when clients are connected."""
546
+ while True:
547
+ try:
548
+ await asyncio.sleep(10)
549
+ if self._client_session_manager.has_interested_clients():
550
+ from .handlers.system_handlers import SystemInfoHandler
551
+ handler = SystemInfoHandler(self._control_channel, self._context)
552
+ system_info = handler.execute({})
553
+ await self._send_session_aware(system_info)
554
+ except Exception as exc:
555
+ logger.exception("Error in periodic system info: %s", exc)
556
+ continue
557
+
384
558
  async def _send_initial_data_to_clients(self, newly_added_sessions: List[str] = None):
385
559
  """Send initial system info and terminal list to connected clients.
386
560
 
@@ -489,9 +663,10 @@ class TerminalManager:
489
663
  if not session:
490
664
  continue
491
665
 
666
+ project_id = session.get("project_id")
492
667
  project_folder_path = session.get("project_folder_path")
493
- if not project_folder_path:
494
- logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_folder_path, skipping")
668
+ if project_id is None or not project_folder_path:
669
+ logger.debug(f"terminal_manager: 🌳 Session {session_name} has no project_id or project_folder_path, skipping")
495
670
  continue
496
671
 
497
672
  logger.info(f"terminal_manager: 🌳 Initializing project state for session {session_name} with folder: {project_folder_path}")
@@ -503,11 +678,13 @@ class TerminalManager:
503
678
  # Send initial project state to the client
504
679
  initial_state_payload = {
505
680
  "event": "project_state_initialized",
681
+ "project_id": project_state.client_session_id, # Add missing project_id field
506
682
  "project_folder_path": project_state.project_folder_path,
507
683
  "is_git_repo": project_state.is_git_repo,
508
684
  "git_branch": project_state.git_branch,
509
685
  "git_status_summary": project_state.git_status_summary,
510
- "open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs],
686
+ "git_detailed_status": asdict(project_state.git_detailed_status) if project_state.git_detailed_status and hasattr(project_state.git_detailed_status, '__dataclass_fields__') else None, # Add missing git_detailed_status field
687
+ "open_tabs": [manager._serialize_tab_info(tab) for tab in project_state.open_tabs.values()], # Fix to use .values() for dict
511
688
  "active_tab": manager._serialize_tab_info(project_state.active_tab) if project_state.active_tab else None,
512
689
  "items": [manager._serialize_file_item(item) for item in project_state.items],
513
690
  "timestamp": time.time(),
@@ -601,15 +778,17 @@ class TerminalManager:
601
778
  payload: The message payload to send
602
779
  project_id: Optional project filter for targeting specific sessions
603
780
  """
781
+ event_type = payload.get("event", "unknown")
782
+
604
783
  # Check if there are any interested clients
605
784
  if not self._client_session_manager.has_interested_clients():
606
- logger.debug("terminal_manager: No interested clients, skipping message send")
785
+ logger.info("terminal_manager: No interested clients for %s event, skipping send", event_type)
607
786
  return
608
787
 
609
788
  # Get target sessions
610
789
  target_sessions = self._client_session_manager.get_target_sessions(project_id)
611
790
  if not target_sessions:
612
- logger.debug("terminal_manager: No target sessions found, skipping message send")
791
+ logger.info("terminal_manager: No target sessions found for %s event (project_id=%s), skipping send", event_type, project_id)
613
792
  return
614
793
 
615
794
  # Add session targeting information
@@ -621,10 +800,21 @@ class TerminalManager:
621
800
  if reply_channel and "reply_channel" not in enhanced_payload:
622
801
  enhanced_payload["reply_channel"] = reply_channel
623
802
 
624
- logger.debug("terminal_manager: Sending to %d client sessions: %s",
625
- len(target_sessions), target_sessions)
803
+ # Log all event dispatches at INFO level, with data size for terminal_data
804
+ if event_type == "terminal_data":
805
+ data_size = len(payload.get("data", ""))
806
+ terminal_id = payload.get("channel", "unknown")
807
+ logger.info("terminal_manager: Dispatching %s event (terminal_id=%s, data_size=%d bytes) to %d client sessions",
808
+ event_type, terminal_id, data_size, len(target_sessions))
809
+ # else:
810
+ # logger.info("terminal_manager: Dispatching %s event to %d client sessions",
811
+ # event_type, len(target_sessions))
626
812
 
627
- await self._control_channel.send(enhanced_payload)
813
+ try:
814
+ await self._control_channel.send(enhanced_payload)
815
+ except ConnectionClosedError as exc:
816
+ logger.warning("terminal_manager: Connection closed (%s); skipping %s event", exc, event_type)
817
+ return
628
818
 
629
819
  async def _send_terminal_list(self) -> None:
630
820
  """Send terminal list for reconnection reconciliation."""
@@ -681,4 +871,4 @@ class TerminalManager:
681
871
  await self._request_client_sessions()
682
872
  logger.info("Client session request sent after reconnection")
683
873
  except Exception as exc:
684
- logger.error("Failed to handle reconnection: %s", exc)
874
+ 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
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" elinks "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gio-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gnome-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" gvfs-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kde-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" kfmclient "$@"
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ COMMAND_NAME="$1"
4
+ shift
5
+
6
+ if [ -z "$COMMAND_NAME" ]; then
7
+ echo "link_capture: missing command name" >&2
8
+ exit 1
9
+ fi
10
+
11
+ exec "$SCRIPT_DIR/link_capture_wrapper.py" "$COMMAND_NAME" "$@"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env python3
2
+ """Simple link capture wrapper that never executes a native browser."""
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+ from urllib.parse import urlparse
11
+
12
+ LINK_CHANNEL_ENV = "PORTACODE_LINK_CHANNEL"
13
+ TERMINAL_ID_ENV = "PORTACODE_TERMINAL_ID"
14
+
15
+
16
+ def _find_link_argument(args):
17
+ for arg in args:
18
+ if not isinstance(arg, str):
19
+ continue
20
+ parsed = urlparse(arg)
21
+ if parsed.scheme and parsed.netloc:
22
+ return arg
23
+ if arg.startswith("file://"):
24
+ return arg
25
+ return None
26
+
27
+
28
+ def _write_capture_event(cmd_name, args, link):
29
+ channel = os.environ.get(LINK_CHANNEL_ENV)
30
+ terminal_id = os.environ.get(TERMINAL_ID_ENV)
31
+ if not channel or not link:
32
+ return
33
+ payload = {
34
+ "terminal_id": terminal_id,
35
+ "command": cmd_name,
36
+ "args": args,
37
+ "url": link,
38
+ "timestamp": time.time(),
39
+ }
40
+ directory = Path(channel)
41
+ try:
42
+ directory.mkdir(parents=True, exist_ok=True)
43
+ except Exception:
44
+ return
45
+ temp_file = directory / f".{uuid.uuid4().hex}.tmp"
46
+ term_label = terminal_id or "unknown"
47
+ base_name = f"{int(time.time() * 1000)}-{term_label}"
48
+ final_file = directory / f"{base_name}.json"
49
+ suffix = 0
50
+ while final_file.exists():
51
+ suffix += 1
52
+ final_file = directory / f"{base_name}-{suffix}.json"
53
+ try:
54
+ temp_file.write_text(json.dumps(payload), encoding="utf-8")
55
+ temp_file.replace(final_file)
56
+ except Exception:
57
+ if temp_file.exists():
58
+ temp_file.unlink(missing_ok=True)
59
+
60
+
61
+ def main() -> None:
62
+ if len(sys.argv) < 2:
63
+ sys.stderr.write("link_capture: missing target command name\n")
64
+ sys.exit(1)
65
+ cmd_name = sys.argv[1]
66
+ cmd_args = sys.argv[2:]
67
+ link = _find_link_argument(cmd_args)
68
+ if link:
69
+ _write_capture_event(cmd_name, cmd_args, link)
70
+ # Never run a real browser; capture and exit successfully.
71
+ sys.exit(0)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" links2 "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" lynx "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" mate-open "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" netsurf "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" sensible-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" w3m "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" x-www-browser "$@"
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
3
+ exec "$SCRIPT_DIR/link_capture_exec.sh" xdg-open "$@"