portacode 0.1.0__tar.gz
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-0.1.0/MANIFEST.in +0 -0
- portacode-0.1.0/PKG-INFO +64 -0
- portacode-0.1.0/README.md +31 -0
- portacode-0.1.0/portacode/__init__.py +16 -0
- portacode-0.1.0/portacode/cli.py +192 -0
- portacode-0.1.0/portacode/connection/__init__.py +7 -0
- portacode-0.1.0/portacode/connection/client.py +180 -0
- portacode-0.1.0/portacode/connection/multiplex.py +65 -0
- portacode-0.1.0/portacode/connection/terminal.py +421 -0
- portacode-0.1.0/portacode/data.py +61 -0
- portacode-0.1.0/portacode/keypair.py +95 -0
- portacode-0.1.0/portacode.egg-info/PKG-INFO +64 -0
- portacode-0.1.0/portacode.egg-info/SOURCES.txt +17 -0
- portacode-0.1.0/portacode.egg-info/dependency_links.txt +1 -0
- portacode-0.1.0/portacode.egg-info/entry_points.txt +2 -0
- portacode-0.1.0/portacode.egg-info/requires.txt +15 -0
- portacode-0.1.0/portacode.egg-info/top_level.txt +1 -0
- portacode-0.1.0/setup.cfg +4 -0
- portacode-0.1.0/setup.py +43 -0
|
File without changes
|
portacode-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portacode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Portacode CLI client and SDK
|
|
5
|
+
Home-page: https://github.com/portacode/portacode
|
|
6
|
+
Author: Meena Erian
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: click>=8.0
|
|
13
|
+
Requires-Dist: platformdirs>=3.0
|
|
14
|
+
Requires-Dist: cryptography>=41.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
Requires-Dist: pyperclip>=1.8
|
|
17
|
+
Requires-Dist: psutil>=5.9
|
|
18
|
+
Requires-Dist: pyte>=0.8
|
|
19
|
+
Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: black; extra == "dev"
|
|
22
|
+
Requires-Dist: flake8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Dynamic: author
|
|
25
|
+
Dynamic: classifier
|
|
26
|
+
Dynamic: description
|
|
27
|
+
Dynamic: description-content-type
|
|
28
|
+
Dynamic: home-page
|
|
29
|
+
Dynamic: provides-extra
|
|
30
|
+
Dynamic: requires-dist
|
|
31
|
+
Dynamic: requires-python
|
|
32
|
+
Dynamic: summary
|
|
33
|
+
|
|
34
|
+
# Portacode
|
|
35
|
+
|
|
36
|
+
Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ pip install portacode
|
|
40
|
+
$ portacode connect
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The first release only ships the `connect` command which:
|
|
44
|
+
|
|
45
|
+
1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
|
|
46
|
+
2. Guides you through adding the public key to your Portacode account.
|
|
47
|
+
3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
|
|
48
|
+
|
|
49
|
+
Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
|
|
50
|
+
|
|
51
|
+
## Project layout
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
portacode/ ‑ Top-level package
|
|
55
|
+
├── cli.py ‑ Click-based CLI entry-point
|
|
56
|
+
├── data.py ‑ Cross-platform data-directory helpers
|
|
57
|
+
├── keypair.py ‑ RSA key generation & storage
|
|
58
|
+
├── connection/ ‑ Networking & multiplexing logic
|
|
59
|
+
│ ├── client.py ‑ WebSocket client with auto-reconnect
|
|
60
|
+
│ └── multiplex.py- Virtual channel multiplexer
|
|
61
|
+
└── …
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See the README files inside each sub-module for more details.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Portacode
|
|
2
|
+
|
|
3
|
+
Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ pip install portacode
|
|
7
|
+
$ portacode connect
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The first release only ships the `connect` command which:
|
|
11
|
+
|
|
12
|
+
1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
|
|
13
|
+
2. Guides you through adding the public key to your Portacode account.
|
|
14
|
+
3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
|
|
15
|
+
|
|
16
|
+
Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
|
|
17
|
+
|
|
18
|
+
## Project layout
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
portacode/ ‑ Top-level package
|
|
22
|
+
├── cli.py ‑ Click-based CLI entry-point
|
|
23
|
+
├── data.py ‑ Cross-platform data-directory helpers
|
|
24
|
+
├── keypair.py ‑ RSA key generation & storage
|
|
25
|
+
├── connection/ ‑ Networking & multiplexing logic
|
|
26
|
+
│ ├── client.py ‑ WebSocket client with auto-reconnect
|
|
27
|
+
│ └── multiplex.py- Virtual channel multiplexer
|
|
28
|
+
└── …
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See the README files inside each sub-module for more details.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Portacode SDK & CLI.
|
|
2
|
+
|
|
3
|
+
This package exposes a top-level `cli` entry-point for interacting with the
|
|
4
|
+
Portacode gateway and also provides programmatic helpers for managing user
|
|
5
|
+
configuration and network connections.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__: str = version("portacode") # type: ignore[arg-type]
|
|
12
|
+
except PackageNotFoundError: # pragma: no cover
|
|
13
|
+
# Package is not installed – most likely running from source tree.
|
|
14
|
+
__version__ = "0.0.0.dev0"
|
|
15
|
+
|
|
16
|
+
__all__: list[str] = ["__version__"]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from multiprocessing import Process
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import signal
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import pyperclip
|
|
13
|
+
|
|
14
|
+
from .data import get_pid_file, is_process_running
|
|
15
|
+
from .keypair import get_or_create_keypair, fingerprint_public_key
|
|
16
|
+
from .connection.client import ConnectionManager, run_until_interrupt
|
|
17
|
+
|
|
18
|
+
GATEWAY_URL = "wss://portacode.com/gateway"
|
|
19
|
+
GATEWAY_ENV = "PORTACODE_GATEWAY"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def cli() -> None:
|
|
24
|
+
"""Portacode command-line interface."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.command()
|
|
28
|
+
@click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
|
|
29
|
+
@click.option("--detach", "detach", "-d", is_flag=True, help="Run connection in background")
|
|
30
|
+
def connect(gateway: str | None, detach: bool) -> None: # noqa: D401 – Click callback
|
|
31
|
+
"""Connect this machine to Portacode gateway."""
|
|
32
|
+
|
|
33
|
+
# 1. Ensure only a single connection per user
|
|
34
|
+
pid_file = get_pid_file()
|
|
35
|
+
if pid_file.exists():
|
|
36
|
+
try:
|
|
37
|
+
other_pid = int(pid_file.read_text())
|
|
38
|
+
except ValueError:
|
|
39
|
+
other_pid = None
|
|
40
|
+
|
|
41
|
+
if other_pid and is_process_running(other_pid):
|
|
42
|
+
click.echo(
|
|
43
|
+
click.style(
|
|
44
|
+
f"Another portacode connection (PID {other_pid}) is active.", fg="yellow"
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
if click.confirm("Terminate the existing connection?", default=False):
|
|
48
|
+
_terminate_process(other_pid)
|
|
49
|
+
pid_file.unlink(missing_ok=True)
|
|
50
|
+
else:
|
|
51
|
+
click.echo("Aborting.")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
else:
|
|
54
|
+
# Stale pidfile
|
|
55
|
+
pid_file.unlink(missing_ok=True)
|
|
56
|
+
|
|
57
|
+
# Determine gateway URL
|
|
58
|
+
target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
|
|
59
|
+
|
|
60
|
+
# 2. Load or create keypair
|
|
61
|
+
keypair = get_or_create_keypair()
|
|
62
|
+
fingerprint = fingerprint_public_key(keypair.public_key_pem)
|
|
63
|
+
|
|
64
|
+
pubkey_b64 = keypair.public_key_der_b64()
|
|
65
|
+
click.echo()
|
|
66
|
+
click.echo(click.style("✔ Generated / loaded RSA keypair", fg="green"))
|
|
67
|
+
|
|
68
|
+
click.echo()
|
|
69
|
+
click.echo(click.style("Public key (copy & paste to your Portacode account):", bold=True))
|
|
70
|
+
click.echo("-" * 60)
|
|
71
|
+
click.echo(pubkey_b64)
|
|
72
|
+
click.echo("-" * 60)
|
|
73
|
+
try:
|
|
74
|
+
pyperclip.copy(pubkey_b64)
|
|
75
|
+
click.echo(click.style("(Copied to clipboard!)", fg="cyan"))
|
|
76
|
+
except Exception:
|
|
77
|
+
click.echo(click.style("(Could not copy to clipboard. Please copy manually.)", fg="yellow"))
|
|
78
|
+
click.echo(f"Fingerprint: {fingerprint}")
|
|
79
|
+
click.echo()
|
|
80
|
+
click.prompt("Press <enter> once the key is added", default="", show_default=False)
|
|
81
|
+
|
|
82
|
+
# 3. Start connection manager
|
|
83
|
+
if detach:
|
|
84
|
+
click.echo("Establishing connection in the background…")
|
|
85
|
+
p = Process(target=_run_connection_forever, args=(target_gateway, keypair, pid_file))
|
|
86
|
+
p.daemon = False # We want it to live beyond parent process on POSIX; on Windows it's anyway independent
|
|
87
|
+
p.start()
|
|
88
|
+
click.echo(click.style(f"Background process PID: {p.pid}", fg="green"))
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
# Foreground mode → run in current event-loop
|
|
92
|
+
pid_file.write_text(str(os.getpid()))
|
|
93
|
+
|
|
94
|
+
async def _main() -> None:
|
|
95
|
+
mgr = ConnectionManager(target_gateway, keypair)
|
|
96
|
+
await run_until_interrupt(mgr)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
asyncio.run(_main())
|
|
100
|
+
finally:
|
|
101
|
+
pid_file.unlink(missing_ok=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _run_connection_forever(url: str, keypair, pid_file: Path):
|
|
105
|
+
"""Entry-point for detached background process."""
|
|
106
|
+
try:
|
|
107
|
+
pid_file.write_text(str(os.getpid()))
|
|
108
|
+
|
|
109
|
+
async def _main() -> None:
|
|
110
|
+
mgr = ConnectionManager(url, keypair)
|
|
111
|
+
await run_until_interrupt(mgr)
|
|
112
|
+
|
|
113
|
+
asyncio.run(_main())
|
|
114
|
+
finally:
|
|
115
|
+
pid_file.unlink(missing_ok=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _terminate_process(pid: int):
|
|
119
|
+
if sys.platform.startswith("win"):
|
|
120
|
+
import ctypes
|
|
121
|
+
PROCESS_TERMINATE = 1
|
|
122
|
+
handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
|
123
|
+
if handle:
|
|
124
|
+
ctypes.windll.kernel32.TerminateProcess(handle, -1)
|
|
125
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
os.kill(pid, signal.SIGTERM) # type: ignore[name-defined]
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Debug helpers – NOT intended for production use
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cli.command()
|
|
139
|
+
@click.argument("message", nargs=1)
|
|
140
|
+
@click.option("--gateway", "gateway", "-g", help="Gateway websocket URL (overrides env/ default)")
|
|
141
|
+
def send_control(message: str, gateway: str | None) -> None: # noqa: D401 – Click callback
|
|
142
|
+
"""Send a raw JSON *control* message on channel 0 and print replies.
|
|
143
|
+
|
|
144
|
+
Example::
|
|
145
|
+
|
|
146
|
+
portacode send-control '{"cmd": "system_info"}'
|
|
147
|
+
|
|
148
|
+
The command opens a short-lived connection, authenticates, sends the
|
|
149
|
+
control message and waits up to 5 seconds for responses which are then
|
|
150
|
+
pretty-printed to stdout.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
payload = json.loads(message)
|
|
155
|
+
except json.JSONDecodeError as exc:
|
|
156
|
+
raise click.BadParameter(f"Invalid JSON: {exc}") from exc
|
|
157
|
+
|
|
158
|
+
target_gateway = gateway or os.getenv(GATEWAY_ENV) or GATEWAY_URL
|
|
159
|
+
|
|
160
|
+
async def _run() -> None:
|
|
161
|
+
keypair = get_or_create_keypair()
|
|
162
|
+
mgr = ConnectionManager(target_gateway, keypair)
|
|
163
|
+
await mgr.start()
|
|
164
|
+
|
|
165
|
+
# Wait until mux is available & authenticated (rudimentary – 2s timeout)
|
|
166
|
+
for _ in range(20):
|
|
167
|
+
if mgr.mux is not None:
|
|
168
|
+
break
|
|
169
|
+
await asyncio.sleep(0.1)
|
|
170
|
+
if mgr.mux is None:
|
|
171
|
+
click.echo("Failed to initialise connection – aborting.")
|
|
172
|
+
await mgr.stop()
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
# Send control frame on channel 0
|
|
176
|
+
ctl = mgr.mux.get_channel(0)
|
|
177
|
+
await ctl.send(payload)
|
|
178
|
+
|
|
179
|
+
# Print replies for a short time
|
|
180
|
+
try:
|
|
181
|
+
with click.progressbar(length=50, label="Waiting for replies") as bar:
|
|
182
|
+
for _ in range(50):
|
|
183
|
+
try:
|
|
184
|
+
reply = await asyncio.wait_for(ctl.recv(), timeout=0.1)
|
|
185
|
+
click.echo(click.style("< " + json.dumps(reply, indent=2), fg="cyan"))
|
|
186
|
+
except asyncio.TimeoutError:
|
|
187
|
+
pass
|
|
188
|
+
bar.update(1)
|
|
189
|
+
finally:
|
|
190
|
+
await mgr.stop()
|
|
191
|
+
|
|
192
|
+
asyncio.run(_run())
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import signal
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import json
|
|
9
|
+
import base64
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
import websockets
|
|
13
|
+
from websockets import WebSocketClientProtocol
|
|
14
|
+
|
|
15
|
+
from ..keypair import KeyPair
|
|
16
|
+
from .multiplex import Multiplexer
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConnectionManager:
|
|
22
|
+
"""Maintain a persistent connection to the Portacode gateway.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
gateway_url: str
|
|
27
|
+
WebSocket URL, e.g. ``wss://portacode.com/gateway``
|
|
28
|
+
keypair: KeyPair
|
|
29
|
+
User's public/private keypair used for authentication.
|
|
30
|
+
reconnect_delay: float
|
|
31
|
+
Seconds to wait before attempting to reconnect after an unexpected drop.
|
|
32
|
+
max_retries: int
|
|
33
|
+
Maximum number of reconnect attempts before giving up.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, gateway_url: str, keypair: KeyPair, reconnect_delay: float = 1.0, max_retries: int = 10):
|
|
37
|
+
self.gateway_url = gateway_url
|
|
38
|
+
self.keypair = keypair
|
|
39
|
+
self.reconnect_delay = reconnect_delay
|
|
40
|
+
self.max_retries = max_retries
|
|
41
|
+
|
|
42
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
43
|
+
self._stop_event = asyncio.Event()
|
|
44
|
+
|
|
45
|
+
self.websocket: Optional[WebSocketClientProtocol] = None
|
|
46
|
+
self.mux: Optional[Multiplexer] = None
|
|
47
|
+
|
|
48
|
+
async def start(self) -> None:
|
|
49
|
+
"""Start the background task that maintains the connection."""
|
|
50
|
+
if self._task is not None:
|
|
51
|
+
raise RuntimeError("Connection already running")
|
|
52
|
+
self._task = asyncio.create_task(self._runner())
|
|
53
|
+
|
|
54
|
+
async def stop(self) -> None:
|
|
55
|
+
"""Request graceful shutdown."""
|
|
56
|
+
self._stop_event.set()
|
|
57
|
+
if self._task is not None:
|
|
58
|
+
await self._task
|
|
59
|
+
|
|
60
|
+
async def _runner(self) -> None:
|
|
61
|
+
attempt = 0
|
|
62
|
+
while not self._stop_event.is_set():
|
|
63
|
+
try:
|
|
64
|
+
if attempt:
|
|
65
|
+
delay = min(self.reconnect_delay * 2 ** (attempt - 1), 30)
|
|
66
|
+
logger.warning("Reconnecting in %.1f s (attempt %d/%d)…", delay, attempt, self.max_retries)
|
|
67
|
+
await asyncio.sleep(delay)
|
|
68
|
+
logger.info("Connecting to gateway at %s", self.gateway_url)
|
|
69
|
+
async with websockets.connect(self.gateway_url) as ws:
|
|
70
|
+
# Reset attempt counter after successful connection
|
|
71
|
+
attempt = 0
|
|
72
|
+
|
|
73
|
+
self.websocket = ws
|
|
74
|
+
self.mux = Multiplexer(self.websocket.send)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Initialise terminal/control management (channel 0)
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
try:
|
|
80
|
+
from .terminal import TerminalManager # local import to avoid heavy deps on startup
|
|
81
|
+
self._terminal_manager = TerminalManager(self.mux) # noqa: pylint=attribute-defined-outside-init
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
logger.warning("TerminalManager unavailable: %s", exc)
|
|
84
|
+
|
|
85
|
+
# Authenticate – abort loop on auth failures
|
|
86
|
+
await self._authenticate()
|
|
87
|
+
|
|
88
|
+
# Start main receive loop until closed or stop requested
|
|
89
|
+
await self._listen()
|
|
90
|
+
except (OSError, websockets.WebSocketException) as exc:
|
|
91
|
+
attempt += 1
|
|
92
|
+
logger.warning("Connection error: %s", exc)
|
|
93
|
+
if attempt > self.max_retries:
|
|
94
|
+
logger.error("Maximum reconnect attempts reached, giving up.")
|
|
95
|
+
break
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logger.exception("Fatal error in connection manager: %s", exc)
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
async def _authenticate(self) -> None:
|
|
101
|
+
"""Challenge-response authentication with the gateway using base64 DER public key."""
|
|
102
|
+
assert self.websocket is not None, "WebSocket not ready"
|
|
103
|
+
# Step 1: Send public key as base64 DER
|
|
104
|
+
await self.websocket.send(self.keypair.public_key_der_b64())
|
|
105
|
+
# Step 2: Receive challenge or error
|
|
106
|
+
response = await self.websocket.recv()
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(response)
|
|
109
|
+
challenge = data["challenge"]
|
|
110
|
+
except Exception:
|
|
111
|
+
# Not a challenge, must be an error
|
|
112
|
+
raise RuntimeError(f"Gateway rejected authentication: {response}")
|
|
113
|
+
# Step 3: Sign challenge and send signature
|
|
114
|
+
signature = self.keypair.sign_challenge(challenge)
|
|
115
|
+
signature_b64 = base64.b64encode(signature).decode()
|
|
116
|
+
await self.websocket.send(json.dumps({"signature": signature_b64}))
|
|
117
|
+
# Step 4: Receive final status
|
|
118
|
+
status = await self.websocket.recv()
|
|
119
|
+
if status != "ok":
|
|
120
|
+
raise RuntimeError(f"Gateway rejected authentication: {status}")
|
|
121
|
+
# Print success message in green and show close instructions
|
|
122
|
+
try:
|
|
123
|
+
import click
|
|
124
|
+
click.echo(click.style("Successfully authenticated with the gateway.", fg="green"))
|
|
125
|
+
if sys.platform == "darwin":
|
|
126
|
+
click.echo(click.style("Press Cmd+C to close the connection.", fg="cyan"))
|
|
127
|
+
else:
|
|
128
|
+
click.echo(click.style("Press Ctrl+C to close the connection.", fg="cyan"))
|
|
129
|
+
except ImportError:
|
|
130
|
+
print("Successfully authenticated with the gateway.")
|
|
131
|
+
if sys.platform == "darwin":
|
|
132
|
+
print("Press Cmd+C to close the connection.")
|
|
133
|
+
else:
|
|
134
|
+
print("Press Ctrl+C to close the connection.")
|
|
135
|
+
|
|
136
|
+
async def _listen(self) -> None:
|
|
137
|
+
assert self.websocket is not None, "WebSocket not ready"
|
|
138
|
+
while not self._stop_event.is_set():
|
|
139
|
+
try:
|
|
140
|
+
message = await asyncio.wait_for(self.websocket.recv(), timeout=1.0)
|
|
141
|
+
if self.mux:
|
|
142
|
+
await self.mux.on_raw_message(message)
|
|
143
|
+
except asyncio.TimeoutError:
|
|
144
|
+
continue
|
|
145
|
+
except websockets.ConnectionClosed:
|
|
146
|
+
break
|
|
147
|
+
# Exit listen loop, trigger closure
|
|
148
|
+
try:
|
|
149
|
+
await self.websocket.close()
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def run_until_interrupt(manager: ConnectionManager) -> None:
|
|
155
|
+
stop_event = asyncio.Event()
|
|
156
|
+
|
|
157
|
+
def _stop(*_):
|
|
158
|
+
# TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
|
|
159
|
+
stop_event.set()
|
|
160
|
+
|
|
161
|
+
# Register SIGTERM handler (works on Unix, ignored on Windows)
|
|
162
|
+
try:
|
|
163
|
+
signal.signal(signal.SIGTERM, _stop)
|
|
164
|
+
except (AttributeError, ValueError):
|
|
165
|
+
pass # Not available on some platforms
|
|
166
|
+
|
|
167
|
+
# Register SIGINT handler (Ctrl+C)
|
|
168
|
+
try:
|
|
169
|
+
signal.signal(signal.SIGINT, _stop)
|
|
170
|
+
except (AttributeError, ValueError):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
await manager.start()
|
|
174
|
+
try:
|
|
175
|
+
await stop_event.wait()
|
|
176
|
+
except KeyboardInterrupt:
|
|
177
|
+
# TODO: Add cleanup logic here (e.g., close sockets, remove PID files, flush logs)
|
|
178
|
+
pass
|
|
179
|
+
await manager.stop()
|
|
180
|
+
# TODO: Add any final cleanup logic here (e.g., remove PID files, flush logs)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from asyncio import Queue
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
__all__ = ["Multiplexer", "Channel"]
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Channel:
|
|
15
|
+
"""Represents a virtual duplex channel over a single WebSocket connection."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, channel_id: int, multiplexer: "Multiplexer"):
|
|
18
|
+
self.id = channel_id
|
|
19
|
+
self._mux = multiplexer
|
|
20
|
+
self._incoming: "Queue[Any]" = asyncio.Queue()
|
|
21
|
+
|
|
22
|
+
async def send(self, payload: Any) -> None:
|
|
23
|
+
await self._mux._send_on_channel(self.id, payload)
|
|
24
|
+
|
|
25
|
+
async def recv(self) -> Any:
|
|
26
|
+
return await self._incoming.get()
|
|
27
|
+
|
|
28
|
+
# Internal API
|
|
29
|
+
async def _deliver(self, payload: Any) -> None:
|
|
30
|
+
await self._incoming.put(payload)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Multiplexer:
|
|
34
|
+
"""Very small message-based multiplexer.
|
|
35
|
+
|
|
36
|
+
Messages exchanged over the WebSocket are JSON objects with two keys:
|
|
37
|
+
|
|
38
|
+
* ``channel`` – integer channel id.
|
|
39
|
+
* ``payload`` – arbitrary JSON-serialisable object.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, send_func):
|
|
43
|
+
self._send_func = send_func # async function (str) -> None
|
|
44
|
+
self._channels: Dict[int, Channel] = {}
|
|
45
|
+
|
|
46
|
+
def get_channel(self, channel_id: int) -> Channel:
|
|
47
|
+
if channel_id not in self._channels:
|
|
48
|
+
self._channels[channel_id] = Channel(channel_id, self)
|
|
49
|
+
return self._channels[channel_id]
|
|
50
|
+
|
|
51
|
+
async def _send_on_channel(self, channel_id: int, payload: Any) -> None:
|
|
52
|
+
frame = json.dumps({"channel": channel_id, "payload": payload})
|
|
53
|
+
await self._send_func(frame)
|
|
54
|
+
|
|
55
|
+
async def on_raw_message(self, raw: str) -> None:
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(raw)
|
|
58
|
+
channel_id = int(data["channel"])
|
|
59
|
+
payload = data.get("payload")
|
|
60
|
+
except (ValueError, KeyError) as exc:
|
|
61
|
+
logger.warning("Discarding malformed frame: %s", exc)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
channel = self.get_channel(channel_id)
|
|
65
|
+
await channel._deliver(payload)
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Terminal session management for Portacode client.
|
|
4
|
+
|
|
5
|
+
This module allows the Portacode gateway to remotely create interactive shell
|
|
6
|
+
sessions on the client machine. Each session gets its own *virtual* channel
|
|
7
|
+
on the existing WebSocket connection so multiple interactive terminals can run
|
|
8
|
+
concurrently without opening extra sockets.
|
|
9
|
+
|
|
10
|
+
The current implementation intentionally stays simple so it works on both
|
|
11
|
+
Unix and Windows without extra native dependencies:
|
|
12
|
+
|
|
13
|
+
* On Unix we *try* to allocate a real PTY so command‐line applications detect
|
|
14
|
+
a TTY and are willing to emit colour sequences, progress bars, etc.
|
|
15
|
+
* On Windows we fall back to a standard subprocess with pipes. While this
|
|
16
|
+
means some programs may not detect a TTY, it still provides a usable shell
|
|
17
|
+
until winpty/pywinpty support is added in the future.
|
|
18
|
+
|
|
19
|
+
Each message exchanged with the gateway uses JSON frames on the **control
|
|
20
|
+
channel 0**. The schema is kept deliberately small for now – it will evolve
|
|
21
|
+
as we add more capabilities:
|
|
22
|
+
|
|
23
|
+
Gateway → Client commands (via channel 0)
|
|
24
|
+
-----------------------------------------
|
|
25
|
+
|
|
26
|
+
1. Start new terminal::
|
|
27
|
+
|
|
28
|
+
{"cmd": "terminal_start", "shell": "/bin/bash"} // *shell* optional
|
|
29
|
+
|
|
30
|
+
• Response (same channel):
|
|
31
|
+
{"event": "terminal_started", "terminal_id": "...", "channel": 123}
|
|
32
|
+
|
|
33
|
+
2. Send data to running terminal::
|
|
34
|
+
|
|
35
|
+
{"cmd": "terminal_send", "terminal_id": "...", "data": "ls -l\n"}
|
|
36
|
+
|
|
37
|
+
3. Close running terminal::
|
|
38
|
+
|
|
39
|
+
{"cmd": "terminal_stop", "terminal_id": "..."}
|
|
40
|
+
|
|
41
|
+
4. List terminals::
|
|
42
|
+
|
|
43
|
+
{"cmd": "terminal_list"}
|
|
44
|
+
• Response: {"event": "terminal_list", "sessions": [...]} // see code
|
|
45
|
+
|
|
46
|
+
5. System information::
|
|
47
|
+
|
|
48
|
+
{"cmd": "system_info"}
|
|
49
|
+
• Response: {"event": "system_info", "info": {...}}
|
|
50
|
+
|
|
51
|
+
Client → Gateway events (channel 0 unless stated otherwise)
|
|
52
|
+
---------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
• Terminal exited (channel 0)::
|
|
55
|
+
|
|
56
|
+
{"event": "terminal_exit", "terminal_id": "...", "returncode": 0}
|
|
57
|
+
|
|
58
|
+
• Terminal output (dedicated *session* channel)::
|
|
59
|
+
|
|
60
|
+
<raw utf-8 text frames> // JSON encoding avoided for efficiency
|
|
61
|
+
|
|
62
|
+
NOTE: For output we currently send **plain text** frames (not JSON) so binary
|
|
63
|
+
streams like progress bars or colour escapes are delivered unchanged. If the
|
|
64
|
+
payload must be JSON for some transports, base64-encoding could be used – but
|
|
65
|
+
that adds 33 % overhead so plain frames are preferred.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
import asyncio
|
|
69
|
+
import json
|
|
70
|
+
import logging
|
|
71
|
+
import os
|
|
72
|
+
import sys
|
|
73
|
+
import uuid
|
|
74
|
+
from asyncio.subprocess import Process
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import Any, Dict, Optional
|
|
77
|
+
from collections import deque
|
|
78
|
+
|
|
79
|
+
import psutil # type: ignore
|
|
80
|
+
|
|
81
|
+
from .multiplex import Multiplexer, Channel
|
|
82
|
+
|
|
83
|
+
logger = logging.getLogger(__name__)
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"TerminalManager",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_IS_WINDOWS = sys.platform.startswith("win")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TerminalSession:
|
|
94
|
+
"""Represents a local shell subprocess bound to a mux *channel*."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, session_id: str, proc: Process, channel: Channel):
|
|
97
|
+
self.id = session_id
|
|
98
|
+
self.proc = proc
|
|
99
|
+
self.channel = channel
|
|
100
|
+
self._reader_task: Optional[asyncio.Task[None]] = None
|
|
101
|
+
self._buffer: deque[str] = deque(maxlen=400) # keep last ~400 lines of raw output
|
|
102
|
+
|
|
103
|
+
async def start_io_forwarding(self) -> None:
|
|
104
|
+
"""Spawn background task that copies stdout/stderr to the channel."""
|
|
105
|
+
assert self.proc.stdout is not None, "stdout pipe not set"
|
|
106
|
+
|
|
107
|
+
async def _pump() -> None:
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
data = await self.proc.stdout.read(1024)
|
|
111
|
+
if not data:
|
|
112
|
+
break
|
|
113
|
+
# Send *raw* text – no JSON envelope, keep escapes intact.
|
|
114
|
+
text = data.decode(errors="ignore")
|
|
115
|
+
logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
|
|
116
|
+
self._buffer.append(text)
|
|
117
|
+
try:
|
|
118
|
+
await self.channel.send(text)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.warning("Failed to forward terminal output: %s", exc)
|
|
121
|
+
break
|
|
122
|
+
finally:
|
|
123
|
+
# Ensure process gets reaped if reader exits unexpectedly.
|
|
124
|
+
if self.proc and self.proc.returncode is None:
|
|
125
|
+
self.proc.kill()
|
|
126
|
+
|
|
127
|
+
self._reader_task = asyncio.create_task(_pump())
|
|
128
|
+
|
|
129
|
+
async def write(self, data: str) -> None:
|
|
130
|
+
if self.proc.stdin is None:
|
|
131
|
+
logger.warning("stdin pipe closed for terminal %s", self.id)
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
self.proc.stdin.write(data.encode())
|
|
135
|
+
await self.proc.stdin.drain()
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
138
|
+
|
|
139
|
+
async def stop(self) -> None:
|
|
140
|
+
if self.proc.returncode is None:
|
|
141
|
+
self.proc.terminate()
|
|
142
|
+
if self._reader_task:
|
|
143
|
+
await self._reader_task
|
|
144
|
+
await self.proc.wait()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
# Introspection helpers
|
|
148
|
+
# ------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def snapshot_buffer(self) -> str:
|
|
151
|
+
"""Return concatenated last buffer contents suitable for UI."""
|
|
152
|
+
return "".join(self._buffer)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TerminalManager:
|
|
156
|
+
"""Manage multiple *TerminalSession*s controlled over a mux channel."""
|
|
157
|
+
|
|
158
|
+
CONTROL_CHANNEL_ID = 0 # messages with JSON commands/events
|
|
159
|
+
|
|
160
|
+
def __init__(self, mux: Multiplexer):
|
|
161
|
+
self.mux = mux
|
|
162
|
+
self._sessions: Dict[str, TerminalSession] = {}
|
|
163
|
+
self._next_channel = 100 # channel ids >=100 reserved for terminals
|
|
164
|
+
# Start control-loop task
|
|
165
|
+
self._control_channel = self.mux.get_channel(self.CONTROL_CHANNEL_ID)
|
|
166
|
+
self._ctl_task = asyncio.create_task(self._control_loop())
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------
|
|
169
|
+
# Control loop – receives commands from gateway
|
|
170
|
+
# ---------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async def _control_loop(self) -> None:
|
|
173
|
+
while True:
|
|
174
|
+
message = await self._control_channel.recv()
|
|
175
|
+
# Older parts of the system may send *raw* str. Ensure dict.
|
|
176
|
+
if isinstance(message, str):
|
|
177
|
+
try:
|
|
178
|
+
message = json.loads(message)
|
|
179
|
+
except Exception:
|
|
180
|
+
logger.warning("Discarding non-JSON control frame: %s", message)
|
|
181
|
+
continue
|
|
182
|
+
if not isinstance(message, dict):
|
|
183
|
+
logger.warning("Invalid control frame type: %r", type(message))
|
|
184
|
+
continue
|
|
185
|
+
cmd = message.get("cmd")
|
|
186
|
+
if not cmd:
|
|
187
|
+
# Ignore frames that are *events* coming from the remote side
|
|
188
|
+
if message.get("event"):
|
|
189
|
+
continue
|
|
190
|
+
logger.warning("Missing 'cmd' in control frame: %s", message)
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
if cmd == "terminal_start":
|
|
194
|
+
shell = message.get("shell")
|
|
195
|
+
cwd = message.get("cwd")
|
|
196
|
+
await self._cmd_terminal_start(shell=shell, cwd=cwd)
|
|
197
|
+
elif cmd == "terminal_send":
|
|
198
|
+
await self._cmd_terminal_send(message)
|
|
199
|
+
elif cmd == "terminal_stop":
|
|
200
|
+
await self._cmd_terminal_stop(message)
|
|
201
|
+
elif cmd == "terminal_list":
|
|
202
|
+
await self._cmd_terminal_list()
|
|
203
|
+
elif cmd == "system_info":
|
|
204
|
+
await self._cmd_system_info()
|
|
205
|
+
else:
|
|
206
|
+
await self._send_error(f"Unknown cmd: {cmd}")
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.exception("Unhandled exception processing %s: %s", cmd, exc)
|
|
209
|
+
await self._send_error(str(exc))
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
# Individual command handlers
|
|
213
|
+
# ------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async def _cmd_terminal_start(self, *, shell: Optional[str], cwd: Optional[str] = None) -> None:
|
|
216
|
+
term_id = uuid.uuid4().hex
|
|
217
|
+
channel_id = self._allocate_channel_id()
|
|
218
|
+
channel = self.mux.get_channel(channel_id)
|
|
219
|
+
# Choose shell
|
|
220
|
+
if shell is None:
|
|
221
|
+
shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
|
|
222
|
+
logger.info("Launching terminal %s using shell=%s on channel=%d", term_id, shell, channel_id)
|
|
223
|
+
if _IS_WINDOWS:
|
|
224
|
+
# Windows: use ConPTY via pywinpty for full TTY semantics
|
|
225
|
+
try:
|
|
226
|
+
from winpty import PtyProcess # type: ignore
|
|
227
|
+
except ImportError as exc:
|
|
228
|
+
logger.error("winpty (pywinpty) not found – please install pywinpty: %s", exc)
|
|
229
|
+
await self._send_error("pywinpty not installed on client")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None)
|
|
233
|
+
|
|
234
|
+
class _WinPTYProxy:
|
|
235
|
+
"""Expose .pid and .returncode for compatibility with Linux branch."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, pty):
|
|
238
|
+
self._pty = pty
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def pid(self):
|
|
242
|
+
return self._pty.pid
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def returncode(self):
|
|
246
|
+
# None while running, else exitstatus
|
|
247
|
+
return None if self._pty.isalive() else self._pty.exitstatus
|
|
248
|
+
|
|
249
|
+
async def wait(self):
|
|
250
|
+
loop = asyncio.get_running_loop()
|
|
251
|
+
await loop.run_in_executor(None, self._pty.wait)
|
|
252
|
+
|
|
253
|
+
class WindowsTerminalSession(TerminalSession):
|
|
254
|
+
"""Terminal session backed by a Windows ConPTY."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, session_id: str, pty, channel: Channel):
|
|
257
|
+
super().__init__(session_id, _WinPTYProxy(pty), channel)
|
|
258
|
+
self._pty = pty
|
|
259
|
+
|
|
260
|
+
async def start_io_forwarding(self) -> None:
|
|
261
|
+
loop = asyncio.get_running_loop()
|
|
262
|
+
|
|
263
|
+
async def _pump() -> None:
|
|
264
|
+
try:
|
|
265
|
+
while True:
|
|
266
|
+
data = await loop.run_in_executor(None, self._pty.read, 1024)
|
|
267
|
+
if not data:
|
|
268
|
+
if not self._pty.isalive():
|
|
269
|
+
break
|
|
270
|
+
await asyncio.sleep(0.05)
|
|
271
|
+
continue
|
|
272
|
+
if isinstance(data, bytes):
|
|
273
|
+
text = data.decode(errors="ignore")
|
|
274
|
+
else:
|
|
275
|
+
text = data
|
|
276
|
+
logging.getLogger("portacode.terminal").debug(
|
|
277
|
+
f"[MUX] Terminal {self.id} output: {text!r}"
|
|
278
|
+
)
|
|
279
|
+
self._buffer.append(text)
|
|
280
|
+
try:
|
|
281
|
+
await self.channel.send(text)
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
logger.warning("Failed to forward terminal output: %s", exc)
|
|
284
|
+
break
|
|
285
|
+
finally:
|
|
286
|
+
if self._pty and self._pty.isalive():
|
|
287
|
+
self._pty.kill()
|
|
288
|
+
|
|
289
|
+
self._reader_task = asyncio.create_task(_pump())
|
|
290
|
+
|
|
291
|
+
async def write(self, data: str) -> None:
|
|
292
|
+
loop = asyncio.get_running_loop()
|
|
293
|
+
try:
|
|
294
|
+
await loop.run_in_executor(None, self._pty.write, data)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
logger.warning("Failed to write to terminal %s: %s", self.id, exc)
|
|
297
|
+
|
|
298
|
+
async def stop(self) -> None:
|
|
299
|
+
if self._pty.isalive():
|
|
300
|
+
self._pty.kill()
|
|
301
|
+
if self._reader_task:
|
|
302
|
+
await self._reader_task
|
|
303
|
+
|
|
304
|
+
session = WindowsTerminalSession(term_id, pty_proc, channel)
|
|
305
|
+
self._sessions[term_id] = session
|
|
306
|
+
await session.start_io_forwarding()
|
|
307
|
+
await self._control_channel.send(
|
|
308
|
+
{
|
|
309
|
+
"event": "terminal_started",
|
|
310
|
+
"terminal_id": term_id,
|
|
311
|
+
"channel": channel_id,
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
asyncio.create_task(self._watch_process_exit(session))
|
|
316
|
+
return # windows branch done
|
|
317
|
+
else:
|
|
318
|
+
# Unix: try real PTY for proper TTY semantics
|
|
319
|
+
try:
|
|
320
|
+
import pty
|
|
321
|
+
master_fd, slave_fd = pty.openpty()
|
|
322
|
+
proc = await asyncio.create_subprocess_exec(
|
|
323
|
+
shell,
|
|
324
|
+
stdin=slave_fd,
|
|
325
|
+
stdout=slave_fd,
|
|
326
|
+
stderr=slave_fd,
|
|
327
|
+
preexec_fn=os.setsid,
|
|
328
|
+
cwd=cwd,
|
|
329
|
+
)
|
|
330
|
+
# Wrap master_fd into a StreamReader
|
|
331
|
+
loop = asyncio.get_running_loop()
|
|
332
|
+
reader = asyncio.StreamReader()
|
|
333
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
334
|
+
await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
|
|
335
|
+
proc.stdout = reader # type: ignore[assignment]
|
|
336
|
+
# Use writer for stdin
|
|
337
|
+
writer_transport, writer_protocol = await loop.connect_write_pipe(lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0))
|
|
338
|
+
proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop) # type: ignore[assignment]
|
|
339
|
+
except Exception:
|
|
340
|
+
logger.warning("Failed to allocate PTY, falling back to pipes")
|
|
341
|
+
proc = await asyncio.create_subprocess_exec(
|
|
342
|
+
shell,
|
|
343
|
+
stdin=asyncio.subprocess.PIPE,
|
|
344
|
+
stdout=asyncio.subprocess.PIPE,
|
|
345
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
346
|
+
cwd=cwd,
|
|
347
|
+
)
|
|
348
|
+
session = TerminalSession(term_id, proc, channel)
|
|
349
|
+
self._sessions[term_id] = session
|
|
350
|
+
await session.start_io_forwarding()
|
|
351
|
+
await self._control_channel.send(
|
|
352
|
+
{
|
|
353
|
+
"event": "terminal_started",
|
|
354
|
+
"terminal_id": term_id,
|
|
355
|
+
"channel": channel_id,
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Also create background watcher for process exit
|
|
360
|
+
asyncio.create_task(self._watch_process_exit(session))
|
|
361
|
+
|
|
362
|
+
async def _cmd_terminal_send(self, msg: Dict[str, Any]) -> None:
|
|
363
|
+
term_id = msg.get("terminal_id")
|
|
364
|
+
data = msg.get("data", "")
|
|
365
|
+
session = self._sessions.get(term_id)
|
|
366
|
+
if not session:
|
|
367
|
+
await self._send_error(f"terminal_id {term_id} not found")
|
|
368
|
+
return
|
|
369
|
+
await session.write(data)
|
|
370
|
+
|
|
371
|
+
async def _cmd_terminal_stop(self, msg: Dict[str, Any]) -> None:
|
|
372
|
+
term_id = msg.get("terminal_id")
|
|
373
|
+
session = self._sessions.pop(term_id, None)
|
|
374
|
+
if not session:
|
|
375
|
+
await self._send_error(f"terminal_id {term_id} not found")
|
|
376
|
+
return
|
|
377
|
+
await session.stop()
|
|
378
|
+
await self._control_channel.send({"event": "terminal_stopped", "terminal_id": term_id})
|
|
379
|
+
|
|
380
|
+
async def _cmd_terminal_list(self) -> None:
|
|
381
|
+
sessions = [
|
|
382
|
+
{
|
|
383
|
+
"terminal_id": s.id,
|
|
384
|
+
"channel": s.channel.id,
|
|
385
|
+
"pid": s.proc.pid,
|
|
386
|
+
"returncode": s.proc.returncode,
|
|
387
|
+
"buffer": s.snapshot_buffer(),
|
|
388
|
+
}
|
|
389
|
+
for s in self._sessions.values()
|
|
390
|
+
]
|
|
391
|
+
await self._control_channel.send({"event": "terminal_list", "sessions": sessions})
|
|
392
|
+
|
|
393
|
+
async def _cmd_system_info(self) -> None:
|
|
394
|
+
info = {
|
|
395
|
+
"cpu_percent": psutil.cpu_percent(interval=0.1),
|
|
396
|
+
"memory": psutil.virtual_memory()._asdict(),
|
|
397
|
+
"disk": psutil.disk_usage(str(Path.home()))._asdict(),
|
|
398
|
+
}
|
|
399
|
+
await self._control_channel.send({"event": "system_info", "info": info})
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# Helpers
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
async def _send_error(self, message: str) -> None:
|
|
406
|
+
await self._control_channel.send({"event": "error", "message": message})
|
|
407
|
+
|
|
408
|
+
def _allocate_channel_id(self) -> int:
|
|
409
|
+
cid = self._next_channel
|
|
410
|
+
self._next_channel += 1
|
|
411
|
+
return cid
|
|
412
|
+
|
|
413
|
+
async def _watch_process_exit(self, session: TerminalSession) -> None:
|
|
414
|
+
await session.proc.wait()
|
|
415
|
+
await self._control_channel.send({
|
|
416
|
+
"event": "terminal_exit",
|
|
417
|
+
"terminal_id": session.id,
|
|
418
|
+
"returncode": session.proc.returncode,
|
|
419
|
+
})
|
|
420
|
+
# Cleanup session table
|
|
421
|
+
self._sessions.pop(session.id, None)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from platformdirs import user_data_dir
|
|
8
|
+
|
|
9
|
+
APP_NAME = "portacode"
|
|
10
|
+
APP_AUTHOR = "Portacode"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_data_dir() -> Path:
|
|
14
|
+
"""Return the platform-specific *user data* directory for Portacode.
|
|
15
|
+
|
|
16
|
+
This is where all persistent user data (keypairs, logs, pid files, …) is stored.
|
|
17
|
+
The directory is created on first use.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
data_dir = Path(user_data_dir(APP_NAME, APP_AUTHOR))
|
|
21
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return data_dir
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_key_dir() -> Path:
|
|
26
|
+
key_dir = get_data_dir() / "keys"
|
|
27
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return key_dir
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_runtime_dir() -> Path:
|
|
32
|
+
"""Return directory for runtime files (pid, sockets, …)."""
|
|
33
|
+
runtime_dir = get_data_dir() / "run"
|
|
34
|
+
runtime_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
return runtime_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_pid_file() -> Path:
|
|
39
|
+
return get_runtime_dir() / "gateway.pid"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_process_running(pid: int) -> bool:
|
|
43
|
+
"""Check whether *pid* refers to a currently running process."""
|
|
44
|
+
if sys.platform.startswith("win"):
|
|
45
|
+
import ctypes
|
|
46
|
+
import ctypes.wintypes as wintypes
|
|
47
|
+
|
|
48
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
49
|
+
handle = ctypes.windll.kernel32.OpenProcess(
|
|
50
|
+
PROCESS_QUERY_LIMITED_INFORMATION, False, pid
|
|
51
|
+
)
|
|
52
|
+
if handle == 0:
|
|
53
|
+
return False
|
|
54
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
55
|
+
return True
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
os.kill(pid, 0)
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
import logging
|
|
6
|
+
import base64
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
10
|
+
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
|
11
|
+
|
|
12
|
+
from .data import get_key_dir
|
|
13
|
+
|
|
14
|
+
PRIVATE_KEY_FILE = "id_portacode"
|
|
15
|
+
PUBLIC_KEY_FILE = "id_portacode.pub"
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
|
|
18
|
+
|
|
19
|
+
class KeyPair:
|
|
20
|
+
"""Container for RSA keypair paths and objects."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, private_key_path: Path, public_key_path: Path):
|
|
23
|
+
self.private_key_path = private_key_path
|
|
24
|
+
self.public_key_path = public_key_path
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def private_key_pem(self) -> bytes:
|
|
28
|
+
return self.private_key_path.read_bytes()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def public_key_pem(self) -> bytes:
|
|
32
|
+
return self.public_key_path.read_bytes()
|
|
33
|
+
|
|
34
|
+
def sign_challenge(self, challenge: str) -> bytes:
|
|
35
|
+
"""Sign a challenge string with the private key."""
|
|
36
|
+
private_key = serialization.load_pem_private_key(
|
|
37
|
+
self.private_key_pem, password=None
|
|
38
|
+
)
|
|
39
|
+
return private_key.sign(
|
|
40
|
+
challenge.encode(),
|
|
41
|
+
padding.PKCS1v15(),
|
|
42
|
+
hashes.SHA256(),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def public_key_der_b64(self) -> str:
|
|
46
|
+
"""Return the public key as base64-encoded DER (single line)."""
|
|
47
|
+
pubkey = serialization.load_pem_public_key(self.public_key_pem)
|
|
48
|
+
der = pubkey.public_bytes(encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
49
|
+
return base64.b64encode(der).decode()
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def der_b64_to_pem(der_b64: str) -> bytes:
|
|
53
|
+
"""Convert base64 DER to PEM format."""
|
|
54
|
+
der = base64.b64decode(der_b64)
|
|
55
|
+
pubkey = serialization.load_der_public_key(der)
|
|
56
|
+
return pubkey.public_bytes(encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _generate_keypair() -> Tuple[bytes, bytes]:
|
|
60
|
+
# Use 1024 bits for smaller demo keys (not secure for production)
|
|
61
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=1024)
|
|
62
|
+
private_pem = private_key.private_bytes(
|
|
63
|
+
encoding=Encoding.PEM,
|
|
64
|
+
format=PrivateFormat.TraditionalOpenSSL,
|
|
65
|
+
encryption_algorithm=NoEncryption(),
|
|
66
|
+
)
|
|
67
|
+
public_pem = (
|
|
68
|
+
private_key.public_key()
|
|
69
|
+
.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
|
|
70
|
+
)
|
|
71
|
+
return private_pem, public_pem
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_or_create_keypair() -> KeyPair:
|
|
75
|
+
"""Return the existing keypair or generate a new one if missing."""
|
|
76
|
+
key_dir = get_key_dir()
|
|
77
|
+
priv_path = key_dir / PRIVATE_KEY_FILE
|
|
78
|
+
pub_path = key_dir / PUBLIC_KEY_FILE
|
|
79
|
+
|
|
80
|
+
if not priv_path.exists() or not pub_path.exists():
|
|
81
|
+
logging.info(f"No keys found, generating new one and saving to {key_dir}")
|
|
82
|
+
private_pem, public_pem = _generate_keypair()
|
|
83
|
+
priv_path.write_bytes(private_pem)
|
|
84
|
+
pub_path.write_bytes(public_pem)
|
|
85
|
+
else:
|
|
86
|
+
logging.info(f"Found existing keys at {key_dir}")
|
|
87
|
+
|
|
88
|
+
return KeyPair(priv_path, pub_path)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def fingerprint_public_key(pem: bytes) -> str:
|
|
92
|
+
"""Return a short fingerprint for display purposes (SHA-256)."""
|
|
93
|
+
digest = hashes.Hash(hashes.SHA256())
|
|
94
|
+
digest.update(pem)
|
|
95
|
+
return digest.finalize().hex()[:16]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: portacode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Portacode CLI client and SDK
|
|
5
|
+
Home-page: https://github.com/portacode/portacode
|
|
6
|
+
Author: Meena Erian
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: click>=8.0
|
|
13
|
+
Requires-Dist: platformdirs>=3.0
|
|
14
|
+
Requires-Dist: cryptography>=41.0
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
Requires-Dist: pyperclip>=1.8
|
|
17
|
+
Requires-Dist: psutil>=5.9
|
|
18
|
+
Requires-Dist: pyte>=0.8
|
|
19
|
+
Requires-Dist: pywinpty>=2.0; platform_system == "Windows"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: black; extra == "dev"
|
|
22
|
+
Requires-Dist: flake8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest; extra == "dev"
|
|
24
|
+
Dynamic: author
|
|
25
|
+
Dynamic: classifier
|
|
26
|
+
Dynamic: description
|
|
27
|
+
Dynamic: description-content-type
|
|
28
|
+
Dynamic: home-page
|
|
29
|
+
Dynamic: provides-extra
|
|
30
|
+
Dynamic: requires-dist
|
|
31
|
+
Dynamic: requires-python
|
|
32
|
+
Dynamic: summary
|
|
33
|
+
|
|
34
|
+
# Portacode
|
|
35
|
+
|
|
36
|
+
Portacode is a modular Python package that provides a command-line interface for connecting your local machine to the Portacode cloud gateway.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ pip install portacode
|
|
40
|
+
$ portacode connect
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The first release only ships the `connect` command which:
|
|
44
|
+
|
|
45
|
+
1. Creates an RSA public/private key-pair (if not already present) in a platform-specific data directory.
|
|
46
|
+
2. Guides you through adding the public key to your Portacode account.
|
|
47
|
+
3. Establishes and maintains a resilient WebSocket connection to `wss://portacode.com/gateway`.
|
|
48
|
+
|
|
49
|
+
Future releases will add more sub-commands and build upon the multiplexing channel infrastructure already included in this version.
|
|
50
|
+
|
|
51
|
+
## Project layout
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
portacode/ ‑ Top-level package
|
|
55
|
+
├── cli.py ‑ Click-based CLI entry-point
|
|
56
|
+
├── data.py ‑ Cross-platform data-directory helpers
|
|
57
|
+
├── keypair.py ‑ RSA key generation & storage
|
|
58
|
+
├── connection/ ‑ Networking & multiplexing logic
|
|
59
|
+
│ ├── client.py ‑ WebSocket client with auto-reconnect
|
|
60
|
+
│ └── multiplex.py- Virtual channel multiplexer
|
|
61
|
+
└── …
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See the README files inside each sub-module for more details.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
portacode/__init__.py
|
|
5
|
+
portacode/cli.py
|
|
6
|
+
portacode/data.py
|
|
7
|
+
portacode/keypair.py
|
|
8
|
+
portacode.egg-info/PKG-INFO
|
|
9
|
+
portacode.egg-info/SOURCES.txt
|
|
10
|
+
portacode.egg-info/dependency_links.txt
|
|
11
|
+
portacode.egg-info/entry_points.txt
|
|
12
|
+
portacode.egg-info/requires.txt
|
|
13
|
+
portacode.egg-info/top_level.txt
|
|
14
|
+
portacode/connection/__init__.py
|
|
15
|
+
portacode/connection/client.py
|
|
16
|
+
portacode/connection/multiplex.py
|
|
17
|
+
portacode/connection/terminal.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
portacode
|
portacode-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from setuptools import find_packages, setup
|
|
4
|
+
|
|
5
|
+
PACKAGE_NAME = "portacode"
|
|
6
|
+
ROOT = Path(__file__).parent
|
|
7
|
+
README = (ROOT / "README.md").read_text(encoding="utf-8")
|
|
8
|
+
|
|
9
|
+
setup(
|
|
10
|
+
name=PACKAGE_NAME,
|
|
11
|
+
version="0.1.0",
|
|
12
|
+
description="Portacode CLI client and SDK",
|
|
13
|
+
long_description=README,
|
|
14
|
+
long_description_content_type="text/markdown",
|
|
15
|
+
author="Meena Erian",
|
|
16
|
+
url="https://github.com/portacode/portacode",
|
|
17
|
+
packages=find_packages(exclude=("tests", "server")),
|
|
18
|
+
python_requires=">=3.8",
|
|
19
|
+
install_requires=[
|
|
20
|
+
"click>=8.0",
|
|
21
|
+
"platformdirs>=3.0",
|
|
22
|
+
"cryptography>=41.0",
|
|
23
|
+
"websockets>=12.0",
|
|
24
|
+
"pyperclip>=1.8",
|
|
25
|
+
"psutil>=5.9",
|
|
26
|
+
"pyte>=0.8",
|
|
27
|
+
"pywinpty>=2.0; platform_system=='Windows'",
|
|
28
|
+
],
|
|
29
|
+
extras_require={
|
|
30
|
+
"dev": ["black", "flake8", "pytest"],
|
|
31
|
+
},
|
|
32
|
+
entry_points={
|
|
33
|
+
"console_scripts": [
|
|
34
|
+
"portacode=portacode.cli:cli",
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
classifiers=[
|
|
38
|
+
"Programming Language :: Python :: 3",
|
|
39
|
+
"License :: OSI Approved :: MIT License",
|
|
40
|
+
"Operating System :: OS Independent",
|
|
41
|
+
],
|
|
42
|
+
include_package_data=True,
|
|
43
|
+
)
|